<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: M. Alwi Sukra</title>
    <description>The latest articles on DEV Community by M. Alwi Sukra (@arkoesalwi).</description>
    <link>https://dev.to/arkoesalwi</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3922142%2F6181c905-c563-4315-acd8-5eec06d607b5.jpg</url>
      <title>DEV Community: M. Alwi Sukra</title>
      <link>https://dev.to/arkoesalwi</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/arkoesalwi"/>
    <language>en</language>
    <item>
      <title>TIL - Choosing Between Code, an LLM Call, and an AI Agent</title>
      <dc:creator>M. Alwi Sukra</dc:creator>
      <pubDate>Mon, 27 Jul 2026 02:16:42 +0000</pubDate>
      <link>https://dev.to/arkoesalwi/til-choosing-between-code-an-llm-call-and-an-ai-agent-3n8c</link>
      <guid>https://dev.to/arkoesalwi/til-choosing-between-code-an-llm-call-and-an-ai-agent-3n8c</guid>
      <description>&lt;p&gt;Two questions sent me down this path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Question one: what is an "AI agent," really?&lt;/strong&gt; Most job posts mention them. I had not looked into it deeply, and from the outside I could not tell what it referred to. Is an agent a different endpoint? A different model? A library we install? Or is it just a name for calling an LLM API in some particular way?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Question two came later, and it stopped me cold.&lt;/strong&gt; Once I understood the mechanics and sat down to pick something to build, I could not come up with a single idea that I could not also write as ordinary Go: a function that calls some APIs, hits a database, and returns a result. If plain code can do it, why pay a model to do it worse?&lt;/p&gt;

&lt;p&gt;This is what I found. There is a real answer to both, and the second one has a trap in it.&lt;/p&gt;




&lt;h2&gt;
  
  
  So I built both
&lt;/h2&gt;

&lt;p&gt;Code: &lt;a href="https://github.com/Arkoes07/llm" rel="noopener noreferrer"&gt;github.com/Arkoes07/llm&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One Go service, one interface, four endpoints of increasing complexity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /chat/no-memory        one stateless LLM call
POST /chat                  multi-turn, history per session
POST /chat/agent/weather    tool-calling loop, one tool
POST /chat/agent/log-triage tool-calling loop, three tools
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No framework. Hand-rolled &lt;code&gt;net/http&lt;/code&gt; first, then a second implementation behind the same interface using an SDK, so I could see exactly what the SDK was doing for me. (Answer: it removed boilerplate, not thinking. The loop logic was identical in both). Worth noting: Groq's official SDKs are Python and JS only, so in Go we are on community libraries or an OpenAI-compatible client.&lt;/p&gt;

&lt;p&gt;Starting at a plain call and walking up to an agent one step at a time turned out to be the whole answer to question one.&lt;/p&gt;




&lt;h2&gt;
  
  
  An agent is a while-loop
&lt;/h2&gt;

&lt;p&gt;Here is the version with no vocabulary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A plain LLM call&lt;/strong&gt; is a stateless HTTP request. We send messages, we get text back. The model remembers nothing. "Conversation history" is state we store and resend every time. That is &lt;code&gt;/chat/no-memory&lt;/code&gt; and &lt;code&gt;/chat&lt;/code&gt; above, and the only difference between them is who keeps the array.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An agent&lt;/strong&gt; adds one thing: we also send a list of tools, described as JSON schemas. Now the model can respond with "call &lt;code&gt;get_metrics&lt;/code&gt; with &lt;code&gt;{"service":"checkout"}&lt;/code&gt;" instead of a final answer. So we loop:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Send messages plus tool schemas.&lt;/li&gt;
&lt;li&gt;Model replies with either a final answer, or a tool request.&lt;/li&gt;
&lt;li&gt;Our code executes the tool. The model executes nothing, it only asks.&lt;/li&gt;
&lt;li&gt;Append the result to the history, call again.&lt;/li&gt;
&lt;li&gt;Repeat until it stops asking, or we hit an iteration cap.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is the difference. Same endpoint, same model, about 150 lines of extra Go. Every agent framework is this loop plus state persistence, retries, and tracing.&lt;/p&gt;

&lt;p&gt;What I realized while building it is that most of the hard parts are things we already know under different names:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agent problem&lt;/th&gt;
&lt;th&gt;What it actually is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tool dispatch + schema validation&lt;/td&gt;
&lt;td&gt;RPC dispatch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model calls the same tool twice&lt;/td&gt;
&lt;td&gt;Idempotency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-step work that must not half-complete&lt;/td&gt;
&lt;td&gt;Saga / compensation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Context window overflow&lt;/td&gt;
&lt;td&gt;Bounded cache with an eviction policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Untrusted text in the prompt issuing instructions&lt;/td&gt;
&lt;td&gt;An authz boundary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runaway loop&lt;/td&gt;
&lt;td&gt;Circuit breaker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Did this actually work?"&lt;/td&gt;
&lt;td&gt;Regression testing, for non-deterministic output&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;There is a reason the table lines up like that, and the loop hides it.&lt;/p&gt;

&lt;p&gt;From inside the code it looks like one API call repeated. But we call the model, the model asks for a tool, we call that tool (in a real system, another service: a logging backend, a metrics API, a runbook store), then we call the model again. One request fans out across several independent services, in an order nobody fixed in advance. That is orchestration, and we already have a name for it: a distributed system.&lt;/p&gt;

&lt;p&gt;The difference is which part we cannot trust. Normally the orchestrator is our own code, the most reliable thing in the system, and we save our suspicion for the dependencies. In an agent, the orchestrator is the model. The component deciding what to call next is now the least reliable one: it picks a different sequence on identical input, it can emit a malformed request, and when it fails it usually fails plausibly instead of loudly.&lt;/p&gt;

&lt;p&gt;So the instincts still apply, we just point them somewhere new. Validate every argument. Make writes idempotent, because the caller may repeat them. Bound the loop. Treat anything a tool returns as data, never as instructions. What we already do at the edge of a system, applied to the orchestration we are used to trusting.&lt;/p&gt;

&lt;p&gt;What is actually new is a short list: prompting, structured output, evals, and retrieval.&lt;/p&gt;

&lt;p&gt;Question one, answered. Which is when question two showed up.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why would I ever use this?
&lt;/h2&gt;

&lt;p&gt;I needed something to build. And every idea I had, I could immediately picture as normal Go.&lt;/p&gt;

&lt;p&gt;"Generate a study plan" is one LLM call with a structured output, wrapped in a function. No loop. "Classify support tickets" is one call, then a &lt;code&gt;switch&lt;/code&gt; on the result. "Summarize a document" is one call. Every time I reached for the loop, plain code was cheaper, faster, deterministic, and testable.&lt;/p&gt;

&lt;p&gt;That is not a small objection. It is the whole question. The loop costs us money (tokens), latency (seconds per iteration), determinism (same input, different output), and debuggability (behavior lives in prose, not code). What are we buying with all that?&lt;/p&gt;

&lt;p&gt;Exactly one thing: &lt;strong&gt;the model chooses the next step at runtime, using information that did not exist when we wrote the code.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not intelligence, and not language. Those come from a single call. The loop buys runtime branch selection and nothing else.&lt;/p&gt;

