<?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: Kainat Saricioglu</title>
    <description>The latest articles on DEV Community by Kainat Saricioglu (@kainat_saricioglu).</description>
    <link>https://dev.to/kainat_saricioglu</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%2F4081992%2F1250d719-e1bf-4072-90d2-2fb3bbd6f870.jpg</url>
      <title>DEV Community: Kainat Saricioglu</title>
      <link>https://dev.to/kainat_saricioglu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kainat_saricioglu"/>
    <language>en</language>
    <item>
      <title>Your API Was Built for Humans. Now an AI Agent Is Calling It.</title>
      <dc:creator>Kainat Saricioglu</dc:creator>
      <pubDate>Wed, 23 Sep 2026 16:06:58 +0000</pubDate>
      <link>https://dev.to/kainat_saricioglu/your-api-was-built-for-humans-now-an-ai-agent-is-calling-it-2c0o</link>
      <guid>https://dev.to/kainat_saricioglu/your-api-was-built-for-humans-now-an-ai-agent-is-calling-it-2c0o</guid>
      <description>&lt;p&gt;For years we designed APIs around one assumption: a human is somewhere on the other side of the request. A user clicks a button, the frontend sends a request, the API validates it, the backend does the work, and a response comes back.&lt;/p&gt;

&lt;p&gt;Then AI agents showed up. Now the caller might not be a browser. It might be an agent deciding which endpoint to call, what arguments to send, whether to call something else afterwards, and whether the result is good enough to continue.&lt;/p&gt;

&lt;p&gt;Your API can still be perfectly correct. Authentication still works, the endpoints still follow REST conventions, and nothing is broken. But &lt;strong&gt;an API that is comfortable for humans is not automatically a good API for agents&lt;/strong&gt;, and the gap shows up in places backend engineers already care about: contracts, permissions, retries, errors, and tracing.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Quick definitions:&lt;/strong&gt; An &lt;strong&gt;AI agent&lt;/strong&gt; is a program built around a language model that decides which actions to take to complete a task, instead of following a fixed script. A &lt;strong&gt;tool&lt;/strong&gt; is one action you expose to that agent, such as "cancel an order." &lt;strong&gt;MCP (Model Context Protocol)&lt;/strong&gt; is an open standard that describes how an AI client discovers and calls those tools, so every integration doesn't have to invent its own mechanism.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The difference isn't just "who is calling"
&lt;/h2&gt;

&lt;p&gt;In a traditional application, the frontend already knows almost everything. It knows what the endpoint does, which fields are required, which values are valid, and what should happen after a successful response. The API can afford to be terse, because a developer has already read the docs and encoded that knowledge in the client.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    U([User]) --&amp;gt; FE[Frontend]
    FE --&amp;gt;|POST /orders| API[Backend API]
    API --&amp;gt; DB[(Database)]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;An agent starts with far less. Before it can call anything, it has to discover which tools exist, pick one, construct the arguments, interpret the response, and decide what to do next.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    U([User]) --&amp;gt; AG[AI agent]
    AG --&amp;gt; D[Discover tools]
    D --&amp;gt; S[Choose a tool]
    S --&amp;gt; P[Construct arguments]
    P --&amp;gt;|API call| API[Backend API]
    API --&amp;gt; R[Interpret response]
    R --&amp;gt; N[Decide next step]
    N --&amp;gt; S&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The agent isn't just executing a workflow. It's participating in one. So your API now has to communicate more than "here is an endpoint." It has to communicate what can be done, what is allowed, what input is expected, and what the operation actually means.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Make operations explicit
&lt;/h2&gt;

&lt;p&gt;Take an endpoint like &lt;code&gt;POST /orders/action&lt;/code&gt;. A frontend developer reads the docs once and moves on. An agent has to guess: does "action" mean create, cancel, approve, retry, or update?&lt;/p&gt;

&lt;p&gt;An agent-facing interface works better when each operation is named and described on its own:&lt;br&gt;
&lt;/p&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cancel_order"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Cancel an existing order that has not yet been shipped."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"parameters"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"orderId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The order's identifier."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Why the customer is cancelling."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&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;The description is no longer just documentation. It becomes part of the model's decision-making context, which means vague wording leads directly to wrong tool choices. It's worth describing side effects too: an agent should be able to tell from the description alone that &lt;code&gt;refund_payment&lt;/code&gt; moves real money.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Responses are part of the conversation
&lt;/h2&gt;

&lt;p&gt;Traditional responses are written for code that already knows the domain:&lt;br&gt;
&lt;/p&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="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4821&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PENDING"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"x"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&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;A frontend developer knows that status &lt;code&gt;3&lt;/code&gt; means pending and that &lt;code&gt;x&lt;/code&gt; is the cancel flag. An agent doesn't, and it will guess. Compare that with a response that carries its own meaning:&lt;br&gt;
&lt;/p&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"orderId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"4821"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pending"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"canCancel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The order has not been shipped and can still be cancelled."&lt;/span&gt;&lt;span class="w"&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;This doesn't mean stuffing paragraphs into every payload. It means the contract should expose meaning rather than internal implementation details. A useful test: &lt;strong&gt;if you deleted the frontend entirely, could a caller understand this response without guessing?&lt;/strong&gt; If not, the contract is leaning on knowledge that lives outside the API.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Don't turn every API into an AI API
&lt;/h2&gt;

&lt;p&gt;I wouldn't rewrite existing REST APIs just because agents exist. Your system still serves browsers, mobile apps, internal services, scheduled jobs, and partner integrations, and those consumers have different needs.&lt;/p&gt;

&lt;p&gt;The more interesting question is what an agent-facing layer looks like &lt;strong&gt;on top of&lt;/strong&gt; what you already have. Often it's a thin adapter that exposes a small set of well-described tools and calls the same services underneath:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    AG([AI agent]) --&amp;gt; T[Agent tool layer / MCP server]
    FE([Browser / mobile]) --&amp;gt; API[Existing REST API]
    T --&amp;gt; SVC[Application services]
    API --&amp;gt; SVC
    SVC --&amp;gt; DB[(Database)]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The business logic doesn't change. What changes is that one more consumer gets an interface designed for how it actually works. This is also why MCP is interesting: it gives that layer a standard shape for describing and invoking tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. "The agent has my token" is not a security architecture
&lt;/h2&gt;

&lt;p&gt;This is where things get serious. Suppose your API already exposes &lt;code&gt;GET /customers/{id}&lt;/code&gt;, &lt;code&gt;POST /payments&lt;/code&gt;, &lt;code&gt;POST /refunds&lt;/code&gt;, and &lt;code&gt;DELETE /users/{id}&lt;/code&gt;. A human user may be allowed to do all of that. Now an agent is doing it on the user's behalf, and the question becomes: &lt;strong&gt;who is actually authorized here, and for what?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The easy shortcut is to hand the agent one powerful access token and let it call anything. It works, right up until a prompt injection, a misread instruction, or a wrong tool choice turns into a deleted customer. (&lt;strong&gt;Prompt injection&lt;/strong&gt; is when text the model reads, such as the contents of an uploaded document, contains instructions that hijack what the agent does next.)&lt;/p&gt;