&lt;p&gt;So the question becomes: when do we actually need that?&lt;/p&gt;




&lt;h2&gt;
  
  
  The criterion I landed on
&lt;/h2&gt;

&lt;p&gt;The common framing is "simple problems go to code, complex problems go to an agent." Too vague to act on. Here is the version I would use:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Can we draw the flowchart before we see the input?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If yes, the decision points are enumerable and we can write the branches, so we should write them. Code is deterministic, testable, debuggable, and roughly free. An agent there is pure overhead, and the LLM's job, if it has one, is a single call inside one box: classify, extract, summarize. No loop.&lt;/p&gt;

&lt;p&gt;If no, meaning the path depends on what each step reveals and the real tree has thousands of branches we would only discover in the moment, that is the narrow case where the loop earns its cost.&lt;/p&gt;

&lt;p&gt;That criterion is why I picked &lt;strong&gt;log triage&lt;/strong&gt;. Consider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;latencyHigh&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;checkDatabase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;dbSlow&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;checkConnectionPool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;checkDownstream&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c"&gt;// which one? there are 30&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We can write this. But which downstream service? Depends on what the logs said. What if the DB check reveals a deploy changed a query plan? We did not have a branch for that. The order we investigate depends on what each step returns.&lt;/p&gt;

&lt;p&gt;That is the trigger. Not "it is complicated," but &lt;strong&gt;"I cannot enumerate the branches at code-writing time."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Keep this criterion in mind. The rest of the post is about what happens when we pass it and then slowly undo our own answer.&lt;/p&gt;




&lt;h2&gt;
  
  
  The test case
&lt;/h2&gt;

&lt;p&gt;A fake incident with a deliberate lie in it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;checkout&lt;/code&gt; p99 spiked at 14:03. But checkout is fine. Its DB pool is healthy (4/20) and traffic is normal. It calls &lt;code&gt;payment&lt;/code&gt;, whose &lt;code&gt;max_pool_size&lt;/code&gt; was quietly lowered 20 → 5 by a config reload at &lt;strong&gt;13:58&lt;/strong&gt;. Payment saturated its pool and started timing out. Checkout is the victim, not the cause.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Three tools over hardcoded data: &lt;code&gt;query_logs&lt;/code&gt;, &lt;code&gt;get_metrics&lt;/code&gt;, &lt;code&gt;search_runbook&lt;/code&gt;. Getting it right means ruling out checkout's own resources, hopping to the downstream service, and (the trap) widening the time window past the incident to catch that 13:58 config change.&lt;/p&gt;

&lt;p&gt;Mocked data is a feature, not a shortcut. Every failure is then unambiguously the loop's, not flaky infrastructure.&lt;/p&gt;




&lt;h2&gt;
  
  
  Then it went wrong, five times
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. It cited a runbook it never opened
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;iteration 1: query_logs(checkout, 13:55-14:05)
iteration 2: get_metrics(checkout, latency)
iteration 3: get_metrics(checkout, error_rate)
iteration 4: get_metrics(payment, latency)
iteration 5: get_metrics(checkout, request_rate)
iteration 6: query_logs(payment, 13:55-14:05)
iteration 7: get_metrics(payment, db_pool)
iteration 8: get_metrics(checkout, db_pool)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what is missing: &lt;code&gt;search_runbook&lt;/code&gt;, never called. Now the answer it produced:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;...These symptoms match the known runbook pattern for "Database connection-pool exhaustion".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Remediation (per the runbook)&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Restore / increase the DB pool size for the payment service&lt;/li&gt;
&lt;li&gt;Restart the payment service to clear lingering connection-wait states&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;

&lt;p&gt;The root cause was correct. The provenance was invented. It attributed general knowledge to an internal document it never opened, and step 2 was not in my runbook at all.&lt;/p&gt;

&lt;p&gt;Nothing in the output looked wrong. Plausible content, authoritative formatting, correct conclusion. &lt;strong&gt;The only way to catch it was diffing the answer against the trace.&lt;/strong&gt; Now imagine a real runbook saying "do NOT restart, drain connections first, page the DBA."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix (system prompt):&lt;/strong&gt; "Only cite the runbook if you called search_runbook. Otherwise say the recommendation is based on general knowledge."&lt;/p&gt;

&lt;p&gt;The lesson: &lt;strong&gt;an agent can reach a correct answer through invalid reasoning and give us no signal that it did.&lt;/strong&gt; That is the argument for evals, and it is checkable. "Every source cited must appear in the tool trace" is an assertion we can write.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. It stopped at the first plausible explanation
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;iteration 1: query_logs(checkout, 13:55-14:05)
iteration 2: get_metrics(checkout, error_rate)
iteration 3: search_runbook("payment.authorize timeout")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three calls, then it concluded: tune the client timeout, add a circuit breaker. Plausible, and wrong. It never checked whether checkout's own DB was healthy or whether traffic was abnormal. It gathered evidence &lt;em&gt;for&lt;/em&gt; its first hypothesis instead of testing alternatives. A human had to type "continue, check the downstream."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix (system prompt):&lt;/strong&gt; "Do not stop at the first plausible explanation. Before concluding, rule out alternatives: check whether the service's own resources are healthy and whether traffic is abnormal."&lt;/p&gt;

&lt;h3&gt;
  
  
  3. It searched the runbook before it knew what to search for
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;iteration 6: search_runbook("payment.Authorize timeout")   ← searched here
iteration 7: get_metrics(payment, latency)
iteration 8: query_logs(payment, 13:55-14:05)              ← found the cause here
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It searched using the symptom it had at iteration 6, found the actual root cause at iteration 8, and never searched again. A human does this reflexively: it is pool exhaustion, so pull up that runbook. &lt;strong&gt;The loop does not re-plan by default.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix (system prompt):&lt;/strong&gt; "After identifying a root cause, search the runbook again using the root-cause terms, not the original symptom."&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The caller emitted an invalid call
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"tool_use_failed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"failed_generation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;function=query_logs{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;service&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;checkout&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}&amp;lt;/function&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Right tool, right arguments, malformed syntax. It is missing one &lt;code&gt;&amp;gt;&lt;/code&gt; after the function name. A gRPC client cannot do this; the wire format is generated and guaranteed. Here the caller's ability to form a valid request is itself probabilistic.&lt;/p&gt;