&lt;p&gt;The MCP authorization specification is strict about this, and the reasoning applies well beyond MCP. A protected server acts as an OAuth 2.1 resource server, meaning it must validate that a token was issued for &lt;em&gt;itself&lt;/em&gt; and reject anything else. Passing a client's token straight through to an upstream API is explicitly forbidden, because the upstream service can no longer tell who is really asking.&lt;/p&gt;

&lt;p&gt;So the architecture should not be "user token → agent → whatever API the agent wants." It should have a boundary where tokens are validated and permissions are narrow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Agent                          Agent
 ├── customer.read     instead   └── admin.*
 ├── order.read          of
 └── order.cancel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Least privilege&lt;/strong&gt;, meaning each caller gets only the permissions it needs, matters more than ever once software is choosing which operations to invoke. In ASP.NET Core, that boundary is ordinary policy-based authorization applied per tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/agent-tools/cancel-order"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;CancelOrderRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;IOrderService&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CancelAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToHttpResult&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RequireAuthorization&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"order.cancel"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// this tool, this scope, nothing wider&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. Reads and writes are not the same kind of tool
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;get_customer&lt;/code&gt; and &lt;code&gt;delete_customer&lt;/code&gt; are both "tools," but their consequences aren't remotely comparable. It helps to separate them deliberately: reads such as &lt;code&gt;get_order&lt;/code&gt; or &lt;code&gt;search_products&lt;/code&gt; on one side, writes such as &lt;code&gt;refund_payment&lt;/code&gt; or &lt;code&gt;delete_customer&lt;/code&gt; on the other.&lt;/p&gt;

&lt;p&gt;For consequential writes, the agent should confirm with the person before acting: &lt;em&gt;"I found the order and it can still be cancelled. Would you like me to cancel it?"&lt;/em&gt; Only after a yes does &lt;code&gt;cancel_order&lt;/code&gt; run.&lt;/p&gt;

&lt;p&gt;The key distinction is that &lt;strong&gt;authorization and confirmation are different things.&lt;/strong&gt; Having permission to perform an operation doesn't mean the operation should happen without asking, especially when it's irreversible or moves money.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Idempotency stops being optional
&lt;/h2&gt;

&lt;p&gt;Agents retry. Networks fail. Tools time out. And an agent often cannot tell whether an operation actually succeeded.&lt;/p&gt;

&lt;p&gt;Picture a payment that succeeds on the server, followed by a network timeout on the way back. All the agent sees is a timeout, so it tries again, and now the customer has paid twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Idempotency&lt;/strong&gt; means an operation can be repeated without changing the result beyond the first time. The usual mechanism is an &lt;strong&gt;idempotency key&lt;/strong&gt;: the caller sends a unique value with the request, and the server remembers the outcome for that key.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /agent-tools/create-payment
Idempotency-Key: 9c4d7e2a-1f0b-4c88-9a41-2f6e0c3d5b7a
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the server, the first request does the work and stores its result; a repeat of the same key returns the stored result instead of charging again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PaymentResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;CreatePaymentAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PaymentRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FindByKeyAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// already processed: return, don't re-charge&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ChargeAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&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;In production you'd also handle two requests arriving with the same key at once, usually by storing the key first with a unique constraint so the second caller waits or gets the first one's result. The point is that the agent doesn't need to understand any of this. &lt;strong&gt;The API protects itself against repeated execution.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Errors should tell the caller what to do next
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;400 Bad Request&lt;/code&gt; is technically valid and practically useless to an agent. It can't tell whether to fix the arguments, retry later, or stop and ask the user. So it may well do the worst thing: retry the same call in a loop.&lt;/p&gt;

&lt;p&gt;An error that carries a decision inside it works much better:&lt;br&gt;
&lt;/p&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ORDER_CANNOT_BE_CANCELLED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Order 4821 has already been shipped."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"retryable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"nextAction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Offer the customer a return instead."&lt;/span&gt;&lt;span class="w"&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;Now the caller knows what happened, whether retrying is pointless, and what a sensible next step looks like. This is one of those changes that helps humans just as much, which is a good sign you're improving the API rather than decorating it for AI.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Tracing has to cover the whole workflow
&lt;/h2&gt;

&lt;p&gt;Classic request logging tells you that &lt;code&gt;POST /orders&lt;/code&gt; returned 200 in 183 ms. That's no longer the interesting unit of work. A single user request might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;user request → model call → search_customers → get_customer
             → search_orders → cancel_order → model call → answer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that takes 40 seconds, where did the time go? The model, a slow endpoint, a retry, or a loop where the agent kept calling the same tool? You can't tell from per-request logs, which is why &lt;strong&gt;distributed tracing&lt;/strong&gt;, meaning one connected trace that follows a request across every service and call it touches, becomes the thing that saves you.&lt;/p&gt;

&lt;p&gt;OpenTelemetry's GenAI semantic conventions (a shared vocabulary for naming these spans) define spans for agent runs, model calls, and tool executions, along with attributes for the model and token usage. They're still evolving, so names may change, but the shape is already useful. In .NET, a tool span is just an &lt;code&gt;Activity&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;ActivitySource&lt;/span&gt; &lt;span class="n"&gt;Source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"MyApp.AgentTools"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;activity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"execute_tool"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ActivityKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Internal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;SetTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"gen_ai.tool.name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"cancel_order"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;SetTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"app.order.id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that in place, you can follow one user request from the agent's decision, through the tool call, into the API and database, and back out again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;p&gt;If I were designing an agent-facing interface today, this is what I'd hold it to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Explicit operations.&lt;/strong&gt; &lt;code&gt;cancel_order&lt;/code&gt;, not &lt;code&gt;POST /orders/action&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Described side effects.&lt;/strong&gt; The agent should know a tool moves money before it calls it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Narrow permissions.&lt;/strong&gt; Per-tool scopes, never a shared admin token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safe retries.&lt;/strong&gt; Idempotency keys on every state-changing operation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Meaningful responses.&lt;/strong&gt; No decoding of internal status codes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Actionable errors.&lt;/strong&gt; Say whether a retry helps, and what to try instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;End-to-end traces.&lt;/strong&gt; Cover the agent run, not just the HTTP request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Human confirmation&lt;/strong&gt; for anything irreversible or financially significant.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What this means for backend engineers
&lt;/h2&gt;

&lt;p&gt;I don't think agents make REST APIs go away. What's changed is that APIs have a new kind of consumer, one that reads descriptions, picks tools, builds parameters, reacts to errors, retries, and chains operations together.&lt;/p&gt;