&lt;p&gt;It also broke my retry logic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;attempt 1: &amp;lt;function=query_logs{...
attempt 2: &amp;lt;function=query_logs{...   ← identical
attempt 3: &amp;lt;function=query_logs={...  ← one char different
attempt 4: &amp;lt;function=query_logs{...   ← identical again
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four attempts, 15 seconds of backoff, the same malformed output. &lt;strong&gt;Retry only helps if the output would differ.&lt;/strong&gt; At low temperature the model is near-deterministic, so an identical request produces an identical failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix (code, then model):&lt;/strong&gt; drop &lt;code&gt;tool_use_failed&lt;/code&gt; from the retryable set, so we fail fast instead of burning 15 seconds. Then switch models. I did not see it again on &lt;code&gt;openai/gpt-oss-120b&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Context growth is the cost model
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Rate limit reached ... tokens per minute (TPM): Limit 8000, Used 6886, Requested 1311
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every iteration resends the entire history, so iteration 8 carries all seven prior tool results. Eight iterations, under a minute, into a hard wall. Not because any single call was large, but because they compound. Log &lt;code&gt;prompt_tokens&lt;/code&gt; per iteration; that curve is the most honest thing we can show about agent economics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix (code):&lt;/strong&gt; exponential backoff on 429, honoring the &lt;code&gt;Retry-After&lt;/code&gt; header rather than our own curve. This is a mitigation, not a fix. The growth is inherent to the loop.&lt;/p&gt;




&lt;h2&gt;
  
  
  The two kinds of guardrail
&lt;/h2&gt;

&lt;p&gt;Look back at the labels on those fixes. Two were code, and code fixes are just engineering: better retry classification, better backoff. Nothing interesting there.&lt;/p&gt;

&lt;p&gt;The other three were system prompt. And those three are not the same species.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Type A: guardrails that improve judgment.&lt;/strong&gt;&lt;br&gt;
Better tool descriptions. "Rule out alternatives before concluding." "Cite the runbook only if you called it." These do not shrink the option space, they help the model navigate the same space better. Nearly free. Keep them, add more.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Type B: guardrails that encode the flowchart.&lt;/strong&gt;&lt;br&gt;
"Always call &lt;code&gt;query_logs&lt;/code&gt; before &lt;code&gt;get_metrics&lt;/code&gt;." "After identifying a root cause, search the runbook again." These remove options. Each one is a branch, written in English instead of Go.&lt;/p&gt;

&lt;p&gt;The test is mechanical:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;If we can state the guardrail as a deterministic rule, we can write it as code. And if we can write it as code, it does not belong in the prompt.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Fix #3 is mine, and it is Type B. "After identifying a root cause, search again with the root-cause terms" is a sequencing rule. I could enforce it in code: if the loop is about to terminate and &lt;code&gt;search_runbook&lt;/code&gt; was never called with the root-cause terms, force one more iteration. Deterministic, testable, free. Instead I wrote a branch in English and paid a model to interpret it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That is the paradox.&lt;/strong&gt; Remember what the loop buys us: the model choosing at runtime. Every Type B guardrail takes some of that back. We are paying full price for a capability we are actively suppressing. Push far enough and we have a complete flowchart written in English, executed by a probabilistic interpreter, at a hundred times the cost of an &lt;code&gt;if&lt;/code&gt;, with no type checking, no test coverage, and no way to diff a behavior change in code review.&lt;/p&gt;

&lt;p&gt;We did not build an agent. We built the world's most expensive &lt;code&gt;switch&lt;/code&gt; statement and moved it out of version control.&lt;/p&gt;

&lt;p&gt;And notice the shape of it. I passed the flowchart criterion honestly (log triage really is not enumerable) and then spent three fixes quietly re-enumerating it anyway.&lt;/p&gt;

&lt;p&gt;Which means the two ideas in this post were never separate. The criterion tells us whether the flowchart exists before we start. The guardrails tell us whether we were right, after. Same spine, checked from both ends. If we find ourselves writing the flowchart in English one rule at a time, the criterion already had the answer.&lt;/p&gt;




&lt;h2&gt;
  
  
  The diagnostic
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The number of Type B guardrails we need is a diagnostic on whether we picked the right tool.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The agent works with almost none, which means the problem really was not enumerable. The loop earned its cost.&lt;/li&gt;
&lt;li&gt;We keep adding them and reliability keeps improving. That is not tuning. That is the problem telling us it &lt;em&gt;was&lt;/em&gt; enumerable, and we should port those rules back into code and shrink the agent to whatever judgment is left.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One caveat: how many we need is not a fixed property of the problem. Compare the two traces above. Finding #2, the three-call run that stopped at checkout, was &lt;code&gt;llama-3.3-70b-versatile&lt;/code&gt;. Finding #1, the eight-call run that ruled out checkout's pool and traffic, hopped to payment, and widened the window to 13:55 on its own, was &lt;code&gt;openai/gpt-oss-120b&lt;/code&gt;. Same tools, same fake world, same prompt. The only change was the model string.&lt;/p&gt;

&lt;p&gt;I only ran each model once, so treat this as a signal and not a benchmark. But the swing matters: &lt;strong&gt;the same code was a supervised tool on one model and an autonomous one on another.&lt;/strong&gt; A guardrail we need this quarter may be dead weight next quarter.&lt;/p&gt;

&lt;p&gt;(Related: the Llama model I started on was deprecated on Groq partway through this project. Model availability is a dependency that disappears).&lt;/p&gt;




&lt;h2&gt;
  
  
  The answer to both questions
&lt;/h2&gt;

&lt;p&gt;Three tools, not two. Most of the confusion in this space comes from collapsing the middle one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Pure code, no LLM at all.&lt;/strong&gt;&lt;br&gt;
The flowchart is drawable and every step is mechanical. Parse, transform, query, branch. If we can specify it, we should specify it. This is still most software.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A single LLM call inside code we control.&lt;/strong&gt;&lt;br&gt;
The flowchart is drawable, but one box in it needs judgment over unstructured input: classify this ticket, extract these fields, summarize this text, draft this reply. One call, structured output, and our code decides everything else. This is the overwhelming majority of real "AI features," and it is not a lesser agent. It is the correct architecture for an enumerable problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The agent loop.&lt;/strong&gt;&lt;br&gt;
We genuinely cannot draw the flowchart, because step three depends on what step two returned and there are too many possibilities to enumerate. Incident triage, code review, open-ended research. Here the loop reaches things branching would not, and we pay for it in tokens, latency, and non-determinism.&lt;/p&gt;

&lt;p&gt;The line between 2 and 3 is the flowchart criterion. The line between 2 and 1 is just whether any single step needs judgment over unstructured input.&lt;/p&gt;

&lt;p&gt;And the sweet spot inside option 3, which is what the guardrail paradox is really about:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Constrain in code everything that can be enumerated. Leave the loop wrapping only the irreducible judgment.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The agent should be as small as possible. That is the principle I would take into the next one, and it follows from the paradox rather than from measurement.&lt;/p&gt;

&lt;p&gt;The observation underneath it is smaller and more concrete. Every idea I started with turned out to be a code problem in disguise. That is what question two was. I could not find a use case that plain Go would not handle until I went looking for one deliberately, and even then the loop only survived because I stopped short of writing the last three rules in code.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I have not resolved
&lt;/h2&gt;

&lt;p&gt;Two things I could not settle from this build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where the crossover actually sits.&lt;/strong&gt; I can say the loop earns its cost when the flowchart is not drawable, but I cannot yet say what that costs in dollars and milliseconds compared to the deterministic version of the same task. I have the argument and not the numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to stop adding Type B guardrails and rewrite in code.&lt;/strong&gt; The diagnostic says a rising count means we picked wrong, but there is no threshold. Two Type B rules is clearly fine. Fifteen is clearly a flowchart. I do not know where the line is, and I suspect it depends on how much the remaining judgment step is worth.&lt;/p&gt;

&lt;p&gt;Answering either one properly would mean building the same task twice, deterministic and agentic, and measuring cost, latency, correctness, and the inputs the deterministic version cannot handle at all. I have not done that, so for now both stay open.&lt;/p&gt;

&lt;p&gt;If you have hit different failure modes running agents in production, I would like to hear them.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>go</category>
      <category>ai</category>
      <category>llm</category>
    </item>
    <item>
      <title>TIL - Compatibility Direction in Schema Evolution</title>
      <dc:creator>M. Alwi Sukra</dc:creator>
      <pubDate>Tue, 21 Jul 2026 06:08:21 +0000</pubDate>
      <link>https://dev.to/arkoesalwi/til-compatibility-direction-in-schema-evolution-41dj</link>
      <guid>https://dev.to/arkoesalwi/til-compatibility-direction-in-schema-evolution-41dj</guid>
      <description>&lt;p&gt;This week I read DDIA Chapter 4, on encoding and evolution, and the part that stuck with me was how to reason about schema compatibility during a rolling upgrade.&lt;/p&gt;

&lt;p&gt;In a large system, code changes don't happen all at once. We do a rolling upgrade, so old and new versions of the code run side by side for a while. For the system to keep working through that window, the data has to survive being read by whichever version happens to pick it up. That's what forward and backward compatibility are for.&lt;/p&gt;

&lt;p&gt;Which direction we actually need isn't fixed. It depends on the dataflow, and on the order we roll things out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Through a database
&lt;/h2&gt;

&lt;p&gt;Say we add a field and update the code that reads and writes the row.&lt;/p&gt;

&lt;p&gt;Backward compatibility is obviously needed: data written by the old code shouldn't break when the new code reads it. But forward compatibility is needed too, because during a rolling upgrade an old instance can read a row that a new instance already wrote.&lt;/p&gt;

&lt;p&gt;And there's a twist a database adds that services don't have: old data just sits there. A row written two years ago is still there, in the old shape, and we have to keep supporting it. We &lt;em&gt;could&lt;/em&gt; migrate (rewrite) the data to avoid that, but on a large database that's expensive, so most of the time we don't. So the database case needs both directions, and it needs them for a long time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Through services (REST and RPC)
&lt;/h2&gt;

&lt;p&gt;Now say we change a request or response schema. Here it splits by who's processing what.&lt;/p&gt;

&lt;p&gt;On the server side, we need backward compatibility: the new server code has to handle old requests from clients that haven't upgraded yet. What we usually &lt;em&gt;don't&lt;/em&gt; strictly need is forward compatibility, because we always deployed the server fully first, then told clients to upgrade. So an old server never sees a new request. I'd been doing this by instinct; the chapter just gave me the reason it holds, the rollout order is what makes that direction safe to skip.&lt;/p&gt;

&lt;p&gt;On the client side it's the mirror image. The client needs forward compatibility: old client code has to handle new responses, because the server upgraded first and is already sending the new shape. It doesn't need backward compatibility, because by the time the client upgrades, the server is already new.&lt;/p&gt;

&lt;p&gt;So "the direction we care about" was never really a rule about APIs. It was a consequence of &lt;em&gt;how we rolled out&lt;/em&gt;. Server first, clients after.&lt;/p&gt;

&lt;h2&gt;
  
  
  Through message passing
&lt;/h2&gt;

&lt;p&gt;Producer and consumer versions coexist, and we can't force the order the way we can with server-then-client. A newer consumer might read a message from an older producer, and an older consumer might read a message from a newer producer. So the consumer needs both directions.&lt;/p&gt;

&lt;p&gt;And even if we decide to upgrade the producer fully first, we still can't drop forward compatibility, because messages live in the broker. They sit there for retries and replays, so a message written by the new producer can still be waiting when an old consumer picks it up. We can't tell a message already sitting in the queue to upgrade itself.&lt;/p&gt;

&lt;p&gt;That's the difference. With an API, the rollout order is something we control. With a queue, the message outlives the moment it was written, so the order isn't ours to enforce anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  The summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Backward (old data → new code)&lt;/th&gt;
&lt;th&gt;Forward (new data → old code)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service (server)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No, because server upgrades first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service (client)&lt;/td&gt;
&lt;td&gt;No, because server upgrades first&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Message passing&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The thing I took from it: I'd been handling both directions in practice, but as a set of habits, not a clear model. What this chapter gave me was the definition, backward and forward as two independent directions, and the reason the direction we need shifts with the dataflow and the rollout order. The moment the dataflow stops letting us control that order, like a message queue does, the shortcut of leaning on rollout order stops working, and we have to actually support both.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>TIL - Picking a Database by Its Read/Write Pattern</title>
      <dc:creator>M. Alwi Sukra</dc:creator>
      <pubDate>Sun, 14 Jun 2026 04:36:15 +0000</pubDate>
      <link>https://dev.to/arkoesalwi/til-picking-a-database-by-its-readwrite-pattern-l27</link>
      <guid>https://dev.to/arkoesalwi/til-picking-a-database-by-its-readwrite-pattern-l27</guid>
      <description>&lt;p&gt;I've shipped systems on Postgres, BigTable, and a column store. If you asked me which one to reach for, I could answer from experience. But until I read DDIA Chapter 3, I couldn't have told you why they behave so differently, because I'd never actually thought about what the storage engine does with the bytes underneath.&lt;/p&gt;

&lt;p&gt;That turned out to be the whole lesson. The internal data structure of a database isn't an implementation detail we can ignore. It's designed and tuned around a read/write access pattern. So once we understand the structures, "which database" stops being a vibe and starts being a consequence of how our workload reads and writes.&lt;/p&gt;




&lt;h3&gt;
  
  
  Two worlds: OLTP and OLAP
&lt;/h3&gt;

&lt;p&gt;DDIA splits database workloads into two shapes. These aren't database types, they're descriptions of how a workload behaves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OLTP (Online Transaction Processing): point reads/writes, low latency, high concurrency, few rows touched per query, needs fresh data. (The app serving user requests)&lt;/li&gt;
&lt;li&gt;OLAP (Online Analytical Processing): large scans, few columns across many rows, throughput over latency, bulk/batch writes, tolerates staleness. (The analytics and reporting)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The thing that finally clicked for me: these labels are something we put on a workload after we've described it, not a bucket we pick first. And one system usually has both. The write path can be analytical-shaped while the read path is transactional-shaped.&lt;/p&gt;




&lt;h3&gt;
  
  
  Picking an engine is asking two questions about the workload
&lt;/h3&gt;

&lt;p&gt;If the storage engine is tuned to the access pattern, then choosing one is really about describing how our workload reads and writes, then matching it. Two questions get us most of the way there.&lt;/p&gt;

&lt;p&gt;One quick note before that. I'm only scratching the surface of each technology here. This is what clicked for me, not a deep dive into compaction internals or page layouts. If you want the full mechanics, the book itself (and plenty of good articles) go far deeper. Treat the names below as starting points to go read about, not as the last word.&lt;/p&gt;




&lt;h4&gt;
  
  
  Q1: write-heavy or read-heavy? (LSM vs B-tree)
&lt;/h4&gt;

&lt;p&gt;Inside the OLTP world, there are two ways to organize data on disk, and the difference is entirely about how each one handles a write.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Log-structured (LSM-tree)&lt;/strong&gt;. New and updated data is appended. Writes land in an in-memory sorted structure (the &lt;em&gt;memtable&lt;/em&gt;), which gets flushed to disk as an immutable, sorted file (an &lt;em&gt;SSTable&lt;/em&gt;). Background compaction merges those files and throws away superseded values. Because every write is a sequential append, write throughput is high. The cost is &lt;em&gt;read amplification&lt;/em&gt;, where a key might live in several SSTables, so reads check multiple files (Bloom filters exist to skip the ones that definitely don't have the key). Databases built this way: Cassandra, RocksDB, LevelDB, HBase, and BigTable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update-in-place (B-tree)&lt;/strong&gt;. Data lives in fixed-size pages, and an update rewrites the page where the key already is. Reads are predictable (a bounded number of page lookups), and this structure fits transactions cleanly. The cost is write amplification: writes are random I/O, and a single update often rewrites a whole page (plus the write-ahead log entry, and sometimes a page split). Databases built this way: Postgres, MySQL (InnoDB), and most traditional relational databases.&lt;/p&gt;

&lt;p&gt;So the heuristic "LSM for write-heavy, B-tree for read-heavy or transactional" isn't a rule to memorize. It falls out of how each structure treats a write. That's the thesis in miniature: the engine is shaped by the access pattern. This is why Cassandra can absorb a firehose of writes while Postgres gives us clean transactions. They picked different sides of this tradeoff.&lt;/p&gt;




&lt;h4&gt;
  
  
  Q2: point access or analytical scan? (and why "row vs column" is the wrong axis)
&lt;/h4&gt;

&lt;p&gt;This is the part that reframed how I think about it.&lt;/p&gt;

&lt;p&gt;I used to assume the storage axis was "row-oriented vs column-oriented." After this chapter I think that's the wrong axis. The real distinction is whether our storage unit is &lt;em&gt;explicitly keyed&lt;/em&gt; or &lt;em&gt;positionally implied&lt;/em&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In a &lt;strong&gt;row store&lt;/strong&gt; like Postgres or MySQL, a row is one keyed record with all its columns fused together. Update one column, and it rewrites the whole record.&lt;/li&gt;
&lt;li&gt;In a &lt;strong&gt;wide-column store&lt;/strong&gt; like BigTable, Cassandra, or HBase, the unit isn't &lt;code&gt;row -&amp;gt; all columns&lt;/code&gt;. The on-disk key is the full cell coordinate &lt;code&gt;(row key, column, timestamp) -&amp;gt; value&lt;/code&gt;. Each cell is independently keyed. A row's cells are sorted to sit next to each other (that's the only thing "row-oriented" really means here, just locality), but each cell is written and updated on its own.&lt;/li&gt;
&lt;li&gt;In a &lt;strong&gt;column store&lt;/strong&gt; like Parquet, ClickHouse, Vertica, or BigQuery, each column is a separate file of bare values, with no key stored next to each value. Row N is whatever sits at position N in every file. Position is the implicit key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example, let's say we have these rows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;row A: impression=11, click=12, cost=13
row B: impression=14, click=15, cost=16
row C: impression=17, click=18, cost=19
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The storage visualization for each type is:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F72kaxekcjin915x7tjzj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F72kaxekcjin915x7tjzj.png" alt="Storage visualization"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That single distinction explains the write behavior the orientation framing can't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A row store updates one column by rewriting the whole row, since the columns are fused into a single keyed record. Cheap when you're touching one row at a time, but two writers updating different columns of the same row still contend on the same record.&lt;/li&gt;
&lt;li&gt;A wide-column store can update one column of one row without touching the others, because the cell is its own keyed thing. Independent writers writing different columns to the same row never collide.&lt;/li&gt;
&lt;li&gt;A column store can't cheaply insert one row, because there's no key to address it by. Position ties every value to its row, so inserting means realigning every file (and the compressed, sorted columns reject cheap in-place edits). That same positional layout is exactly what makes scans and compression spectacular.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Strength and weakness come from the same design decision. Keyed buys us independent writes and locality. Positional buys us compression and scan speed. Neither is "better." Each is tuned to a different access pattern.&lt;/p&gt;

&lt;p&gt;The read side mirrors it: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A row store fetches one whole record in a single read, but scanning one column means dragging every full row off disk. &lt;/li&gt;
&lt;li&gt;A wide-column store reads one row as a contiguous scan over its adjacent cells, but it's still reading row by row, not scanning one column across everything. &lt;/li&gt;
&lt;li&gt;A column store reads one column across millions of rows by touching just that file, but reassembling one whole row means gathering a value from every file. &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Point access wants keyed; analytical scan wants positional. Read and write pull the same direction, because they're the same access-pattern bet.&lt;/p&gt;




&lt;h3&gt;
  
  
  Where I'd been relying on this without naming it
&lt;/h3&gt;

&lt;p&gt;A system I worked on stored predictions for an ads insights platform: three prediction types (impression uplift, click uplift, cost saving), produced by several suggestion engines, kept in BigTable. We did reason our way there from the read/write pattern. What this chapter gave me was sharper vocabulary for the thing we were already doing.&lt;/p&gt;

&lt;p&gt;Run the workload through the two questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Writes were high-volume and batch: several engines aggregating offline, then loading results in. That's append-heavy ingest, the LSM side of Q1.&lt;/li&gt;
&lt;li&gt;Reads were point and prefix lookups on a composite key like &lt;code&gt;shop#group&lt;/code&gt; (read one group, or a range of groups under a shop). Not analytical scans across one column, so keyed, not positional, on Q2.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Append-heavy writes, keyed point and prefix reads. That shape fits a wide-column store like BigTable almost exactly, so that's where we landed. None of it is "row vs column" or "SQL vs NoSQL." It's just the workload described honestly, and the engine fell out of the description.&lt;/p&gt;

&lt;p&gt;The fit wasn't perfect, either. Some read paths still needed online aggregation, which traded read latency for write-path simplicity, but that's a story for another post.&lt;/p&gt;




&lt;h3&gt;
  
  
  The part the access pattern doesn't decide
&lt;/h3&gt;

&lt;p&gt;Here's what I only noticed reading this chapter. We assessed the read/write pattern, it pointed at BigTable, and we stopped there. I never actually asked the next question: could Postgres have done this too?&lt;/p&gt;

&lt;p&gt;The honest answer is, probably yes.&lt;/p&gt;

&lt;p&gt;Whole-row update contention from multiple writers? Postgres has a workaround. Split each writer's columns into separate tables keyed on &lt;code&gt;(shop, group)&lt;/code&gt;. No shared row, no contention.&lt;/p&gt;

&lt;p&gt;Avoid joins on large tables? That join only exists because I split the tables. Keep one wide table and there's no join at all. And even the split join is cheap when it's indexed on a bounded set of rows.&lt;/p&gt;

&lt;p&gt;The data too big for one machine? It wasn't, at the time. It fit comfortably on a single node.&lt;/p&gt;

&lt;p&gt;So the thing that pointed me at BigTable, the access pattern, wasn't actually the thing that ruled Postgres out. Postgres could have served the same reads and writes. I just never ran the comparison, because I'd found something that fit and didn't look back.&lt;/p&gt;

&lt;p&gt;That's the lesson I took from it. The access pattern narrows you to an engine class, and that narrowing is real and useful. But it doesn't rule out the alternatives within reach of that class. What actually separates BigTable from Postgres here is how each one grows past a single machine. Postgres is single-node by design: scaling out means sharding it yourself, picking a shard key, routing queries in the application, and rebalancing by hand as it grows. BigTable partitions automatically, splitting data into tablets by row-key range and spreading them across machines on its own. So once the data outgrows one node, Postgres turns into an operational project and BigTable just keeps going. That's the real dividing line, and it's the question I never got around to asking out loud.&lt;/p&gt;




&lt;h3&gt;
  
  
  Takeaway
&lt;/h3&gt;

&lt;p&gt;So the honest shape of database selection, after this chapter:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The read/write access pattern selects the engine class: LSM vs B-tree, keyed vs positional. This part is real and reasoned, and it's what I actually did.&lt;/li&gt;
&lt;li&gt;Scale and transactional needs finish the selection, and these are independent of the access pattern. Two stores can match the access pattern perfectly and differ entirely on whether they shard or do multi-row ACID. This is the step it's easy to skip once you've found something that fits.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The data structure follows the access pattern. The product follows the data structure plus scale.&lt;/p&gt;

&lt;p&gt;This is the storage-engine layer of the decision. Replication, partitioning, and consistency (later chapters in the book) add their own factors, but those sit on top of this layer, they don't replace it.&lt;/p&gt;

</description>
      <category>software</category>
      <category>database</category>
    </item>
    <item>
      <title>TIL - Graph Thinking Without a Graph Database</title>
      <dc:creator>M. Alwi Sukra</dc:creator>
      <pubDate>Thu, 28 May 2026 17:19:16 +0000</pubDate>
      <link>https://dev.to/arkoesalwi/til-graph-thinking-without-a-graph-database-1d11</link>
      <guid>https://dev.to/arkoesalwi/til-graph-thinking-without-a-graph-database-1d11</guid>
      <description>&lt;p&gt;This week I read DDIA Chapter 2 related to data models. Most of it felt familiar. Relational vs document, many-to-many with junction tables, schema-on-read vs schema-on-write. These were things I had opinions about already.&lt;/p&gt;

&lt;p&gt;But the graph data model section was a blind spot. I assumed graph databases were for social networks, interesting but not relevant to anything I was doing.&lt;/p&gt;




&lt;h3&gt;
  
  
  What graph data models actually are
&lt;/h3&gt;

&lt;p&gt;One key aspect that the chapter emphasizes is how different data models handle many-to-many relationships. In a relational data model, we usually have several tables and a junction table that connects them. We also can add some additional columns to that junction table.&lt;/p&gt;

&lt;p&gt;For a graph data model, we can think of it having 2 different tables: &lt;strong&gt;nodes&lt;/strong&gt; and &lt;strong&gt;edges&lt;/strong&gt;. Nodes are entities. Edges are relationships between them. Both can have properties.&lt;/p&gt;

&lt;p&gt;There is not much difference between a relational and graph data model for a single relationship at a fixed depth. For example, a &lt;code&gt;friendships&lt;/code&gt; table with &lt;code&gt;user_a_id, user_b_id, since, is_close_friend&lt;/code&gt; is basically an edge with properties. Relational handles that fine.&lt;/p&gt;

&lt;p&gt;The difference shows up when we start traversing.&lt;/p&gt;

&lt;p&gt;Say we want "friends of friends". With a junction table, that's a self-join. "Friends of friends of friends" is another join. "Anyone reachable from me through any number of friendship hops" is a recursive CTE. It works, but the query complexity has nothing to do with how simple the question sounds.&lt;/p&gt;

&lt;p&gt;In a graph query language, traversal is the native operation. Here's friends of friends in Cypher (Neo4j's query language):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;me:&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;id:&lt;/span&gt; &lt;span class="n"&gt;$userId&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:FOLLOWS&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;friend&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:FOLLOWS&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fof&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;fof&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We can read it almost like a sentence: &lt;em&gt;match the pattern where I follow a friend, who follows a friend-of-friend&lt;/em&gt;. The arrows are edges; &lt;code&gt;[:FOLLOWS]&lt;/code&gt; is the edge type to traverse.&lt;/p&gt;

&lt;p&gt;And arbitrary depth is just one more character:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;me:&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;id:&lt;/span&gt; &lt;span class="n"&gt;$userId&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:FOLLOWS&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reachable&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;reachable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;*&lt;/code&gt; means "follow any number of these edges." Same query shape whether it's one hop or ten. In SQL, that jump from fixed depth to arbitrary depth means rewriting our query as a recursive CTE.&lt;/p&gt;

&lt;p&gt;The second difference is that a graph treats different relationship types uniformly. In relational, &lt;code&gt;follows&lt;/code&gt;, &lt;code&gt;blocks&lt;/code&gt;, and &lt;code&gt;memberships&lt;/code&gt; are usually separate tables, and traversing across them means a different join per table. In a graph, they're all just edges, and we can traverse across types in a single pattern.&lt;/p&gt;

&lt;p&gt;So my take is that the real distinction is the traversal. Especially variable-depth traversal across multiple relationship types. It's a first-class operation in a graph model and an awkward bolt-on in SQL.&lt;/p&gt;




&lt;h3&gt;
  
  
  The shape that fits
&lt;/h3&gt;

&lt;p&gt;The chapter convinced me that graph models suit problems where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Relationships are recursive or variable-depth (friends of friends, transitive dependencies, reachability)&lt;/li&gt;
&lt;li&gt;Multiple paths can exist between the same two entities&lt;/li&gt;
&lt;li&gt;The type of relationship matters as much as the entities themselves&lt;/li&gt;
&lt;li&gt;Queries are about traversal and reachability, not just lookup&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If our data is mostly "fetch a row by ID" or "join two tables on a foreign key", relational is fine. But the moment we start asking "what's reachable from here through any valid path?", &lt;strong&gt;that's a graph question, whether we store it in a graph database or not&lt;/strong&gt;.&lt;/p&gt;




&lt;h3&gt;
  
  
  Looking at my own work through this lens
&lt;/h3&gt;

&lt;p&gt;I worked on an ads management system. The schema looked like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4i6iovrmcrjszyz5k6ew.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4i6iovrmcrjszyz5k6ew.png" alt="ERD" width="799" height="399"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Some queries this service needed to answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Find all keywords in a shop.&lt;/li&gt;
&lt;li&gt;Find all keywords in a group.&lt;/li&gt;
&lt;li&gt;Find all keywords in an ad. (Keywords directly on the ad, plus keywords on the group that contains it.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reasonable schema, queries, and I'd worked with this code. But when I tried drawing the data as a graph, this is what I got:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr74480pyyf2fzqjrahyy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr74480pyyf2fzqjrahyy.png" alt="Graph" width="800" height="414"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There are multiple paths from a shop to a keyword: through an ad group, through an ad, through both. When I query "all keywords in a shop", I'm doing a graph traversal: "find every Keyword reachable from this Shop through any path of contains edges". I just hadn't been calling it that.&lt;/p&gt;




&lt;h3&gt;
  
  
  What actually changed for me
&lt;/h3&gt;

&lt;h4&gt;
  
  
  1. I started thinking about reachability instead of joins
&lt;/h4&gt;

&lt;p&gt;Before: every question about the data felt like a question about which tables to join. To get keywords in a shop, I join &lt;code&gt;keyword&lt;/code&gt; tables with &lt;code&gt;ad&lt;/code&gt;/&lt;code&gt;ad_group&lt;/code&gt; tables and filter by &lt;code&gt;shop_id&lt;/code&gt;. The query was a sequence of join operations.&lt;/p&gt;

&lt;p&gt;After: every question about the data feels like a question about which nodes are reachable from which. Find all keyword nodes reachable from this shop. The traversal is the question, and the join is just one implementation of the traversal.&lt;/p&gt;

&lt;p&gt;The shift sounds subtle, but it's what made other approaches (denormalization, recursive CTEs, even just rephrasing the SQL) become visible. Once the question is "what's reachable from here?", the answer doesn't have to be "join these tables." It can be anything that gets us the same set of reachable nodes.&lt;/p&gt;

&lt;h4&gt;
  
  
  2. I noticed the multiple paths problem
&lt;/h4&gt;

&lt;p&gt;In a tree, every node has exactly one parent. The hierarchy I was working with isn't a tree. A keyword can be reached from a shop through an ad group, or through an ad, or through both. An ad can belong to a shop directly, or be inside a group.&lt;/p&gt;

&lt;p&gt;I'd been treating this as a quirk of the schema. The nullable columns, the join table, the two separate keyword tables, these were just "how things are." But the graph lens names it clearly: the data has multiple paths between the same kinds of nodes. That's a structural property, not a quirk.&lt;/p&gt;

&lt;p&gt;And it explains why my SQL queries kept needing unions. Each &lt;code&gt;UNION&lt;/code&gt; branch is one path. The graph is telling me up front that I'm going to need multiple branches; the schema was hiding that until the query made it visible.&lt;/p&gt;

&lt;h4&gt;
  
  
  3. I saw edges that weren't in my schema
&lt;/h4&gt;

&lt;p&gt;The graph diagram has a &lt;code&gt;contains&lt;/code&gt; edge from &lt;code&gt;ad_group&lt;/code&gt; to &lt;code&gt;ad&lt;/code&gt;. In my schema, that relationship lives in the &lt;code&gt;ad_group_ad&lt;/code&gt; junction table.&lt;/p&gt;

&lt;p&gt;But the graph also has implicit relationships that my schema doesn't model directly. The "keyword in shop X" relationship is real (and we query for it constantly), but no column or table represents it directly. It's a derived relationship, computed every time we run the traversal.&lt;/p&gt;

&lt;p&gt;That's where the option space opens up. Once I can see shop-to-keyword as a meaningful relationship, I can ask whether to materialize it (denormalize shop_id onto every keyword) or keep deriving it (current approach with traversal). Both are valid; the graph view is what made the choice visible.&lt;/p&gt;




&lt;h3&gt;
  
  
  What it would look like as a graph query
&lt;/h3&gt;

&lt;p&gt;In Cypher, "all keywords in a shop" is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;s:&lt;/span&gt;&lt;span class="n"&gt;Shop&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;id:&lt;/span&gt; &lt;span class="n"&gt;$shopId&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:CONTAINS&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;k:&lt;/span&gt;&lt;span class="n"&gt;Keyword&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In our actual schema, the same query takes two branches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Keywords on groups in this shop&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ad_group_keyword&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;ad_group&lt;/span&gt; &lt;span class="k"&gt;g&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ad_group_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shop_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;shopId&lt;/span&gt;

&lt;span class="k"&gt;UNION&lt;/span&gt;

&lt;span class="c1"&gt;-- Keywords on ads in this shop&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ad_keyword&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;ad&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ad_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shop_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;shopId&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works. But this query got slow on us in a way that took us a while to understand.&lt;/p&gt;

&lt;p&gt;The issue wasn't the &lt;code&gt;JOIN&lt;/code&gt; itself, or the &lt;code&gt;UNION&lt;/code&gt;. It was the query planner. When a shop has many keywords, the planner sometimes picks an index path that ends up scanning across the ad tables (soft-deleted rows included), even when the request is for a small page of results.&lt;/p&gt;

&lt;p&gt;The behavior was hard to predict because it depended on the shop's data distribution. Small shops were fine. Large shops sometimes triggered the bad path. And because the keyword list is a batch API, a single slow query multiplied across the batch and put real pressure on the database.&lt;/p&gt;

&lt;p&gt;The team's fix was to stop letting the planner choose. We took the joins out of SQL and resolved them in application code: query each table separately with &lt;code&gt;WHERE shop_id = X AND status = 'active'&lt;/code&gt; (which uses clean indexes predictably), then stitch the results in Go.&lt;/p&gt;

&lt;p&gt;It works. But it's a workaround for a query that's conceptually one thing: find all keywords reachable from this shop. The graph traversal is happening, just spread across multiple queries and some application code, with the planner taken out of the loop entirely.&lt;/p&gt;




&lt;h3&gt;
  
  
  Would I actually use a graph database?
&lt;/h3&gt;

&lt;p&gt;Constraints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The hierarchy is only 3-4 levels deep. Graph databases shine on deep or unbounded traversal. Mine is bounded.&lt;/li&gt;
&lt;li&gt;Nobody on the team has run Neo4j in production. PostgreSQL we know cold.&lt;/li&gt;
&lt;li&gt;Our source of truth is already in Postgres. Adding a graph database means syncing two stores or migrating the source of truth, both big commitments.&lt;/li&gt;
&lt;li&gt;Our queries are predictable. We're not doing pattern matching or shortest-path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'm not sure I'd reach for Neo4j here. The elegance of the Cypher query is real, but the operational cost feels high for the shape of problem I have.&lt;/p&gt;

&lt;p&gt;What's more interesting is that the graph lens opened up another option.&lt;/p&gt;




&lt;h3&gt;
  
  
  A different option
&lt;/h3&gt;

&lt;p&gt;What if every entity in the hierarchy carried its ancestor IDs directly?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ad:               id, shop_id, ad_group_id (nullable), status
ad_group_keyword: id, ad_group_id, tag, shop_id
ad_keyword:       id, ad_id, shop_id, ad_group_id (nullable), status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ad_group_ad&lt;/code&gt; still records the actual ad-to-group membership, but &lt;code&gt;ad.ad_group_id&lt;/code&gt; is a maintained denormalization. Same idea on the keyword tables: each keyword carries &lt;code&gt;shop_id&lt;/code&gt;, and &lt;code&gt;ad_keyword&lt;/code&gt; also carries &lt;code&gt;ad_group_id&lt;/code&gt; and &lt;code&gt;status&lt;/code&gt;. The ID columns are nullable where the relationship doesn't apply.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5wowcd1fafbw8t10bnqw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5wowcd1fafbw8t10bnqw.png" alt="Graph Denormalized" width="799" height="545"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now the "find" queries get simpler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- All keywords in a shop&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ad_group_keyword&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;shop_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;shopId&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ad_keyword&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;shop_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;shopId&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;

&lt;span class="c1"&gt;-- All keywords in a group&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ad_group_keyword&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ad_group_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;groupId&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ad_keyword&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ad_group_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;groupId&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;

&lt;span class="c1"&gt;-- All keywords in an ad&lt;/span&gt;
&lt;span class="c1"&gt;-- Step 1: get the ad's group (single PK read)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;ad_group_id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ad&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;adId&lt;/span&gt;
&lt;span class="c1"&gt;-- Step 2: pull keywords from both sources&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ad_keyword&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ad_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;adId&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ad_group_keyword&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ad_group_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;adGroupId&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are direct lookups on indexed columns. There's no join for the planner to mis-optimize, no intermediate result set whose size depends on data distribution. The query behavior is the same whether a shop has 50 keywords or 50,000.&lt;/p&gt;

&lt;p&gt;The third case is two reads, but step 1 is just a primary-key lookup on the &lt;code&gt;ad&lt;/code&gt; row we'd usually be fetching anyway.&lt;/p&gt;

&lt;p&gt;The cost is that denormalization is now a system, not a single column. Moving an ad updates &lt;code&gt;ad_group_id&lt;/code&gt; across its keywords; soft-deleting an ad updates &lt;code&gt;status&lt;/code&gt; across its keywords. Two operations, both fan out to the keyword rows, both have to be transactional or the data drifts.&lt;/p&gt;

&lt;p&gt;Drift is the part that worries me. The query patterns get dramatically cleaner, but the write paths get more places they could go wrong. Six months later, someone adds a new way to move ads between groups and forgets to update the keywords. The reads quietly return wrong results. So I'm not sure denormalization is the answer either.&lt;/p&gt;

&lt;p&gt;What surprised me is that I'd been treating it as a normalization problem ("where should the foreign keys go?") instead of a modeling problem ("what shape does the data actually have?"). The graph perspective is what reframed it for me.&lt;/p&gt;




&lt;h3&gt;
  
  
  The takeaway
&lt;/h3&gt;

&lt;p&gt;The data was always graph-shaped. The queries were always graph queries. The schema and the application code were doing graph work without the vocabulary to describe it. And once I could see the shape, alternatives I hadn't been considering became visible, even if I haven't decided which one is right.&lt;/p&gt;

&lt;p&gt;I'm not sure I'll ever reach for a graph database. But learning about them is already changing how I think about modeling, even though I'm not using one.&lt;/p&gt;

</description>
      <category>software</category>
      <category>database</category>
    </item>
    <item>
      <title>TIL - What Response Time Metrics Really Mean</title>
      <dc:creator>M. Alwi Sukra</dc:creator>
      <pubDate>Sun, 10 May 2026 07:55:53 +0000</pubDate>
      <link>https://dev.to/arkoesalwi/til-what-response-time-metrics-really-mean-1df9</link>
      <guid>https://dev.to/arkoesalwi/til-what-response-time-metrics-really-mean-1df9</guid>
      <description>&lt;p&gt;I always thought high percentiles didn't really matter, they only impact a small number of users, right? I interpreted them as the worst case (something unlikely to affect most users).&lt;/p&gt;

&lt;p&gt;This week I read DDIA and came across the part describing how Amazon sets their response time requirements at p99.9. That means the requirement is based on 1 in 1000 users :). But the reason is something I never thought of: the users in the high percentiles are most likely the ones with the most data, which makes them important users for Amazon.&lt;/p&gt;

&lt;p&gt;I reflected on this with my experience working on an Ads Platform. Some processes were slow and it was almost always the same small group of users with many ads, which I assume also correlates with ads revenue contribution. I wonder if we had designed the system around those high-percentile users, maybe we could have made the platform better for all users, and best for our most important sellers.&lt;/p&gt;




&lt;h3&gt;
  
  
  Response time isn't the same as latency
&lt;/h3&gt;

&lt;p&gt;I don't know why, but somehow I just know that response time and latency are different:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Service time&lt;/strong&gt;: how long the server actually spends processing the request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency&lt;/strong&gt;: time the request spends waiting (queued, in transit, blocked).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Response time&lt;/strong&gt;: what the caller sees: service time + network + queueing + everything else.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Response time is from the caller's perspective. Service time is from the callee's. They're almost never equal. I think this is important because most of us only track one side.&lt;/p&gt;




&lt;h3&gt;
  
  
  Average hides the shape
&lt;/h3&gt;

&lt;p&gt;Response times aren't a single number, they're a distribution. Most requests are fast, a few are very slow, and the average sits somewhere awkward between them.&lt;/p&gt;

&lt;p&gt;Average doesn't tell us how many users actually experienced the delay. An average of 200ms can mean everyone gets ~200ms, or that most get 50ms while a few get 2 seconds. The average doesn't tell us which one we have.&lt;/p&gt;

&lt;p&gt;That's why averages aren't enough. We need a metric that respects the shape.&lt;/p&gt;




&lt;h3&gt;
  
  
  Percentiles, properly
&lt;/h3&gt;

&lt;p&gt;A percentile shows "what response time were X% of requests faster than?"&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;p50: half were faster.&lt;/li&gt;
&lt;li&gt;p95: 95% were faster, 5% were slower.&lt;/li&gt;
&lt;li&gt;p99: 99% were faster, 1% were slower.&lt;/li&gt;
&lt;li&gt;p99.9: 99.9% were faster, 0.1% were slower.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If p99 = 500ms, it means 1 out of every 100 requests took longer than 500ms. That's the part I used to dismiss as noise.&lt;/p&gt;




&lt;h3&gt;
  
  
  Which percentile to chase
&lt;/h3&gt;

&lt;p&gt;Once we accept the tail matters, the next question is how far in?&lt;/p&gt;

&lt;p&gt;Honestly, I don't know how to answer the question. Maybe the choice isn't really technical and it's a business question: which users have we decided to serve well? p99 means we're serving 99% of requests well. p99.9 means we're including heavy users (the ones who, going back to the Amazon insight, probably matter most).&lt;/p&gt;




&lt;h3&gt;
  
  
  A few things I wish I'd known earlier
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Measure at both caller and callee&lt;/strong&gt;. Callee might report p99 = 50ms while caller sees p99 = 300ms for the same calls. The 250ms gap is in the network, the connection pool, queueing, or the caller's own thread pool. If we only look at one side, we miss it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timeouts decouple the metrics&lt;/strong&gt;. If the caller times out at 200ms and the callee takes 500ms, the callee's dashboard shows a successful 500ms response to a request the caller already gave up on. Both metrics are technically correct but are misleading on their own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't average percentiles across servers&lt;/strong&gt;. This is my second confession. For years, when our dashboard showed p99 from multiple servers, I'd take the average of those numbers and call it our "global p99." That's mathematically meaningless. The average of ten p99s is not the p99 of the combined population. The right way is to merge the underlying histograms first, then compute the percentile.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Takeaway
&lt;/h3&gt;

&lt;p&gt;A metric isn't just a number. It's a statement about which users we've decided to serve well.&lt;/p&gt;

&lt;p&gt;An average says "I care about the typical user." p99 says "I care about almost everyone." p99.9 says "I care about the heavy users too, the ones who probably matter most to the business."&lt;/p&gt;

&lt;p&gt;For years, I was implicitly choosing the first one without realizing I was choosing anything.&lt;/p&gt;

</description>
      <category>software</category>
      <category>monitoring</category>
      <category>performance</category>
    </item>
  </channel>
</rss>