&lt;p&gt;That pulls API design into AI system design. And the skills it needs are ones backend engineers already have: clear contracts, least privilege, idempotency, useful errors, and good tracing. We were supposed to be doing all of this anyway. The difference is that a human developer could paper over a vague contract by reading the code, and an agent can't.&lt;/p&gt;

&lt;p&gt;The endpoint hasn't changed. The caller has.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Have you exposed part of your system to an AI agent yet? I'd be interested to hear what you had to change first. Let me know in the comments.&lt;/em&gt; 👇&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>dotnet</category>
      <category>architecture</category>
    </item>
    <item>
      <title>RAG Is Not a Vector Database Problem. It’s a Data Problem.</title>
      <dc:creator>Kainat Saricioglu</dc:creator>
      <pubDate>Wed, 16 Sep 2026 10:00:04 +0000</pubDate>
      <link>https://dev.to/kainat_saricioglu/rag-is-not-a-vector-database-problem-its-a-data-problem-kp2</link>
      <guid>https://dev.to/kainat_saricioglu/rag-is-not-a-vector-database-problem-its-a-data-problem-kp2</guid>
      <description>&lt;p&gt;I built a RAG application, and the more I worked on it, the clearer one thing became: &lt;strong&gt;the vector database was not the difficult part.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It's easy to focus on embeddings, similarity search, pgvector, and which LLM to use. But when a RAG application gives a wrong answer, the cause is usually much earlier in the pipeline. The document may have been parsed incorrectly. A chunk may have lost important context. Metadata may be missing. Or we may simply be sending the wrong pieces of information to the model.&lt;/p&gt;

&lt;p&gt;That changed how I think about RAG. I now see it less as an "AI problem" and more as a &lt;strong&gt;data pipeline problem&lt;/strong&gt;, and that view makes it much easier to reason about and debug.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Quick definitions:&lt;/strong&gt; &lt;strong&gt;RAG (Retrieval-Augmented Generation)&lt;/strong&gt; means searching your own documents for relevant passages and giving them to an LLM along with the user's question, so it answers from your data. An &lt;strong&gt;embedding&lt;/strong&gt; is a list of numbers that represents the meaning of a piece of text, so texts with similar meanings get similar numbers. A &lt;strong&gt;chunk&lt;/strong&gt; is a small piece of a document that gets its own embedding.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The RAG pipeline is longer than it looks
&lt;/h2&gt;

&lt;p&gt;RAG is usually explained as a short chain: question, embedding, vector database, LLM, answer. That makes it sound almost trivial. In a real application, the pipeline looks more like this:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    A[Documents] --&amp;gt; B[Parsing]
    B --&amp;gt; C[Cleaning]
    C --&amp;gt; D[Chunking]
    D --&amp;gt; E[Metadata]
    E --&amp;gt; F[Embedding]
    F --&amp;gt; G[(Vector store)]
    G --&amp;gt; H[Retrieval]
    H --&amp;gt; I[Filtering / Reranking]
    I --&amp;gt; J[Context construction]
    J --&amp;gt; K[LLM]
    K --&amp;gt; L[Answer]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Every arrow is a place where information can be lost. And once information is lost, &lt;strong&gt;no vector database can bring it back.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The vector database gets blamed for problems it didn't create
&lt;/h2&gt;

&lt;p&gt;Imagine you upload a 200-page company policy and ask: &lt;em&gt;"What is the maximum amount an employee can claim for business travel?"&lt;/em&gt; The app answers: &lt;em&gt;"Employees can claim up to $500."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The actual policy says: &lt;em&gt;"Employees can claim up to $500 per trip, excluding accommodation."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Nothing crashed. The vector search worked, and the LLM worked. The answer even looks plausible. But it's incomplete, and the cause could be as simple as the chunker splitting that sentence in two:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Chunk 12: "...Employees can claim up to $500 per trip,"
Chunk 13: "excluding accommodation. Meal expenses are..."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If chunk 12 is retrieved and chunk 13 isn't, the model never sees the full rule, and it can't answer correctly with information it never received. That leads to the mental model I now use:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RAG quality is limited by the quality of the data that reaches retrieval.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Parsing is already a RAG problem
&lt;/h2&gt;

&lt;p&gt;Before you create any embeddings, you have to extract text from the document. With real-world files, and especially PDFs, that's harder than it sounds. A table that looks like this on screen:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Benefit&lt;/th&gt;
&lt;th&gt;Limit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Travel&lt;/td&gt;
&lt;td&gt;$500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Accommodation&lt;/td&gt;
&lt;td&gt;$1,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Meals&lt;/td&gt;
&lt;td&gt;$100&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;can come out of a PDF text extractor like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Benefit Limit Travel Accommodation Meals $500 $1,000 $100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A human instantly sees which amount belongs to which benefit. The extracted text no longer says that. You can embed it with an excellent model, store it in Postgres, and send it to the newest LLM, and you'll still get unreliable answers, because the damage happened before the vector database was involved.&lt;/p&gt;

&lt;p&gt;The practical lesson: &lt;strong&gt;look at your extracted text&lt;/strong&gt; before blaming anything later in the pipeline. For table-heavy documents, it's worth converting tables into a form that keeps each row together, such as &lt;code&gt;Travel: $500&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Chunking is not just "split every 500 characters"
&lt;/h2&gt;

&lt;p&gt;A document isn't just a stream of characters. It has headings, paragraphs, lists, tables, and sentences that refer back to earlier sentences. Consider this section:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Refund Policy
Customers may request a refund within 30 days.
For enterprise customers, this period is extended to 60 days.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A naive chunker might put each sentence in a separate chunk. Now a user asks: &lt;em&gt;"How long do enterprise customers have to request a refund?"&lt;/em&gt; The second chunk is the best match, but on its own it says "this period" without saying &lt;em&gt;which&lt;/em&gt; period, and it doesn't even mention refunds.&lt;/p&gt;

&lt;p&gt;A common fix is &lt;strong&gt;chunk overlap&lt;/strong&gt;, where neighboring chunks share a few sentences so text cut at a boundary also appears whole in the next chunk. Overlap helps, but it doesn't guarantee that a chunk carries its full meaning. Two further approaches often help more:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Split on the document's structure&lt;/strong&gt;, meaning headings and paragraphs, rather than a fixed character count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add the section heading to each chunk's text&lt;/strong&gt; before embedding it, so "this period" arrives together with "Refund Policy".&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. More chunks don't automatically mean better retrieval
&lt;/h2&gt;

&lt;p&gt;When retrieval is weak, it's tempting to just fetch more chunks. In RAG this setting is often called &lt;strong&gt;top K&lt;/strong&gt;, meaning "return the K most similar chunks." Raising K sometimes helps, but it can also hurt.&lt;/p&gt;

&lt;p&gt;If you retrieve 20 chunks and only 4 are relevant, the model now has to reason through old policy versions, duplicates, and loosely related sections. That costs more tokens, adds latency, and increases the chance of conflicting or outdated information in the answer.&lt;/p&gt;

&lt;p&gt;So the goal of retrieval isn't to fetch as much as possible. It's to &lt;strong&gt;fetch the smallest set of information that is sufficient to answer the question.&lt;/strong&gt; A &lt;strong&gt;reranker&lt;/strong&gt;, which is a second model that re-scores the retrieved chunks by how well they actually answer the question, can help you retrieve broadly and then keep only the best few.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Metadata can be as important as embeddings
&lt;/h2&gt;

&lt;p&gt;Each chunk has an embedding, but you usually know much more about it: its department, document type, year, region, and version. Throwing that information away is a mistake.&lt;/p&gt;

&lt;p&gt;If a user asks about &lt;em&gt;"the 2026 travel policy,"&lt;/em&gt; you don't want a 2022 policy ranking highly just because its wording is similar. Metadata lets you combine &lt;strong&gt;semantic similarity&lt;/strong&gt; with &lt;strong&gt;ordinary filters and business rules&lt;/strong&gt;. With pgvector, both fit in one SQL query:&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="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chunk_index&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;document_chunks&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;              &lt;span class="c1"&gt;-- only this customer's data&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Policy'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_current_version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;   &lt;span class="c1"&gt;-- skip outdated versions&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;         &lt;span class="c1"&gt;-- &amp;lt;=&amp;gt; is cosine distance: smaller = more similar&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One pgvector detail is worth knowing here. With an HNSW index (an index that finds &lt;em&gt;approximately&lt;/em&gt; nearest vectors quickly), Postgres finds nearby vectors first and applies the &lt;code&gt;WHERE&lt;/code&gt; filter afterwards, so a strict filter can return fewer rows than your &lt;code&gt;LIMIT&lt;/code&gt;. pgvector 0.8 and later can keep scanning until enough rows match:&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="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;iterative_scan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;relaxed_order&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the point where RAG starts to feel very familiar to backend engineers. It's not just AI anymore. &lt;strong&gt;It's data modeling.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Your schema matters
&lt;/h2&gt;

&lt;p&gt;A chunk is not the document. Its embedding is only one property of it: an indexable representation of part of the document. A schema along these lines keeps the rest of the information that retrieval depends on:&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="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;                  &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tenant_id&lt;/span&gt;           &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;title&lt;/span&gt;               &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;document_type&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;version&lt;/span&gt;             &lt;span class="nb"&gt;INT&lt;/span&gt;  &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;is_current_version&lt;/span&gt;  &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;          &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;document_chunks&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;           &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;document_id&lt;/span&gt;  &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;chunk_index&lt;/span&gt;  &lt;span class="nb"&gt;INT&lt;/span&gt;  &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;section&lt;/span&gt;      &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;content&lt;/span&gt;      &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;embedding&lt;/span&gt;    &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The original content, the relationships, the version, and the permissions all still matter, and they belong in your schema just as much as the vector does.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Access control matters even more in enterprise RAG
&lt;/h2&gt;

&lt;p&gt;Imagine one system holding HR, finance, customer, and engineering documents. A user asks: &lt;em&gt;"What is our salary adjustment policy?"&lt;/em&gt; Semantic search may find exactly the right document, but &lt;strong&gt;should this user be allowed to see it?&lt;/strong&gt; That's a separate question, and similarity search doesn't answer it.&lt;/p&gt;

&lt;p&gt;Retrieved chunks are application data, and they need the same protection as any other data. Authentication, authorization, &lt;strong&gt;tenancy&lt;/strong&gt; (keeping each customer's data separate), auditing, and data retention all still apply. The safest place to enforce them is inside the retrieval query itself, as in the &lt;code&gt;tenant_id&lt;/code&gt; filter above, so unauthorized content never reaches the model at all. AI doesn't remove these backend responsibilities. It makes them more important.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Measure retrieval quality instead of guessing
&lt;/h2&gt;

&lt;p&gt;The easiest mistake is testing RAG by hand. You ask one question, the answer looks good, and you conclude that RAG works. That tells you very little.&lt;/p&gt;

&lt;p&gt;A better approach is a small &lt;strong&gt;evaluation set&lt;/strong&gt;: a list of questions where you already know which chunk should be found. Even 20 to 30 cases are enough to start. Then you can measure the &lt;strong&gt;hit rate&lt;/strong&gt;, which is the share of questions where the expected chunk appears in the top K results:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;EvalCase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;ExpectedChunkId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RetrievalEvaluator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IRetriever&lt;/span&gt; &lt;span class="n"&gt;retriever&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;HitRateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IReadOnlyList&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;EvalCase&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;cases&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;topK&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;evalCase&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cases&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;retriever&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SearchAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;evalCase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;topK&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ChunkId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;evalCase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ExpectedChunkId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;++;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="n"&gt;cases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Count&lt;/span&gt;&lt;span class="p"&gt;;&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;Now, when you change the chunk size or the embedding model, you get a number instead of a feeling. It also changes the debugging conversation from &lt;em&gt;"the LLM gave a bad answer"&lt;/em&gt; to &lt;em&gt;"did retrieval return the right evidence?"&lt;/em&gt;, which is a much more useful engineering question.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Debug RAG from the bottom up
&lt;/h2&gt;

&lt;p&gt;When an answer is wrong, don't start by swapping the LLM. Walk backwards through the pipeline instead:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The source document:&lt;/strong&gt; was the information actually there?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extraction:&lt;/strong&gt; was the text extracted correctly?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chunking:&lt;/strong&gt; did the relevant information stay together?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metadata:&lt;/strong&gt; were the document, version, and tenant attached correctly?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embedding:&lt;/strong&gt; was the chunk embedded at all?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retrieval:&lt;/strong&gt; was the right chunk returned?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context construction:&lt;/strong&gt; did anything get cut off, removed, or reordered?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prompt:&lt;/strong&gt; did we clearly tell the model how to use the context?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The LLM:&lt;/strong&gt; only now is it time to suspect the model.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This works much better when you can see what the model actually received, so log it on every request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"RAG query {QueryId} retrieved chunks {ChunkIds} with distances {Distances}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;queryId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ChunkId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Distance&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without this, it's easy to keep changing the embedding model, then the chunk size, then the LLM, then the prompt, without ever knowing which change fixed the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The backend engineer's view of RAG
&lt;/h2&gt;

&lt;p&gt;RAG is usually presented as an AI architecture, but much of it is really a &lt;strong&gt;data architecture&lt;/strong&gt;. All the familiar backend concerns are still here: ingestion, validation, data modeling, indexing, caching, authorization, versioning, observability, performance, cost, and testing. What's new is that they now sit next to embeddings, semantic retrieval, context construction, and LLM calls. The interesting engineering happens where those two worlds meet.&lt;/p&gt;

&lt;h2&gt;
  
  
  So, should you use a vector database?
&lt;/h2&gt;

&lt;p&gt;Yes. Whether it's a dedicated product or pgvector inside Postgres, you need a way to search by meaning. But I wouldn't start a RAG project by asking &lt;em&gt;"Which vector database should I use?"&lt;/em&gt; I'd start by asking:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"What does my data look like, and what does a correct retrieval result look like?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;From there, work backwards: which questions the system must answer, what evidence each answer needs, where that evidence lives, how documents should be split, which metadata is required, and only then which storage technology fits. &lt;strong&gt;The technology should follow the retrieval requirements, not the other way around.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  One final thought
&lt;/h2&gt;

&lt;p&gt;In a RAG system, the LLM is often the easiest part to replace. You can switch models, vector databases, embedding providers, and frameworks. But if the data pipeline is poor, every combination will keep producing poor results.&lt;/p&gt;

&lt;p&gt;A powerful model can't use information that was never indexed correctly. A vector database can't recover context that chunking destroyed. And an embedding model can't fix a badly extracted document.&lt;/p&gt;

&lt;p&gt;So the next time your RAG application gives a strange answer, don't start with &lt;em&gt;"Which LLM should we use?"&lt;/em&gt; Start with:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"What data did the model actually see?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That question will usually take you much closer to the real problem, because RAG isn't just about giving an LLM access to your data. It's about building a reliable pipeline that gets the right data to the model at the right time. And that's a backend engineering problem.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Have you debugged a RAG system that gave confidently wrong answers? I'd love to hear where the problem turned out to be. Share it in the comments.&lt;/em&gt; 👇&lt;/p&gt;

</description>
      <category>data</category>
      <category>llm</category>
      <category>rag</category>
      <category>software</category>
    </item>
    <item>
      <title>Your Database Said "Success." Your Message Broker Said "Try Again."</title>
      <dc:creator>Kainat Saricioglu</dc:creator>
      <pubDate>Mon, 17 Aug 2026 17:20:39 +0000</pubDate>
      <link>https://dev.to/kainat_saricioglu/your-database-said-success-your-message-broker-said-try-again-4jaj</link>
      <guid>https://dev.to/kainat_saricioglu/your-database-said-success-your-message-broker-said-try-again-4jaj</guid>
      <description>&lt;p&gt;&lt;em&gt;This is where distributed systems get interesting.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you've worked with backend systems long enough, you've probably written code that looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;transaction&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BeginTransactionAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;OrderStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Paid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveChangesAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;messageBus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PublishAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;PaymentCompleted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CommitAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At first glance, it looks reasonable.&lt;/p&gt;

&lt;p&gt;The order is updated.&lt;/p&gt;

&lt;p&gt;The event is published.&lt;/p&gt;

&lt;p&gt;The transaction commits.&lt;/p&gt;

&lt;p&gt;Done.&lt;/p&gt;

&lt;p&gt;Except there is a problem hiding in the middle.&lt;/p&gt;

&lt;p&gt;What happens if &lt;code&gt;PublishAsync()&lt;/code&gt; fails?&lt;/p&gt;

&lt;p&gt;What happens if RabbitMQ is temporarily unavailable?&lt;/p&gt;

&lt;p&gt;What happens if the network connection drops after the broker accepted the message but before your application receives the response?&lt;/p&gt;

&lt;p&gt;What happens if the application crashes at exactly the wrong millisecond?&lt;/p&gt;

&lt;p&gt;And most importantly:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does your system believe happened?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is one of those backend problems that doesn't show up in a happy-path demo.&lt;/p&gt;

&lt;p&gt;It shows up at 3:17 AM in production.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem with "just use a transaction"
&lt;/h2&gt;

&lt;p&gt;Let's simplify the system.&lt;/p&gt;

&lt;p&gt;Imagine an Order Service.&lt;/p&gt;

&lt;p&gt;It has:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;              ┌──────────────┐
              │  Order API   │
              └──────┬───────┘
                     │
                     ▼
              ┌──────────────┐
              │ Order Service│
              └──────┬───────┘
                     │
             ┌───────┴────────┐
             ▼                ▼
      ┌────────────┐   ┌──────────────┐
      │ PostgreSQL │   │   RabbitMQ   │
      └────────────┘   └──────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A request comes in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /orders/123/pay
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The service needs to do two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Update the database.&lt;/li&gt;
&lt;li&gt;Tell other services that the payment succeeded.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Order.Status = Paid
        +
PaymentCompleted event
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And this is where the trouble starts.&lt;/p&gt;

&lt;p&gt;Your database and your message broker are &lt;strong&gt;two different systems&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A database transaction can guarantee atomicity inside the database.&lt;/p&gt;

&lt;p&gt;RabbitMQ doesn't magically become part of that transaction.&lt;/p&gt;

&lt;p&gt;So this is not really:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BEGIN
    UPDATE DATABASE
    PUBLISH MESSAGE
COMMIT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is more like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DATABASE                    MESSAGE BROKER

   │                              │
   │ UPDATE                       │
   │─────────────────────────────&amp;gt;│
   │                              │
   │ COMMIT                       │
   │                              │
   │                              │
   │        ??? PUBLISH ???       │
   │                              │
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is a gap.&lt;/p&gt;

&lt;p&gt;And that gap is where distributed systems become difficult.&lt;/p&gt;




&lt;h1&gt;
  
  
  Failure scenario #1: Database first
&lt;/h1&gt;

&lt;p&gt;Let's say we do the sensible-looking thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveChangesAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;messageBus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PublishAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Suppose the database succeeds.&lt;/p&gt;

&lt;p&gt;Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Database
──────────────
Order 123
Status = Paid
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything looks good.&lt;/p&gt;

&lt;p&gt;But immediately afterward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RabbitMQ
──────────────
PaymentCompleted
       ❌
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Maybe RabbitMQ is temporarily unavailable.&lt;/p&gt;

&lt;p&gt;Maybe DNS failed.&lt;/p&gt;

&lt;p&gt;Maybe the pod restarted.&lt;/p&gt;

&lt;p&gt;Maybe the process crashed.&lt;/p&gt;

&lt;p&gt;Maybe there was a network timeout.&lt;/p&gt;

&lt;p&gt;Now your database says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Payment completed."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But the rest of your system never heard about it.&lt;/p&gt;

&lt;p&gt;The Inventory Service doesn't know.&lt;/p&gt;

&lt;p&gt;The Notification Service doesn't know.&lt;/p&gt;

&lt;p&gt;The Analytics Service doesn't know.&lt;/p&gt;

&lt;p&gt;Whatever depends on &lt;code&gt;PaymentCompleted&lt;/code&gt; doesn't know.&lt;/p&gt;

&lt;p&gt;And if you simply retry the entire HTTP request, you could create another problem.&lt;/p&gt;




&lt;h1&gt;
  
  
  Failure scenario #2: Message first
&lt;/h1&gt;

&lt;p&gt;So perhaps we reverse the order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;messageBus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PublishAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveChangesAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now imagine the message is successfully published.&lt;/p&gt;

&lt;p&gt;Then the database transaction fails.&lt;/p&gt;

&lt;p&gt;Maybe there is a deadlock.&lt;/p&gt;

&lt;p&gt;Maybe a constraint violation occurs.&lt;/p&gt;

&lt;p&gt;Maybe the database connection disappears.&lt;/p&gt;

&lt;p&gt;Now we have the opposite situation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RabbitMQ
──────────────
PaymentCompleted
       ✅

Database
──────────────
Order.Status = Pending
       ❌
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Inventory Service receives:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;PaymentCompleted&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But the Order Service says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Actually... no.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Now we're inconsistent in the other direction.&lt;/p&gt;




&lt;h1&gt;
  
  
  "Can we just use distributed transactions?"
&lt;/h1&gt;

&lt;p&gt;This is where someone usually says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Why not use a distributed transaction?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In theory, we could try to coordinate the database and broker through a distributed transaction protocol.&lt;/p&gt;

&lt;p&gt;In practice, this introduces another set of problems.&lt;/p&gt;

&lt;p&gt;Distributed transactions can be complex, expensive, operationally awkward, and tightly couple infrastructure components.&lt;/p&gt;

&lt;p&gt;The classic transactional outbox pattern exists largely because we want the database update and the intent to publish an event to become atomic &lt;strong&gt;without requiring a two-phase commit across the database and broker&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And this is where I think a very simple idea becomes extremely powerful.&lt;/p&gt;




&lt;h1&gt;
  
  
  The Outbox Pattern
&lt;/h1&gt;

&lt;p&gt;Instead of immediately publishing the message, we save the message &lt;strong&gt;inside the same database transaction&lt;/strong&gt; as the business change.&lt;/p&gt;

&lt;p&gt;Something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    ┌─────────────────────────┐
                    │       Transaction       │
                    │                         │
                    │  UPDATE Orders          │
                    │          +              │
                    │  INSERT OutboxMessage   │
                    │                         │
                    └────────────┬────────────┘
                                 │
                              COMMIT
                                 │
                    ┌────────────▼────────────┐
                    │      Database            │
                    │                          │
                    │ Orders                   │
                    │ OutboxMessages           │
                    └────────────┬─────────────┘
                                 │
                                 │
                         Background Worker
                                 │
                                 ▼
                         ┌──────────────┐
                         │   RabbitMQ   │
                         └──────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the important part:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The application isn't trying to atomically update two systems anymore.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It only has to atomically update one: &lt;strong&gt;the database.&lt;/strong&gt;&lt;/p&gt;




&lt;h1&gt;
  
  
  A simple Outbox table
&lt;/h1&gt;

&lt;p&gt;For example:&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="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;OutboxMessages&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Id&lt;/span&gt;              &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;Type&lt;/span&gt;            &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Payload&lt;/span&gt;         &lt;span class="n"&gt;JSONB&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;OccurredAt&lt;/span&gt;      &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ProcessedAt&lt;/span&gt;     &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RetryCount&lt;/span&gt;      &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now our application transaction becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;transaction&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BeginTransactionAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;OrderStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Paid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;OutboxMessage&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewGuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PaymentCompleted&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;Payload&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;PaymentCompleted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;OccurredAt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OutboxMessages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveChangesAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CommitAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice something important.&lt;/p&gt;

&lt;p&gt;We're &lt;strong&gt;not talking to RabbitMQ inside the transaction anymore&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;We're just changing the database.&lt;/p&gt;

&lt;p&gt;Either both changes succeed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Orders
   +
OutboxMessages
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or neither does.&lt;/p&gt;

&lt;p&gt;That's the part we can make truly atomic.&lt;/p&gt;

&lt;p&gt;The transactional outbox pattern specifically works by storing the outgoing message in the same database transaction and having a separate relay publish it to the broker.&lt;/p&gt;




&lt;h1&gt;
  
  
  But now we have another problem
&lt;/h1&gt;

&lt;p&gt;The outbox worker has to read those messages and publish them.&lt;/p&gt;

&lt;p&gt;Something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;stoppingToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsCancellationRequested&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OutboxMessages&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProcessedAt&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OrderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OccurredAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToListAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;messageBus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PublishAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Payload&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProcessedAt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveChangesAsync&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;Looks good.&lt;/p&gt;

&lt;p&gt;Until we consider this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Worker reads message
2. Worker publishes message
3. RabbitMQ accepts message
4. Worker crashes
5. Worker never marks message as processed
6. Worker restarts
7. Worker publishes message AGAIN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Congratulations.&lt;/p&gt;

&lt;p&gt;We've solved one consistency problem and created another.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Duplicate messages.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And this is one of the most important lessons in distributed systems:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;"Exactly once" is usually much harder than it sounds.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The outbox relay itself can publish a message more than once if it crashes after publishing but before recording that it was published. The standard pattern therefore expects consumers to be able to process duplicate messages safely.&lt;/p&gt;

&lt;p&gt;Which brings us to my favorite word in distributed systems:&lt;/p&gt;

&lt;h1&gt;
  
  
  Idempotency
&lt;/h1&gt;

&lt;p&gt;An operation is idempotent when performing it multiple times has the same effect as performing it once.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Set status = Paid
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;is naturally easier to make idempotent than:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;balance += 100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;because:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Set Paid
Set Paid
Set Paid
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;still results in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Paid
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+100
+100
+100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;doesn't.&lt;/p&gt;




&lt;h1&gt;
  
  
  Make the consumer idempotent
&lt;/h1&gt;

&lt;p&gt;Suppose our PaymentCompleted event reaches the Notification Service.&lt;/p&gt;

&lt;p&gt;We could create an inbox/processed-message table:&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="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;ProcessedMessages&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;MessageId&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ProcessedAt&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;transaction&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BeginTransactionAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;alreadyProcessed&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProcessedMessages&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AnyAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MessageId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alreadyProcessed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;notificationService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SendPaymentConfirmationAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderId&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProcessedMessages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;ProcessedMessage&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;MessageId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ProcessedAt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveChangesAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CommitAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;MessageId&lt;/code&gt; should also be protected by a database-level unique constraint.&lt;/p&gt;

&lt;p&gt;Why?&lt;/p&gt;

&lt;p&gt;Because this is not enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;insert&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;Two instances can execute the check concurrently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Instance A                 Instance B

    │                          │
    │── Does it exist? ───────&amp;gt;│
    │                          │
    │&amp;lt;────── No ───────────────│
    │                          │
    │                          │
    │── Does it exist? ───────&amp;gt;│
    │                          │
    │&amp;lt;────── No ───────────────│
    │                          │
    ▼                          ▼
  INSERT                     INSERT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now both think they're the first.&lt;/p&gt;

&lt;p&gt;That's why the database should enforce the invariant.&lt;/p&gt;

&lt;p&gt;For example:&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="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;ProcessedMessages&lt;/span&gt;
&lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;PK_ProcessedMessages&lt;/span&gt;
&lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MessageId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostgreSQL's unique constraints are enforced through unique indexes, making the database itself responsible for preventing duplicate keys.&lt;/p&gt;

&lt;p&gt;This is a pattern I really like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Application logic decides what should happen.&lt;br&gt;
The database enforces what must never happen.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h1&gt;
  
  
  But wait... there's another race condition
&lt;/h1&gt;

&lt;p&gt;Let's make the system more realistic.&lt;/p&gt;

&lt;p&gt;We have multiple instances:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    ┌───────────────┐
                    │ Load Balancer │
                    └───────┬───────┘
                            │
                 ┌──────────┼──────────┐
                 ▼          ▼          ▼
             API Pod 1  API Pod 2  API Pod 3
                 │          │          │
                 └──────────┼──────────┘
                            ▼
                        Database
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now imagine all three workers poll the outbox at the same time.&lt;/p&gt;

&lt;p&gt;They might all see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Message #123
ProcessedAt = NULL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What stops them from all publishing it?&lt;/p&gt;

&lt;p&gt;This is where things get interesting.&lt;/p&gt;

&lt;p&gt;One approach is to atomically claim rows.&lt;/p&gt;

&lt;p&gt;For example, conceptually:&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="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;OutboxMessages&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ProcessedAt&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;OccurredAt&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact implementation depends on your database, workload and broker semantics, but the underlying idea is important:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reading a message and claiming responsibility for it are not necessarily the same operation.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And once you start thinking about multiple instances, retries and crashes, the design becomes much more than:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Let's add RabbitMQ."&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h1&gt;
  
  
  What about retries?
&lt;/h1&gt;

&lt;p&gt;The worker will fail.&lt;/p&gt;

&lt;p&gt;That's normal.&lt;/p&gt;

&lt;p&gt;A production system should expect failure.&lt;/p&gt;

&lt;p&gt;So instead of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;Publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;catch&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&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 need an actual retry strategy.&lt;/p&gt;

&lt;p&gt;For example:&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
   ↓
failure
   ↓
wait 1 second
   ↓
Attempt 2
   ↓
failure
   ↓
wait 5 seconds
   ↓
Attempt 3
   ↓
failure
   ↓
wait 30 seconds
   ↓
Attempt 4
   ↓
failure
   ↓
Dead Letter / Failed State
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I generally prefer exponential backoff rather than hammering a dependency that is already unhealthy.&lt;/p&gt;

&lt;p&gt;Something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retryCount&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;But retrying forever is not a strategy.&lt;/p&gt;

&lt;p&gt;Eventually you need to know:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"This message is failing too many times. What do we do with it?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's where a dead-letter mechanism, failed state, alerting and operational tooling become important.&lt;/p&gt;

&lt;p&gt;A message stuck for 30 seconds might be normal.&lt;/p&gt;

&lt;p&gt;A message stuck for 30 minutes might be a problem.&lt;/p&gt;

&lt;p&gt;A message stuck for 3 days is probably an incident.&lt;/p&gt;




&lt;h1&gt;
  
  
  Observability is part of the architecture
&lt;/h1&gt;

&lt;p&gt;This is another thing I think backend developers sometimes underestimate.&lt;/p&gt;

&lt;p&gt;Having an outbox is not enough.&lt;/p&gt;

&lt;p&gt;You need to be able to answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;How many messages are waiting?

How old is the oldest message?

How many have failed?

How many retries happened?

Which message is failing?

Which service consumed it?

How long did it take?

Are duplicates increasing?

Is the broker unavailable?

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For example, one metric I'd absolutely want is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;outbox_oldest_message_age_seconds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Queue size = 10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;doesn't tell me much.&lt;/p&gt;

&lt;p&gt;Those 10 messages might have been created 100 milliseconds ago.&lt;/p&gt;

&lt;p&gt;But:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Queue size = 10
Oldest message = 47 minutes old
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;is a very different story.&lt;/p&gt;




&lt;h1&gt;
  
  
  There is no "perfect" outbox implementation
&lt;/h1&gt;

&lt;p&gt;And this is probably the most important point.&lt;/p&gt;

&lt;p&gt;The outbox pattern isn't magic.&lt;/p&gt;

&lt;p&gt;It introduces its own costs.&lt;/p&gt;

&lt;p&gt;You now have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Another table&lt;/li&gt;
&lt;li&gt;Another background process&lt;/li&gt;
&lt;li&gt;Retry logic&lt;/li&gt;
&lt;li&gt;Duplicate handling&lt;/li&gt;
&lt;li&gt;Cleanup/retention&lt;/li&gt;
&lt;li&gt;Monitoring&lt;/li&gt;
&lt;li&gt;Potential ordering problems&lt;/li&gt;
&lt;li&gt;Additional database load&lt;/li&gt;
&lt;li&gt;Operational complexity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The polling-publisher approach, for example, is straightforward and works with SQL databases, but ordering and efficient publishing become concerns. Transaction-log-based approaches can reduce some polling concerns but introduce database-specific infrastructure and their own duplicate-publishing considerations.&lt;/p&gt;

&lt;p&gt;So I don't think the answer should be:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Always use the Outbox Pattern."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The better question is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;"What consistency guarantee does this particular business operation actually require?"&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the architectural question.&lt;/p&gt;




&lt;h1&gt;
  
  
  Payment systems make this especially interesting
&lt;/h1&gt;

&lt;p&gt;Imagine this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Customer
   │
   ▼
Payment API
   │
   ├──────────────► Payment Provider
   │
   ▼
Database
   │
   ▼
Outbox
   │
   ▼
Message Broker
   │
   ├────────► Order Service
   │
   ├────────► Notification Service
   │
   └────────► Analytics
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now think about retries.&lt;/p&gt;

&lt;p&gt;The customer clicks:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pay&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The request times out.&lt;/p&gt;

&lt;p&gt;They click again.&lt;/p&gt;

&lt;p&gt;The first payment might have succeeded even though the client never received the response.&lt;/p&gt;

&lt;p&gt;Now you potentially have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP retry
     +
Payment retry
     +
Message retry
     +
Consumer retry
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every layer can independently retry.&lt;/p&gt;

&lt;p&gt;And suddenly:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Idempotency isn't an optimization.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It's a business requirement.&lt;/p&gt;

&lt;p&gt;You might need an idempotency key such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Idempotency-Key: 4f1a7c...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and persist that key with the operation.&lt;/p&gt;

&lt;p&gt;Now your system can distinguish:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;New request
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Same request being retried
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is one reason I find payment and identity systems particularly interesting: the cost of "doing the same thing twice" can be much higher than simply returning a duplicate record.&lt;/p&gt;




&lt;h1&gt;
  
  
  The architecture I would start with
&lt;/h1&gt;

&lt;p&gt;For a typical .NET microservice, I'd be comfortable starting with something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                         ┌───────────────┐
                         │     Client    │
                         └───────┬───────┘
                                 │
                                 ▼
                         ┌───────────────┐
                         │ ASP.NET Core  │
                         │      API      │
                         └───────┬───────┘
                                 │
                       ┌─────────▼─────────┐
                       │   DB Transaction  │
                       │                   │
                       │ Business Data     │
                       │        +          │
                       │ Outbox Message    │
                       └─────────┬─────────┘
                                 │
                              COMMIT
                                 │
                                 ▼
                         ┌───────────────┐
                         │ Outbox Worker │
                         └───────┬───────┘
                                 │
                           retry/backoff
                                 │
                                 ▼
                         ┌───────────────┐
                         │   RabbitMQ    │
                         └───────┬───────┘
                                 │
                ┌────────────────┼────────────────┐
                ▼                ▼                ▼
          Order Service    Notification       Analytics
                              Service
                │                │
                └───────┬────────┘
                        ▼
                 Idempotent Consumer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And I'd explicitly design for these failure modes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure&lt;/th&gt;
&lt;th&gt;Expected behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Database unavailable&lt;/td&gt;
&lt;td&gt;Request fails; nothing is committed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Business transaction fails&lt;/td&gt;
&lt;td&gt;No event exists&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Broker unavailable&lt;/td&gt;
&lt;td&gt;Event remains in outbox&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worker crashes before publish&lt;/td&gt;
&lt;td&gt;Event is retried&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worker crashes after publish&lt;/td&gt;
&lt;td&gt;Duplicate is possible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consumer receives duplicate&lt;/td&gt;
&lt;td&gt;Duplicate is safely ignored&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consumer fails&lt;/td&gt;
&lt;td&gt;Message is retried&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Message permanently fails&lt;/td&gt;
&lt;td&gt;Dead-letter/failed state + alert&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multiple workers process same event&lt;/td&gt;
&lt;td&gt;Database constraint protects invariant&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's a much more realistic definition of "reliable" than:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"It works when everything works."&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h1&gt;
  
  
  The part I like most about backend engineering
&lt;/h1&gt;

&lt;p&gt;This is why I still find backend development so interesting.&lt;/p&gt;

&lt;p&gt;The difficult part usually isn't writing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveChangesAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difficult part is asking:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if the next line never executes?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And then:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if it executes but the response is lost?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if the application crashes immediately afterward?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if two instances do it simultaneously?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if the message arrives twice?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if the database succeeds but the broker doesn't?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And eventually:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can the system recover without someone manually fixing the data at 3 AM?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's where architecture stops being a collection of boxes and arrows.&lt;/p&gt;

&lt;p&gt;It becomes a set of guarantees.&lt;/p&gt;




&lt;h2&gt;
  
  
  My current rule of thumb
&lt;/h2&gt;

&lt;p&gt;When designing distributed backend systems, I try to think in terms of &lt;strong&gt;failure boundaries&lt;/strong&gt;, not just components.&lt;/p&gt;

&lt;p&gt;Instead of asking:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Should we use RabbitMQ?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I'd ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"What happens if RabbitMQ disappears for 10 minutes?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Instead of:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Should this be a microservice?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I'd ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"What happens when this service is unavailable?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Instead of:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Should we add retries?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I'd ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"What happens when the operation succeeds but the response is lost and we retry it?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Instead of:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Can we process this asynchronously?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I'd ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"What consistency guarantee does the business actually need?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Those questions usually lead to much better architecture decisions.&lt;/p&gt;




&lt;h1&gt;
  
  
  One final thought
&lt;/h1&gt;

&lt;p&gt;I think one of the biggest misconceptions about backend engineering is that reliability comes from adding more infrastructure.&lt;/p&gt;

&lt;p&gt;More services.&lt;/p&gt;

&lt;p&gt;More queues.&lt;/p&gt;

&lt;p&gt;More replicas.&lt;/p&gt;

&lt;p&gt;More caching.&lt;/p&gt;

&lt;p&gt;More Kubernetes.&lt;/p&gt;

&lt;p&gt;More distributed components.&lt;/p&gt;

&lt;p&gt;Sometimes it does.&lt;/p&gt;

&lt;p&gt;But sometimes the best reliability improvement is much simpler:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make the important invariant explicit.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Then decide where that invariant should be enforced.&lt;/p&gt;

&lt;p&gt;Sometimes that's the application.&lt;/p&gt;

&lt;p&gt;Sometimes that's the database.&lt;/p&gt;

&lt;p&gt;Sometimes it's both.&lt;/p&gt;

&lt;p&gt;And sometimes the correct answer is to avoid distributing the operation in the first place.&lt;/p&gt;

&lt;p&gt;The more distributed our systems become, the more valuable these fundamentals become.&lt;/p&gt;

&lt;p&gt;Because eventually every distributed system has the same question waiting for us:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;"What happens when things fail between step A and step B?"&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the question I'm increasingly interested in.&lt;/p&gt;

&lt;p&gt;And I suspect it's one of the questions that separates code that &lt;em&gt;works&lt;/em&gt; from software that can actually survive production.&lt;/p&gt;




&lt;h3&gt;
  
  
  What would you choose?
&lt;/h3&gt;

&lt;p&gt;Suppose you have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Database update
      +
Event publication
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and you cannot use a distributed transaction.&lt;/p&gt;

&lt;p&gt;Would you choose:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A)&lt;/strong&gt; Transactional Outbox&lt;br&gt;
&lt;strong&gt;B)&lt;/strong&gt; Direct publish + retry&lt;br&gt;
&lt;strong&gt;C)&lt;/strong&gt; Event sourcing&lt;br&gt;
&lt;strong&gt;D)&lt;/strong&gt; Something else&lt;/p&gt;

&lt;p&gt;And more importantly:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What failure scenario would drive your decision?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I'd genuinely like to hear how other backend engineers approach this.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm planning to write more about the practical side of backend engineering—especially .NET, distributed systems, microservices, databases, identity/security, and the kinds of problems that only become obvious when software meets production.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;#BackendDevelopment #DotNet #CSharp #Microservices #DistributedSystems #SoftwareArchitecture #RabbitMQ #PostgreSQL #Database #SystemDesign&lt;/p&gt;

</description>
      <category>backenddevelopment</category>
      <category>microservices</category>
      <category>distributedsystems</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
