<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: arun rajkumar</title>
    <description>The latest articles on DEV Community by arun rajkumar (@mickyarun).</description>
    <link>https://dev.to/mickyarun</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3835684%2F4771b603-8faa-42b1-9e0e-0687faea63a3.jpg</url>
      <title>DEV Community: arun rajkumar</title>
      <link>https://dev.to/mickyarun</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mickyarun"/>
    <language>en</language>
    <item>
      <title>Payment Webhooks Will Lie To You. Here's How We Built Ones That Don't (in NestJS)</title>
      <dc:creator>arun rajkumar</dc:creator>
      <pubDate>Wed, 29 Apr 2026 11:48:31 +0000</pubDate>
      <link>https://dev.to/mickyarun/payment-webhooks-will-lie-to-you-heres-how-we-built-ones-that-dont-in-nestjs-30g9</link>
      <guid>https://dev.to/mickyarun/payment-webhooks-will-lie-to-you-heres-how-we-built-ones-that-dont-in-nestjs-30g9</guid>
      <description>&lt;p&gt;A payment webhook fires once. You miss it. The customer thinks they paid. Your dashboard says they didn't.&lt;/p&gt;

&lt;p&gt;Welcome to my Tuesday morning, two years ago.&lt;/p&gt;

&lt;p&gt;I've shipped four payment webhook systems in my career. The first three taught me everything I now refuse to do again. The fourth — the one running inside Atoa today — handles open banking payment notifications across our Node.js services without a single missed event in the last 14 months.&lt;/p&gt;

&lt;p&gt;Here's the boring, opinionated, production-tested pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lie webhooks tell you
&lt;/h2&gt;

&lt;p&gt;Every payment platform sells webhooks the same way:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"We'll notify your endpoint the moment the payment status changes."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What they don't sell you on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Webhooks &lt;strong&gt;retry&lt;/strong&gt;. Sometimes 8 times. Sometimes never.&lt;/li&gt;
&lt;li&gt;Webhooks &lt;strong&gt;arrive out of order&lt;/strong&gt;. &lt;code&gt;failed&lt;/code&gt; can land before &lt;code&gt;pending&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Webhooks &lt;strong&gt;lie about idempotency&lt;/strong&gt;. Two &lt;code&gt;succeeded&lt;/code&gt; events for the same payment is normal, not a bug.&lt;/li&gt;
&lt;li&gt;Webhooks &lt;strong&gt;drop&lt;/strong&gt;. Network blip, your pod restart, a bad DNS lookup — one missed delivery and your reconciliation is wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your webhook handler is a 30-line controller that updates a row in your database, you don't have a payment system. You have a hope.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four-layer pattern
&lt;/h2&gt;

&lt;p&gt;Every webhook flow we run at Atoa has four layers. Skip any one and you'll be reconciling spreadsheets at midnight.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Verify the signature &lt;em&gt;before&lt;/em&gt; you parse the body
&lt;/h3&gt;

&lt;p&gt;The most common bug I see in code reviews from junior devs: parsing the JSON before checking the HMAC.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// webhook.controller.ts&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;atoa&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-atoa-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;RawBody&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// raw, not parsed&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UnauthorizedException&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two non-negotiables:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use the &lt;strong&gt;raw body&lt;/strong&gt; for HMAC verification. NestJS's default JSON parser will mutate whitespace and break your signature check. Enable &lt;code&gt;rawBody: true&lt;/code&gt; on the app.&lt;/li&gt;
&lt;li&gt;Reject before you do &lt;em&gt;anything else&lt;/em&gt;. No DB hits, no logging the payload at info level, nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Acknowledge fast. Process slow.
&lt;/h3&gt;

&lt;p&gt;The webhook controller does two things: verify, enqueue. That's it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// verify (above)&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;payment.webhook&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;  &lt;span class="c1"&gt;// 200 within ~50ms&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your handler takes 8 seconds because you're hitting Stripe + your DB + sending an email, the sender will time out and retry. Now you have two events. Then four. Then the on-call engineer.&lt;/p&gt;

&lt;p&gt;We use BullMQ on Redis. You can use SQS, NATS, Kafka — pick your poison. The point is: &lt;strong&gt;the HTTP response is decoupled from the work&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Idempotency keys are not optional
&lt;/h3&gt;

&lt;p&gt;Every event has an &lt;code&gt;event_id&lt;/code&gt;. Before you do anything in your worker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Processor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;payment.webhook&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WebhookProcessor&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Job&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;WebhookEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payment_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;firstSeen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Duplicate event &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; — skipping`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;applyStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payment_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;firstSeen&lt;/code&gt; is a write to a Postgres table with &lt;code&gt;event_id&lt;/code&gt; as the primary key. If the insert succeeds, this is the first time we've seen this event. If it conflicts, we've processed it before. No race conditions, no Redis dance — just let the database do the work it's good at.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. State machines, not status updates
&lt;/h3&gt;

&lt;p&gt;This is the one that took me three failed payment systems to learn.&lt;/p&gt;

&lt;p&gt;A payment doesn't have a "status field." It has a &lt;strong&gt;state machine&lt;/strong&gt;. Some transitions are legal. Most aren't.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ALLOWED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;PaymentStatus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PaymentStatus&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;initiated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;authorising&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;authorising&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;succeeded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;succeeded&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;            &lt;span class="c1"&gt;// terminal&lt;/span&gt;
  &lt;span class="na"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;               &lt;span class="c1"&gt;// terminal&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;applyStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PaymentStatus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ALLOWED&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Illegal transition: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; → &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// do not update, do not throw — this is normal&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why this matters: when &lt;code&gt;failed&lt;/code&gt; arrives before &lt;code&gt;pending&lt;/code&gt; (and it will), your code shouldn't downgrade a &lt;code&gt;succeeded&lt;/code&gt; payment to &lt;code&gt;failed&lt;/code&gt;. With a state machine, the invalid transition is dropped. The reconciler picks it up later. The customer's payment stays correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we'd never do again
&lt;/h2&gt;

&lt;p&gt;Three patterns I see in the wild that I had to unlearn:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Polling instead of webhooks&lt;/strong&gt;. "We'll just check the status every 30 seconds." Sure — and you'll burn rate limits, miss the 5-second window where a customer is staring at the spinner, and pay for compute that does nothing 99% of the time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replaying webhooks by re-running the handler&lt;/strong&gt;. If the handler does five things, replaying it does five things again. Idempotency keys mean replays are free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logging the full payload at info level&lt;/strong&gt;. PSD2 says your logs are PII now. Log the event_id and the status. Nothing else.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Where this gets you
&lt;/h2&gt;

&lt;p&gt;We process open banking payment notifications across dozens of UK merchants on this exact pattern. Zero missed events in 14 months. Reconciliation runs once a day and finds nothing to reconcile.&lt;/p&gt;

&lt;p&gt;The pattern doesn't care which payment provider you use. Stripe, GoCardless, Atoa — same four layers.&lt;/p&gt;

&lt;p&gt;If you want to see what these webhooks look like on the open banking side, our API docs walk through the full payment lifecycle and the webhook events we fire: &lt;a href="https://docs.atoa.me/api-reference/Payment/process-payment" rel="noopener noreferrer"&gt;docs.atoa.me&lt;/a&gt;. Sandbox is free, no card needed.&lt;/p&gt;

&lt;p&gt;Build the boring layers first. Sleep through Tuesday mornings.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Arun is co-founder &amp;amp; CTO of &lt;a href="https://paywithatoa.co.uk" rel="noopener noreferrer"&gt;Atoa&lt;/a&gt;, a UK open banking payments platform. He's &lt;a class="mentioned-user" href="https://dev.to/mickyarun"&gt;@mickyarun&lt;/a&gt; on X and dev.to. Driven by passion.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nestjs</category>
      <category>webhooks</category>
      <category>openbanking</category>
      <category>node</category>
    </item>
    <item>
      <title>I Asked Three Coding Agents to Build My Son's Cricket Coach a Website. The Result Wasn't Decided by the Model — It Was Decided by Taste.</title>
      <dc:creator>arun rajkumar</dc:creator>
      <pubDate>Tue, 28 Apr 2026 08:51:56 +0000</pubDate>
      <link>https://dev.to/mickyarun/i-asked-three-coding-agents-to-build-my-sons-cricket-coach-a-website-the-result-wasnt-decided-by-3fam</link>
      <guid>https://dev.to/mickyarun/i-asked-three-coding-agents-to-build-my-sons-cricket-coach-a-website-the-result-wasnt-decided-by-3fam</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fog0y03vew7mee6anv6xy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fog0y03vew7mee6anv6xy.png" alt=" " width="800" height="421"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Codex GPT-5.5, Claude Opus 4.7, Gemini 3.1 Pro. Same prompt. Same 18 photos. Five total runs across different effort budgets. The one that won wasn't the prettiest. It was the one that understood the job: parents in Bengaluru enquire on WhatsApp, not contact forms.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;My son's cricket coach asked me for a website.&lt;/p&gt;

&lt;p&gt;Saturday afternoon. He runs &lt;strong&gt;Bangalore Royal Cricket Academy&lt;/strong&gt; — a small but seriously good cricket academy for kids. He had two phone numbers, a folder of 18 WhatsApp photos taken by parents, and a single line of brief: &lt;em&gt;"Like a real cricket academy, parents should be able to call or WhatsApp from their phone."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I'm a CTO. I'm in the trenches with AI coding agents most weeks. This felt like a clean, low-stakes test.&lt;/p&gt;

&lt;p&gt;So I gave the &lt;strong&gt;exact same prompt&lt;/strong&gt; and the &lt;strong&gt;exact same 18 photos&lt;/strong&gt; to three coding agents:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI Codex&lt;/strong&gt; (GPT-5.5, medium effort)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anthropic Claude Opus 4.7&lt;/strong&gt; (low effort, then re-run on medium)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google Gemini 3.1 Pro&lt;/strong&gt; (low effort, then re-run on high)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Five outputs. One Saturday. Five very different opinions on what "a cricket academy website" actually is.&lt;/p&gt;

&lt;p&gt;I went in expecting a verdict on visual quality. I didn't get one. I got something more interesting.&lt;/p&gt;




&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;The prompt was deliberately short:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Build a single-page website for Bangalore Royal Cricket Academy. Brand line: "Nurturing champions, one delivery at a time." Programs: Summer Camp, Weekday Batch, Weekend Batch, Intensive (elite). Two phone numbers. The photos are in &lt;code&gt;/photos for website&lt;/code&gt;. Parents should be able to contact us easily from their phone.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's it. No design system. No colour palette. No mention of WhatsApp by name. No mention of tests, deployment, SEO meta, or Cloudflare. Whatever each agent decided "easily contact us from their phone" meant — that was on the agent.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I got back, in five outputs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Claude Opus 4.7, low effort
&lt;/h3&gt;

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

&lt;p&gt;Single-file HTML, Tailwind via CDN, Bebas Neue display font, royal navy + gold palette.&lt;/p&gt;

&lt;p&gt;The headline made me sit up: &lt;strong&gt;"CHAMPIONS ARE / BUILT HERE."&lt;/strong&gt; with the second half in gold. It was the only one of the five where the hero felt like it belonged on a printed flyer the coach would hand out at a school. Visually polished.&lt;/p&gt;

&lt;p&gt;Engineering-wise, thin: no tests, no OG tags beyond a &lt;code&gt;&amp;lt;meta description&amp;gt;&lt;/code&gt;, photos referenced as &lt;code&gt;img-01.jpg&lt;/code&gt;…&lt;code&gt;img-18.jpg&lt;/code&gt;, all 14 used in a uniform 4-column grid. Tel: links only. No WhatsApp.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Claude Opus 4.7, medium effort
&lt;/h3&gt;

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

&lt;p&gt;Same starting point, completely different output.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;section&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"top"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"relative h-screen min-h-[640px] w-full overflow-hidden"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"absolute inset-0"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"assets/photos/brca-01.jpeg"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"kenburns"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"absolute inset-0 bg-gradient-to-b from-navy-deep/85 via-navy/70 to-navy-deep/95"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  ...
&lt;span class="nt"&gt;&amp;lt;/section&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full-screen hero with a &lt;strong&gt;Ken Burns animation&lt;/strong&gt; on the image. A scroll indicator with an animated dot inside a mouse outline. A &lt;strong&gt;gold cricket-seam pattern divider&lt;/strong&gt; between sections — actual dashed lines that look like ball stitching. Two-image collage in the about section with offset margins. CSS-columns masonry gallery using all 15 photos. Inline-SVG favicon as a data URI (one fewer request). OG tags. &lt;code&gt;theme-color&lt;/code&gt;. WhatsApp deep-link button on the contact section.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://wa.me/917337726777?text=Hi%20BRCA%2C%20I%27d%20like%20to%20know%20more%20about%20your%20programs."&lt;/span&gt;
   &lt;span class="na"&gt;target=&lt;/span&gt;&lt;span class="s"&gt;"_blank"&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"noopener"&lt;/span&gt;
   &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"bg-gold text-navy font-semibold px-6 py-3.5 rounded-md"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  💬 Message us on WhatsApp
&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was the prettiest output of the five. By a clear margin. Bebas Neue + Inter, Ken Burns, gold seam, masonry — the only one I'd let near a printer.&lt;/p&gt;

&lt;p&gt;Still Tailwind via CDN. Still no test suite. Still no automated deploy. Photos renamed semantically (&lt;code&gt;brca-01.jpeg&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Codex GPT-5.5, medium effort
&lt;/h3&gt;

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

&lt;p&gt;Vanilla HTML + 800-line vanilla CSS + 16 lines of vanilla JS. White-and-navy local-business layout. Numbered "01–04" feature blocks. WhatsApp green CTAs in the contact section.&lt;/p&gt;

&lt;p&gt;It looks less editorial than Claude-medium. It also does five things none of the others did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One.&lt;/strong&gt; It picked &lt;strong&gt;6 photos&lt;/strong&gt; out of 18 and renamed them by content:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;brca-team-ground.jpeg
brca-trophy-team.jpeg
brca-trophy-presentation.jpeg
brca-young-achievers.jpeg
brca-coaching-moment.jpeg
brca-floodlight-batch.jpeg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's editorial judgement encoded in code output. It chose; it didn't dump everything into a grid.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two.&lt;/strong&gt; It wrote a &lt;code&gt;_headers&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;/*
  &lt;span class="n"&gt;X&lt;/span&gt;-&lt;span class="n"&gt;Content&lt;/span&gt;-&lt;span class="n"&gt;Type&lt;/span&gt;-&lt;span class="n"&gt;Options&lt;/span&gt;: &lt;span class="n"&gt;nosniff&lt;/span&gt;
  &lt;span class="n"&gt;Referrer&lt;/span&gt;-&lt;span class="n"&gt;Policy&lt;/span&gt;: &lt;span class="n"&gt;strict&lt;/span&gt;-&lt;span class="n"&gt;origin&lt;/span&gt;-&lt;span class="n"&gt;when&lt;/span&gt;-&lt;span class="n"&gt;cross&lt;/span&gt;-&lt;span class="n"&gt;origin&lt;/span&gt;
  &lt;span class="n"&gt;Permissions&lt;/span&gt;-&lt;span class="n"&gt;Policy&lt;/span&gt;: &lt;span class="n"&gt;camera&lt;/span&gt;=(), &lt;span class="n"&gt;microphone&lt;/span&gt;=(), &lt;span class="n"&gt;geolocation&lt;/span&gt;=()

/&lt;span class="n"&gt;assets&lt;/span&gt;/*
  &lt;span class="n"&gt;Cache&lt;/span&gt;-&lt;span class="n"&gt;Control&lt;/span&gt;: &lt;span class="n"&gt;public&lt;/span&gt;, &lt;span class="n"&gt;max&lt;/span&gt;-&lt;span class="n"&gt;age&lt;/span&gt;=&lt;span class="m"&gt;31536000&lt;/span&gt;, &lt;span class="n"&gt;immutable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Security headers and cache rules. I didn't ask for them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three.&lt;/strong&gt; It wrote a real test suite using &lt;code&gt;node:test&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;home page exposes call and WhatsApp enrollment links&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;index.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/href="tel:&lt;/span&gt;&lt;span class="se"&gt;\+&lt;/span&gt;&lt;span class="sr"&gt;917337726777"/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/href="tel:&lt;/span&gt;&lt;span class="se"&gt;\+&lt;/span&gt;&lt;span class="sr"&gt;917337736777"/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/https:&lt;/span&gt;&lt;span class="se"&gt;\/\/&lt;/span&gt;&lt;span class="sr"&gt;wa&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;me&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;917337726777/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/https:&lt;/span&gt;&lt;span class="se"&gt;\/\/&lt;/span&gt;&lt;span class="sr"&gt;wa&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;me&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;917337736777/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;referenced local assets and Cloudflare Pages config exist&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;imageRefs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/src="&lt;/span&gt;&lt;span class="se"&gt;([^&lt;/span&gt;&lt;span class="sr"&gt;"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\.(?:&lt;/span&gt;&lt;span class="sr"&gt;jpg|jpeg|png|webp&lt;/span&gt;&lt;span class="se"&gt;))&lt;/span&gt;&lt;span class="sr"&gt;"/gi&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;imageRefs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;at least six academy photos are used&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;imageRefs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; exists`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three tests. They assert brand text, both phone numbers, both WhatsApp links, security file existence, responsive CSS, and that &lt;strong&gt;every referenced image actually exists on disk&lt;/strong&gt;. That last one is the one I respect most. It catches the single most common silent break in a static site.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Four.&lt;/strong&gt; Every primary CTA is a &lt;code&gt;wa.me&lt;/code&gt; deep link with &lt;strong&gt;prefilled message text&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"contact-link whatsapp"&lt;/span&gt;
   &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://wa.me/917337726777?text=Hi%20BRCA%2C%20I%20would%20like%20to%20know%20more%20about%20cricket%20training."&lt;/span&gt;
   &lt;span class="na"&gt;target=&lt;/span&gt;&lt;span class="s"&gt;"_blank"&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"noopener"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;span&amp;gt;&lt;/span&gt;WhatsApp&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;strong&amp;gt;&lt;/span&gt;7337726777&lt;span class="nt"&gt;&amp;lt;/strong&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not just &lt;code&gt;wa.me/91…&lt;/code&gt;. &lt;strong&gt;Pre-filled message text.&lt;/strong&gt; Parent taps. Message lands. Zero typing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Five.&lt;/strong&gt; It deployed it. It opened my browser, walked me through a Cloudflare OAuth handshake, then pushed the build to Cloudflare Pages. The &lt;code&gt;.wrangler/cache/pages.json&lt;/code&gt; left behind:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"account_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"project_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"brca-academy"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most coding agents stop at &lt;em&gt;"here's the HTML."&lt;/em&gt; Codex stopped at a live URL. That distinction — treating &lt;em&gt;"build a website"&lt;/em&gt; as a unit of work that includes shipping, not just generating markup — is what made me rate it the most production-ready output of the five.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Gemini 3.1 Pro, low effort
&lt;/h3&gt;

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

&lt;p&gt;Dark slate background. Electric blue + amber accents. 60 lines of vanilla JS with an IntersectionObserver scroll-reveal effect.&lt;/p&gt;

&lt;p&gt;It looked like a SaaS analytics dashboard. Wrong audience by about ten years. Photos referenced as &lt;code&gt;photo_1.jpeg&lt;/code&gt;…&lt;code&gt;photo_18.jpeg&lt;/code&gt;. Tel: only.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Gemini 3.1 Pro, high effort
&lt;/h3&gt;

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

&lt;p&gt;Palette fixed: navy + amber. Playfair Display + Outfit for typography. About section with an image collage and an "Elite Training Facility" badge. Wider elite-program card with a dedicated highlights box. Mobile menu with hamburger.&lt;/p&gt;

&lt;p&gt;Visually, a different website from the low-effort version. Genuinely better.&lt;/p&gt;

&lt;p&gt;What it still didn't have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;WhatsApp deep links. Anywhere. Tel: only.&lt;/li&gt;
&lt;li&gt;OG tags or &lt;code&gt;theme-color&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A test suite.&lt;/li&gt;
&lt;li&gt;A deployment config.&lt;/li&gt;
&lt;li&gt;Semantic photo names — still &lt;code&gt;img1.jpeg&lt;/code&gt; through &lt;code&gt;img8.jpeg&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;More budget bought better visuals. It didn't buy better judgement about what a Bengaluru cricket academy website is &lt;em&gt;for&lt;/em&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  What actually decided it
&lt;/h2&gt;

&lt;p&gt;Not the prettiest hero. Not the cleverest animation.&lt;/p&gt;

&lt;p&gt;This:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;In Bengaluru, parents enquire on WhatsApp. Not email. Not contact forms. Not phone calls until they've messaged first.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The single biggest conversion lever for an Indian local business website is &lt;code&gt;wa.me&lt;/code&gt; deep linking with prefilled message text. Parent opens the page. Parent taps the button. WhatsApp opens with "Hi BRCA, I would like to know more about cricket training" already typed. They send. Coach gets a notification.&lt;/p&gt;

&lt;p&gt;Codex did this on every primary CTA. Claude-medium did it as one button at the bottom of the contact section. Claude-low, Gemini-low, and Gemini-high didn't do it at all.&lt;/p&gt;

&lt;p&gt;That single decision was worth more than the prettiest hero in the comparison.&lt;/p&gt;




&lt;h2&gt;
  
  
  The thing I wasn't expecting
&lt;/h2&gt;

&lt;p&gt;I went in assuming effort budget would be the variable that explained quality differences.&lt;/p&gt;

&lt;p&gt;Compare what happened when I doubled the effort budget on each model:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude (low → medium):&lt;/strong&gt; The visual quality jumped from "pretty" to "editorial-grade". It added Ken Burns animation, masonry gallery, OG tags, a &lt;code&gt;theme-color&lt;/code&gt;, semantic photo names, &lt;strong&gt;and a WhatsApp button&lt;/strong&gt;. It also renamed photos from &lt;code&gt;img-XX.jpg&lt;/code&gt; to &lt;code&gt;brca-XX.jpeg&lt;/code&gt;. The model used the extra budget to upgrade both taste &lt;em&gt;and&lt;/em&gt; product judgement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gemini (low → high):&lt;/strong&gt; The visual quality jumped. The palette got fixed. The typography got upgraded. The layout got more sophisticated.&lt;/p&gt;

&lt;p&gt;It still didn't add WhatsApp.&lt;/p&gt;

&lt;p&gt;It still didn't write tests.&lt;/p&gt;

&lt;p&gt;It still didn't deploy.&lt;/p&gt;

&lt;p&gt;It still left photos as &lt;code&gt;img1.jpeg&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;More budget didn't teach the model what the website was &lt;em&gt;for&lt;/em&gt;. It only taught it to make the wrong website prettier.&lt;/p&gt;

&lt;p&gt;The headline isn't &lt;em&gt;Codex won because GPT-5.5 is the best model&lt;/em&gt;. The headline is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Effort budget isn't the variable that explains output quality. Taste is.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Codex on a single medium run produced more production-ready output than Gemini on high. Claude on medium produced the most beautiful site in the lineup. Gemini on high produced a much-improved-but-still-fundamentally-misjudged website.&lt;/p&gt;

&lt;p&gt;The extra budget surfaced what each model already understood about the job. It didn't change what the model thought the job was.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sidebar: Two paths to a Cloudflare token
&lt;/h2&gt;

&lt;p&gt;Worth mentioning because it's the kind of thing CTOs care about.&lt;/p&gt;

&lt;p&gt;When each agent needed to deploy to Cloudflare Pages, they took one of two paths:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Path A — silent OAuth.&lt;/strong&gt; Codex (medium) and Gemini (low) opened my browser, walked me through Cloudflare's OAuth flow, and got a session. Fast. Smooth. I never saw the token. The agent now has access to my entire Cloudflare account for the duration of that session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Path B — paste-your-own-token.&lt;/strong&gt; Claude (at every effort level) and Gemini (at medium effort) said: "Go to Cloudflare → My Profile → API Tokens → Create Token with these specific scopes — Account: Cloudflare Pages: Edit — and paste it here. I won't see your account session." More friction at install time. Also more control: the token is scoped, I can see exactly what I gave the agent, I can rotate or revoke it without touching my main session.&lt;/p&gt;

&lt;p&gt;Both are defensible. Path A optimises for time-to-deploy. Path B optimises for credential hygiene.&lt;/p&gt;

&lt;p&gt;If you're a solo developer building a side project, Path A is probably fine. If you're running production infrastructure for a fintech and an AI agent is asking for credentials, Path B is the only answer. The fact that two of three agents converge to Path B at higher effort levels — Claude always, Gemini at medium and above — suggests their "thoughtful" mode is more security-aware. Codex stayed silent-OAuth even at medium. Worth knowing.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this means for picking a coding agent in 2026
&lt;/h2&gt;

&lt;p&gt;Three takeaways, none of them about benchmarks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One. Test the agent on a job, not on a problem.&lt;/strong&gt; "Build a website" and "build a website that converts WhatsApp leads for an Indian local business" are different evaluations. The first is a syntax exercise. The second tells you whether the agent can read the room.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two. Effort budgets are amplifiers, not teachers.&lt;/strong&gt; They make a model more of what it already is. If a model doesn't understand the job at low effort, high effort will produce a more polished version of the wrong thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three. Production scaffolding is the cheapest signal of seriousness.&lt;/strong&gt; Tests. Headers. OG meta. A &lt;code&gt;404.html&lt;/code&gt;. Curated photos with content-aware filenames. None of these were in my prompt. The agent that wrote all of them on its own is the one I trust with code I can't review line by line.&lt;/p&gt;




&lt;h2&gt;
  
  
  Coda — what actually shipped
&lt;/h2&gt;

&lt;p&gt;I have to be honest about something the single-shot benchmark couldn't capture.&lt;/p&gt;

&lt;p&gt;Codex won my engineering eval. That stands. It's the one I'd hand a junior dev and say &lt;em&gt;"ship it."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;But the one I reached for next was &lt;strong&gt;Claude&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Two more prompts with the medium-effort Claude — &lt;em&gt;"add a persistent WhatsApp floating button," "add a three-card contact section like a real local business, with primary office / coaching desk / WhatsApp"&lt;/em&gt; — and a bit of browser automation to handle the Cloudflare deploy and DNS, and the site went live at &lt;strong&gt;&lt;a href="https://brca.in/" rel="noopener noreferrer"&gt;brca.in&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That's the version the coach is using today. WhatsApp floating button. Three contact cards. A "Free trial session available" pill the coach asked for after the first parent enquiry. A schedule strip. Custom domain. Live HTTPS.&lt;/p&gt;

&lt;p&gt;Why Claude, not Codex — given my own engineering verdict?&lt;/p&gt;

&lt;p&gt;Because the single-shot test answers &lt;em&gt;"which agent has the best instincts."&lt;/em&gt; The shipping test answers &lt;em&gt;"which agent do I want as a collaborator."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Those are different questions. They had different answers for me.&lt;/p&gt;

&lt;p&gt;Claude was the one I wanted to keep editing. The Bebas Neue + gold-seam aesthetic, the masonry gallery, the Ken Burns hero — those are the parts of the design I didn't want to throw away. Codex's output was more correct. Claude's output was the one I had a relationship with.&lt;/p&gt;

&lt;p&gt;That's a real signal. Worth saying out loud.&lt;/p&gt;




&lt;h2&gt;
  
  
  The closer
&lt;/h2&gt;

&lt;p&gt;The coach got a website. Parents got a WhatsApp button. The site is live at &lt;strong&gt;&lt;a href="https://brca.in/" rel="noopener noreferrer"&gt;brca.in&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The first parent message landed in the inbox before sundown. &lt;em&gt;"Hi BRCA, I would like to know more about cricket training."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The one-shot finding holds: at first contact, taste decided the comparison. Codex's instinct for what an Indian local business website needed to do was sharper than any other model in the lineup.&lt;/p&gt;

&lt;p&gt;But the part of the comparison nobody benchmarks is the part that matters most after the demo: &lt;strong&gt;which agent do you actually want to keep working with&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For me, on this job — it was Claude.&lt;/p&gt;

&lt;p&gt;Champions are built here. Apparently websites too.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Site live at &lt;a href="https://brca.in/" rel="noopener noreferrer"&gt;brca.in&lt;/a&gt;. Drop a comment if you'd like the source code for all five runs — happy to share the GitHub repo.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I'd love to know:&lt;/strong&gt; which agent are you reaching for in 2026 — and what's the smallest job you've used to test whether it actually understands the room? Reply below.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>webdev</category>
      <category>startup</category>
    </item>
    <item>
      <title>Your Agent Doesn't Need a Better Model — It Needs a Context Layer</title>
      <dc:creator>arun rajkumar</dc:creator>
      <pubDate>Fri, 24 Apr 2026 12:49:42 +0000</pubDate>
      <link>https://dev.to/mickyarun/your-agent-doesnt-need-a-better-model-it-needs-a-context-layer-41pg</link>
      <guid>https://dev.to/mickyarun/your-agent-doesnt-need-a-better-model-it-needs-a-context-layer-41pg</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwq0i9qwqxxtonx0cw5pm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwq0i9qwqxxtonx0cw5pm.png" alt=" " width="800" height="447"&gt;&lt;/a&gt;We stopped trying to find a better model.&lt;/p&gt;

&lt;p&gt;We built a better context surface. Different problem. Different fix.&lt;/p&gt;

&lt;p&gt;Here's the story of how we got there, and why I think most teams in 2026 are optimising the wrong side of the equation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 1,200-line PR
&lt;/h2&gt;

&lt;p&gt;A few months ago, one of our engineers asked an AI agent to help add a new refund flow to our merchant service. The agent returned a PR. 1,200 lines. It compiled. The tests passed.&lt;/p&gt;

&lt;p&gt;It also did three things we'd explicitly decided, months earlier, to never do in this codebase:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It created a new service-to-service HTTP client instead of using our internal &lt;code&gt;ServiceBus&lt;/code&gt; abstraction.&lt;/li&gt;
&lt;li&gt;It persisted refund state in the merchant service's own database instead of emitting a domain event for the ledger service to consume.&lt;/li&gt;
&lt;li&gt;It wrote a retry loop with &lt;code&gt;setTimeout&lt;/code&gt; instead of using our &lt;code&gt;@Retryable&lt;/code&gt; decorator, which has backoff policies tied to our SLOs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is in the agent's training data. Nothing in the README told it either. And the reviewer — doing the review at 6pm on a Friday — skimmed the diff, saw green CI, and approved.&lt;/p&gt;

&lt;p&gt;Two weeks later we had a duplicate-refund incident. One hour of debugging to find the cause. Not a bug in the agent's code. A design-pattern violation the agent had no way to know existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The realisation
&lt;/h2&gt;

&lt;p&gt;Here's the uncomfortable part.&lt;/p&gt;

&lt;p&gt;The agent didn't do anything wrong. It did exactly what a capable junior engineer would have done if dropped into the repo for the first time with no context. Which is: it solved the immediate problem with reasonable-looking code, using the patterns it had seen in its training data.&lt;/p&gt;

&lt;p&gt;Our new hires did the same thing. I went back and checked. In the six months before that incident, we'd had three separate PRs from three different people — two human, one AI — all creating bespoke HTTP clients instead of using &lt;code&gt;ServiceBus&lt;/code&gt;. All of them reviewed by people who knew better but missed it under time pressure.&lt;/p&gt;

&lt;p&gt;The bug wasn't the model. The bug was that &lt;strong&gt;the knowledge of which patterns we'd consciously chosen to standardise lived nowhere an agent could read it, and only half-lived in the heads of senior engineers who weren't always in the review.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So we stopped chasing model quality and started building the thing that was actually missing: a context layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "context layer" actually means
&lt;/h2&gt;

&lt;p&gt;The phrase gets thrown around loosely since MCP took off, so let me be concrete.&lt;/p&gt;

&lt;p&gt;In our stack, a context layer is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A single, versioned source of truth&lt;/strong&gt; for architectural decisions, design patterns, and merchant-domain invariants.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured as machine-readable documents&lt;/strong&gt; (MDX with frontmatter, not free-form Confluence pages).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Served over MCP&lt;/strong&gt; so the same corpus is queryable by every AI tool on the team — Claude, Cursor, Copilot, our internal agents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enforced by CI&lt;/strong&gt; through design-pattern lints that fail the build when any PR — human-authored or AI-authored — violates a recorded pattern.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The enforcement layer is what most teams skip. The context on its own is a wiki nobody reads. The lints on their own are arbitrary rules nobody remembers the reason for. Pairing them is where the leverage lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three files that made it work
&lt;/h2&gt;

&lt;p&gt;Here's the minimum structure we settled on, with real examples from our monorepo.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. &lt;code&gt;adr/*.mdx&lt;/code&gt; — architectural decisions, machine-readable
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
id: ADR-0047
title: "Service-to-service communication goes through ServiceBus"
status: accepted
date: 2025-11-12
tags: [microservices, inter-service, nestjs]
supersedes: null
lint_rule: no-direct-http-client
---

## Context
15 NestJS microservices. Two years ago, every service had its own
Axios instance. Retry semantics drifted. Timeouts drifted. Tracing
headers got dropped. Incidents had no consistent trail.

## Decision
All service-to-service calls go through @atoa/service-bus, which
wraps Axios with retries, circuit breaking, OpenTelemetry tracing,
and our standard auth header injection.

## Rationale
- Retry policies live in one place, tied to SLOs.
- Every call is traced by default.
- Failures surface consistently in Grafana.

## Enforcement
eslint rule: no-direct-http-client (see lint-rules/)
CI gate: fail on import of 'axios' or 'node:http' in service code.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every ADR has a &lt;code&gt;lint_rule&lt;/code&gt; pointer. No ADR ships without one, unless explicitly marked &lt;code&gt;advisory&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. &lt;code&gt;lint-rules/no-direct-http-client.ts&lt;/code&gt; — the actual enforcement
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;TSESTree&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;TSESLint&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@typescript-eslint/utils&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BANNED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;axios&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:http&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:https&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;undici&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ALLOWED_PATHS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;libs/service-bus/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;libs/http-primitives/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TSESLint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RuleModule&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;useServiceBus&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;problem&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;useServiceBus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Direct HTTP clients are banned. Use @atoa/service-bus. See ADR-0047.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;defaultOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;filename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getFilename&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ALLOWED_PATHS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nc"&gt;ImportDeclaration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;node&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TSESTree&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ImportDeclaration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;BANNED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;useServiceBus&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing clever. The point is: when an agent (or a human) ships the banned pattern, the PR cannot land. Not "a reviewer will notice." The build fails. Every time.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. &lt;code&gt;context.mcp.json&lt;/code&gt; — what we expose to every tool
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"atoa-engineering-context"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.4.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resources"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"uri"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"adr://*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Architectural decisions with enforcement status"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"uri"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pattern://*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Approved design patterns with code examples"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"uri"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"domain://merchant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Merchant domain invariants and flows"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"uri"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"domain://payments"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Payment flow state machines"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tools"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"check_pattern"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Given a code snippet, return any ADR violations it would trigger"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"find_precedent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Search for prior implementations of a similar pattern in our codebase"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every AI tool our team uses mounts this MCP server. When an engineer asks Claude to "add a refund flow," the model has the ADRs in retrieval &lt;em&gt;before&lt;/em&gt; it starts writing code. When it asks "how have we handled async retries in the past," &lt;code&gt;find_precedent&lt;/code&gt; returns the real decorator, not something that looks plausible.&lt;/p&gt;

&lt;p&gt;The agent stopped hallucinating patterns not because the model got smarter. Because we gave it somewhere to look.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened in the last 30 days
&lt;/h2&gt;

&lt;p&gt;We've been running this layer across the full engineering team — 18 people, mix of AI-heavy and AI-light workflows — for just under a quarter now. Last month's numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;23 pattern violations caught&lt;/strong&gt; by design-pattern lints before merge. 14 from human-authored PRs. 9 from AI-authored PRs. The ratio surprised me. I'd expected AI to dominate the violation list. It did not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2 architectural regressions avoided&lt;/strong&gt; that would previously have shipped. One was a would-be duplicate-refund bug in the same area as the Friday-night incident. The lint caught what the reviewer under time pressure would have missed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Onboarding time for a new engineer down from 2 weeks to 4 days&lt;/strong&gt; on the local-dev side, which is a separate story, but the context layer helped here too. New hires read the ADR corpus once, then let the MCP server answer their day-to-day "does this already exist?" questions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero arguments in code review about "is this the right pattern."&lt;/strong&gt; When a disagreement happens, the question becomes "is there an ADR for this?" If yes, the lint decides. If no, we write the ADR.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the quiet win. Code review time on architectural questions dropped by roughly a third, because we stopped relitigating decisions we'd already made.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part most teams get wrong
&lt;/h2&gt;

&lt;p&gt;Two patterns I see repeatedly on teams that try to build this and don't get the leverage:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Context without enforcement.&lt;/strong&gt; A beautiful ADR wiki nobody reads. Every violation still ships because there's no gate. This is where most teams stop because the wiki felt like the real deliverable. It is not. The lint is the real deliverable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Enforcement without context.&lt;/strong&gt; A forest of lint rules nobody understands. The first time someone hits a red CI gate with a rule they've never seen, they open a Slack channel and ask why. If the lint points to an ADR with a clear rationale, the question answers itself. If it points to a rule that just says "forbidden," you've built a political problem disguised as infrastructure.&lt;/p&gt;

&lt;p&gt;Pairing them is not optional. Either one alone is worse than nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for "model quality" debates in 2026
&lt;/h2&gt;

&lt;p&gt;Every week there's a new "is Claude 4.6 better than Opus 4.5 at code" thread. I read them. I have opinions. But in terms of what actually moved the needle on our shipping velocity this quarter — it wasn't the model.&lt;/p&gt;

&lt;p&gt;It was the retrieval surface.&lt;/p&gt;

&lt;p&gt;The model doesn't need to be smarter. It needs to read the right thing before it answers. And once the context layer is good enough, the difference between "good model" and "great model" collapses, because both are now looking at the same authoritative source.&lt;/p&gt;

&lt;p&gt;For 2026, if I had to pick one place to invest a quarter of engineering time to improve AI-native development, it wouldn't be better prompts. It wouldn't be a new IDE extension. It would be this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write down the patterns you've actually chosen. Make them machine-readable. Serve them over MCP. Enforce them in CI. Stop relying on tribal knowledge to survive code review.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The agent isn't the bottleneck. The knowledge surface is.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Arun, CTO and co-founder at &lt;a href="https://paywithatoa.co.uk" rel="noopener noreferrer"&gt;Atoa&lt;/a&gt; — we build open banking payments for the UK. We run 15 NestJS microservices in production and I write about the things we've learned the hard way. Find me on X &lt;a href="https://x.com/mickyarun" rel="noopener noreferrer"&gt;@mickyarun&lt;/a&gt; if you want to argue about any of this.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>ai</category>
      <category>agents</category>
      <category>sre</category>
    </item>
    <item>
      <title>What Developers Get Wrong About PSD2 and Payment Initiation</title>
      <dc:creator>arun rajkumar</dc:creator>
      <pubDate>Wed, 22 Apr 2026 06:16:57 +0000</pubDate>
      <link>https://dev.to/mickyarun/what-developers-get-wrong-about-psd2-and-payment-initiation-2o3m</link>
      <guid>https://dev.to/mickyarun/what-developers-get-wrong-about-psd2-and-payment-initiation-2o3m</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fur71t93pqnivlb3xigu8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fur71t93pqnivlb3xigu8.png" alt=" " width="800" height="447"&gt;&lt;/a&gt;&lt;br&gt;
I've spent UK FinTech Week (April 20–24) reading developer threads about open banking. Same misconceptions every time.&lt;/p&gt;

&lt;p&gt;PSD2 is "just OAuth for banks." Payment Initiation Services are "basically a bank transfer." The whole open banking stack is "Stripe with worse DX."&lt;/p&gt;

&lt;p&gt;None of that is right. And the gap matters, because the developers carrying these assumptions are the ones building the next wave of UK checkouts. If you're shipping payments code in 2026, here's what I'd want you to know before you write the first line.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. PSD2 is not OAuth
&lt;/h2&gt;

&lt;p&gt;The flow looks like OAuth. It is not OAuth.&lt;/p&gt;

&lt;p&gt;OAuth gives an app permission to read or write data on behalf of a user. PSD2's Payment Initiation Service (PIS) gives a regulated third party — the PISP — the legal right to instruct a payment from the user's bank account, with the bank legally obligated to execute it.&lt;/p&gt;

&lt;p&gt;That is a fundamentally different contract.&lt;/p&gt;

&lt;p&gt;The bank is not "letting your app do something." The bank is being compelled by regulation to act on a payment instruction from a licensed PISP, after Strong Customer Authentication (SCA) has been completed. The user authenticates inside their banking app — biometrics, PIN, or device binding — and the bank moves the money. No card network. No tokenisation. No 3DS dance.&lt;/p&gt;

&lt;p&gt;If you treat PIS like OAuth, you'll over-engineer the consent layer and under-engineer the settlement layer. They're different problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. "Just hit the bank API" is not a real architecture
&lt;/h2&gt;

&lt;p&gt;I see a lot of "we'll integrate directly with each bank's API." Sure. There are 9 CMA9 banks in the UK alone. Add the building societies, the challenger banks, and the EU PSD2 obligations if you're cross-border.&lt;/p&gt;

&lt;p&gt;Each bank exposes a slightly different flavour of the Open Banking Standard. Different consent expiry rules. Different ASPSP redirect quirks. Different webhook delivery patterns. Different rate limits.&lt;/p&gt;

&lt;p&gt;We learned this the hard way running 15 microservices for UK payment flows. Bank-by-bank integration is not a feature. It is a maintenance liability that grows linearly with every new bank you add and exponentially with every spec revision the OBIE pushes.&lt;/p&gt;

&lt;p&gt;The architectural choice is binary: become an FCA-authorised PISP yourself (months of compliance work, a regulated entity, ongoing capital requirements), or integrate against an aggregator who's already done it.&lt;/p&gt;

&lt;p&gt;If you're not building a payments company, do not become a PISP. Use one.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. SCA is not a checkbox
&lt;/h2&gt;

&lt;p&gt;Strong Customer Authentication is the single biggest thing developers underestimate.&lt;/p&gt;

&lt;p&gt;You don't add SCA to a payment flow. SCA &lt;em&gt;is&lt;/em&gt; the payment flow.&lt;/p&gt;

&lt;p&gt;Every payment initiation in the UK requires two of three factors: knowledge (PIN), possession (device), inherence (biometrics). The user has to authenticate inside their bank, on every payment, unless an exemption applies — and the exemption rules are tighter than most teams realise. Low-value contactless. Recurring TPP-managed VRPs. Trusted beneficiaries. That's mostly it.&lt;/p&gt;

&lt;p&gt;If your UX assumes "save the bank, charge silently next time" the way Stripe lets you save a card — you're going to ship a flow the bank will block.&lt;/p&gt;

&lt;p&gt;This is also why commercial Variable Recurring Payments (cVRP) is the most-watched topic at UK FinTech Week this week. UK Finance proposed the cVRP Wave 2 commercial model earlier this month. cVRP is the legitimate, regulator-blessed answer to "how do I take recurring open banking payments without making the user re-auth every time." It's coming. Build for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The webhook is the source of truth, not the redirect
&lt;/h2&gt;

&lt;p&gt;This one breaks junior payments code more than anything else.&lt;/p&gt;

&lt;p&gt;The user completes authentication in their bank. The bank redirects them back to your &lt;code&gt;redirectUrl&lt;/code&gt;. Your app shows "Payment successful."&lt;/p&gt;

&lt;p&gt;Wrong. The redirect is a UX hint. It is not a payment confirmation.&lt;/p&gt;

&lt;p&gt;The actual payment status — &lt;code&gt;COMPLETED&lt;/code&gt;, &lt;code&gt;PENDING&lt;/code&gt;, &lt;code&gt;FAILED&lt;/code&gt;, &lt;code&gt;CANCELLED&lt;/code&gt; — comes from a server-to-server webhook the PISP fires once the bank has settled (or refused) the payment instruction. Sometimes that's instant. Sometimes there's a delay if the bank is doing fraud checks. Sometimes the user closes the browser before the redirect fires but the payment still completes.&lt;/p&gt;

&lt;p&gt;If your fulfilment logic depends on the redirect, you will eventually ship orders for payments that never landed, or refuse orders for payments that actually did. We had to retrofit this in our merchant app early on. Webhook-first, redirect-second. Always.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Open banking is not "Stripe but cheaper"
&lt;/h2&gt;

&lt;p&gt;I'll be opinionated here because I think the framing matters.&lt;/p&gt;

&lt;p&gt;Stripe is a magnificent product. It abstracts card networks beautifully. It is also, structurally, a card-rails product paying Visa and Mastercard interchange on every transaction. That's why UK card processing costs sit at 1.5–2.9%. The interchange is a tax built into the rail.&lt;/p&gt;

&lt;p&gt;Open banking is a different rail. There is no interchange. The money moves over Faster Payments (UK) or SEPA Instant (EU). The cost structure is fundamentally different — flat fee, not percentage. Atoa is roughly half the cost of cards because we're not paying Visa for the privilege of moving the money.&lt;/p&gt;

&lt;p&gt;The right mental model is not "Stripe alternative." It is "second payment rail, with different economics, different latency, different UX, different fraud profile, different settlement guarantees."&lt;/p&gt;

&lt;p&gt;For high-ticket B2B invoices, open banking is dramatically better. For impulse e-commerce, cards still win on conversion friction. For UK SaaS doing recurring billing, cVRP is about to flip the calculus. Pick the rail that fits the use case. Don't pick the rail that fits last year's mental model.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR for developers shipping in 2026
&lt;/h2&gt;

&lt;p&gt;PSD2 is not OAuth. Don't integrate banks directly. SCA isn't optional. Webhooks are the source of truth. Open banking is its own rail, not a Stripe replacement.&lt;/p&gt;

&lt;p&gt;If you're at UK FinTech Week this week and want to see this in code, the Atoa sandbox takes 5 minutes to set up. We've spent years getting the integration down to a single API call so you don't have to relearn what we already did wrong.&lt;/p&gt;

&lt;p&gt;Try it: &lt;a href="https://docs.atoa.me" rel="noopener noreferrer"&gt;docs.atoa.me&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What's the misconception about open banking you keep hearing from your engineering team?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Arun Rajkumar is Co-Founder &amp;amp; CTO of Atoa, an FCA-authorised UK open banking payments platform. He writes about CTO lessons, microservices, and what we're learning building a payments rail outside the card networks.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>psd2</category>
      <category>openbanking</category>
      <category>fintech</category>
      <category>api</category>
    </item>
    <item>
      <title>We Built an Open-Source Coding Exam Platform Because Every Vendor Let Us Down</title>
      <dc:creator>arun rajkumar</dc:creator>
      <pubDate>Sat, 11 Apr 2026 01:05:35 +0000</pubDate>
      <link>https://dev.to/mickyarun/we-built-an-open-source-coding-exam-platform-because-every-vendor-let-us-down-a7m</link>
      <guid>https://dev.to/mickyarun/we-built-an-open-source-coding-exam-platform-because-every-vendor-let-us-down-a7m</guid>
      <description>&lt;p&gt;Every year, our team visits engineering colleges across India to hire freshers. The first round is always an online coding test — 300+ students, one shot at finding the ones who can actually think.&lt;/p&gt;

&lt;p&gt;We tried Coderbyte. Fifty concurrent user limit. So we'd split students into batches, stagger timings, juggle schedules between college coordinators and our engineers.&lt;/p&gt;

&lt;p&gt;We tried HackerRank's community edition. Different tool, different headache.&lt;/p&gt;

&lt;p&gt;Every vendor had a ceiling — concurrency limits, inflexible problem formats, generic DSA questions that tested memorization over problem-solving. And the pricing? Designed for companies ten times our size.&lt;/p&gt;

&lt;p&gt;I was ranting about this to my engineering team. Out loud. In our standup. Trying to find yet another vendor to evaluate.&lt;/p&gt;

&lt;p&gt;My engineers — most of them freshers themselves just a couple years ago — went quiet. Said nothing for a few days.&lt;/p&gt;

&lt;p&gt;Then they shipped a product. Two engineers. One weekend. AI-assisted development. And two days of intensive testing before it went live.&lt;/p&gt;




&lt;h2&gt;
  
  
  What They Built
&lt;/h2&gt;

&lt;p&gt;A full-stack, self-hosted coding exam platform. Not a toy. Not a prototype. A production system we ran 300+ students through this hiring season.&lt;/p&gt;

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

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

&lt;p&gt;Here's what's under the hood:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monaco Editor&lt;/strong&gt; — the same engine that powers VS Code. Syntax highlighting, autocomplete, multi-language support. Students write real code, not paste answers into a textarea.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Judge0 Sandboxed Execution&lt;/strong&gt; — every submission runs inside a sandboxed Judge0 instance. Test cases execute in parallel with automatic batching. Students get instant, per-test-case verdicts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ICPC-Style Scoring&lt;/strong&gt; — not just pass/fail. Penalty points for wrong attempts. Time-based ranking. Race-condition-safe writes to the database. The leaderboard feels like a competitive programming contest, not a homework checker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live Leaderboard&lt;/strong&gt; — backed by a PostgreSQL materialized view that refreshes after every accepted submission. O(1) rank queries. Students watch themselves climb in real-time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API-Based Challenges&lt;/strong&gt; — beyond traditional stdin/stdout problems, we built support for API-format challenges where students interact with real endpoints. This lets us test how candidates think about integration, not just algorithms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Server-Synced Timer&lt;/strong&gt; — the countdown runs on server time, not the client clock. No inspect-element tricks. Configurable start/end windows with server-enforced access guards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Autosave&lt;/strong&gt; — code drafts are debounce-saved to the server every few seconds. Browser crash? Tab closed? The student picks up right where they left off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;White-Label Ready&lt;/strong&gt; — app name, logo, brand colors, copyright — all configurable via environment variables. Zero code changes. We use it as our own branded platform; anyone can make it theirs.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture at a Glance
&lt;/h2&gt;

&lt;p&gt;The platform is a monorepo with two core applications:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client/   → Vue 3 SPA (student exam UI + admin panel)
server/   → NestJS REST API (auth, exam logic, code execution, scoring)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In production, the server compiles and serves the client's static build directly — no separate web server or CDN needed.&lt;/p&gt;

&lt;p&gt;The submission flow works like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Student writes code in the Monaco editor and hits Submit&lt;/li&gt;
&lt;li&gt;The Vue client POSTs to the API with the code and language&lt;/li&gt;
&lt;li&gt;The SubmissionsService fetches all test cases and sends batch requests to Judge0, automatically chunking to stay within limits&lt;/li&gt;
&lt;li&gt;The server polls Judge0 tokens until all results resolve&lt;/li&gt;
&lt;li&gt;The ScoringService applies the ICPC penalty formula and updates the score using a pessimistic database lock&lt;/li&gt;
&lt;li&gt;The LeaderboardService refreshes the materialized view&lt;/li&gt;
&lt;li&gt;Results return to the client with per-test-case verdicts and an updated leaderboard&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All of this happens in seconds, even under load.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Tech Stack
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Frontend:&lt;/strong&gt; Vue 3 (Composition API), Vite 8, TypeScript 5.9, Pinia 3 for state, Monaco Editor 0.55, Brotli compression&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backend:&lt;/strong&gt; NestJS 11, TypeScript 5.7, TypeORM 0.3, Passport JWT, Swagger/OpenAPI docs, rate limiting via @nestjs/throttler&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Database:&lt;/strong&gt; PostgreSQL 17 for the application, PostgreSQL 16 + Redis 7.2 for Judge0's internal queue&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Infrastructure:&lt;/strong&gt; Docker Compose orchestrates six services — app, app-db, judge0-server, judge0-worker, judge0-db, and judge0-redis. Multi-stage Dockerfile produces a minimal Node 22-alpine image running as a non-root user.&lt;/p&gt;




&lt;h2&gt;
  
  
  Features That Matter
&lt;/h2&gt;

&lt;p&gt;Here's what we built because we needed it, not because a product manager spec'd it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Multiple concurrent exams&lt;/strong&gt; — run several exams at once; students pick which to enter&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mixed formats&lt;/strong&gt; — MCQs alongside coding problems in the same exam&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Admin panel&lt;/strong&gt; — create exams, duplicate them, manage problems with visible/hidden test cases, configure weights&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safe Exam Browser detection&lt;/strong&gt; — a composable detects whether students are in a locked-down browser&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built-in API docs&lt;/strong&gt; — interactive API reference baked right into the student UI for API-format challenges&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;QA role opt-in&lt;/strong&gt; — students can flag interest in QA engineering during registration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run mode&lt;/strong&gt; — execute code against sample inputs without scoring; lets students experiment before committing&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why Open Source?
&lt;/h2&gt;

&lt;p&gt;We're a fintech startup. Thirty-odd people. We didn't build this to sell it.&lt;/p&gt;

&lt;p&gt;We built it because we were tired of bending our hiring process around someone else's product limitations. And once we had it, we realized every small company visiting colleges faces the exact same problem.&lt;/p&gt;

&lt;p&gt;Here's the thing that makes this story worth telling: two engineers built this in a weekend, with AI doing the heavy lifting on scaffolding, boilerplate, and iteration. Then two days of intensive testing to harden it for production. That's the power of AI-assisted development — it doesn't replace engineers, it turns two of them into ten.&lt;/p&gt;

&lt;p&gt;In the AI era, expensive hiring software shouldn't be a gate that keeps small teams from finding great talent. If two engineers with AI tools can build a platform that handles 300+ concurrent students with ICPC scoring and sandboxed execution in a weekend, there's no reason that capability should be locked behind enterprise pricing.&lt;/p&gt;

&lt;p&gt;The whole thing is AGPL-3.0 licensed. Fork it, brand it, run it on your own infrastructure — just keep your modifications open too.&lt;/p&gt;




&lt;h2&gt;
  
  
  Getting Started
&lt;/h2&gt;

&lt;p&gt;The fastest path is Docker Compose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
&lt;span class="c"&gt;# Set DB_PASSWORD, JWT_SECRET, ADMIN_SETUP_KEY&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six services start in dependency order. The app waits for the database health check, runs migrations automatically, and you're live.&lt;/p&gt;

&lt;p&gt;For local development without Docker, you'll need PostgreSQL 17 and a Judge0 instance. The README walks through every step — database creation, migrations, environment variables, and running the frontend and backend separately.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;We're cleaning up a few things before the public launch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Finishing the test suite (Jest is installed and configured, specs are being added)&lt;/li&gt;
&lt;li&gt;Polishing the contributor docs&lt;/li&gt;
&lt;li&gt;Adding a demo mode so people can try it without setting up Judge0&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're interested, &lt;strong&gt;follow me here&lt;/strong&gt; — I'll drop the GitHub link as soon as the repo goes public.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bigger Lesson
&lt;/h2&gt;

&lt;p&gt;I went looking for a vendor. My team handed me a product.&lt;/p&gt;

&lt;p&gt;Two engineers. One weekend of building. Two days of intensive testing. Powered by AI-assisted development. A platform that replaced two commercial tools and produced measurably better candidate quality in round two.&lt;/p&gt;

&lt;p&gt;That's what happens when you hire people for intent over resumes — and then get out of their way.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Vue 3, NestJS, PostgreSQL, Judge0, and a healthy disregard for vendor lock-in.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Star the repo when it drops. Or better yet — fork it and run your own hiring season on it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>hiring</category>
      <category>webdev</category>
      <category>ai</category>
    </item>
    <item>
      <title>Open Banking Was Built for the Wrong Future — and That's Why It's Perfect for AI Agents</title>
      <dc:creator>arun rajkumar</dc:creator>
      <pubDate>Tue, 07 Apr 2026 19:35:42 +0000</pubDate>
      <link>https://dev.to/mickyarun/open-banking-was-built-for-the-wrong-future-and-thats-why-its-perfect-for-ai-agents-4oha</link>
      <guid>https://dev.to/mickyarun/open-banking-was-built-for-the-wrong-future-and-thats-why-its-perfect-for-ai-agents-4oha</guid>
      <description>&lt;p&gt;Visa announced infrastructure for AI agents to make payments without asking you first.&lt;/p&gt;

&lt;p&gt;GoCardless shipped an MCP server in February so developers can talk to their payment platform in natural language.&lt;/p&gt;

&lt;p&gt;I build open banking payment infrastructure for the UK. I've been watching both of these announcements very closely.&lt;/p&gt;

&lt;p&gt;And I have a counterintuitive take: &lt;strong&gt;the payment rail everyone called "too complicated for normal users" might be the only one that actually works for AI agents.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem With Cards and AI Agents
&lt;/h2&gt;

&lt;p&gt;When an AI agent needs to make a payment on your behalf, the obvious infrastructure is what already exists. Cards. Stored credentials. The same rails your Netflix subscription uses.&lt;/p&gt;

&lt;p&gt;Here's the problem.&lt;/p&gt;

&lt;p&gt;Card authorisation is broad. When you give Stripe a card token, you're essentially giving that token permission to charge whatever you've authorised — subject to 3DS, fraud rules, and limits. But the authorisation scope isn't bound to a specific action.&lt;/p&gt;

&lt;p&gt;For an AI agent, that's dangerous.&lt;/p&gt;

&lt;p&gt;You want an agent to book a flight. It has your card token. Nothing technically stops it from booking the wrong flight, adding seat upgrades you didn't ask for, or — if the prompt is maliciously crafted — doing something you absolutely didn't intend.&lt;/p&gt;

&lt;p&gt;Visa understands this. Their Trusted Agent Protocol exists precisely to solve it: a way for merchants to verify that an agent is legitimate and acting within its authorised scope. It's clever engineering. But it's being bolted onto rails that weren't designed for it.&lt;/p&gt;

&lt;p&gt;Open banking wasn't designed for AI agents either. But its constraints happen to be exactly the right shape.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Open Banking Consent Actually Looks Like
&lt;/h2&gt;

&lt;p&gt;When a customer pays via open banking — the way Atoa processes payments — here's what actually happens under the hood:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Merchant creates a payment consent object
   → amount: £49.99
   → merchant: Atoa test merchant
   → purpose: "Coffee subscription - April"

2. Customer is redirected to their bank
   → Bank shows: "Atoa wants to take £49.99 from your account"
   → Customer approves or declines
   → Bank issues a single-use authorisation code

3. Atoa exchanges the code for the payment
   → One payment. Specific amount. Specific purpose.
   → The authorisation is consumed. It cannot be reused.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every payment is its own consent event. Every consent is scoped to a specific amount and purpose. You can't overcharge. You can't quietly add extras. You can't reuse the authorisation.&lt;/p&gt;

&lt;p&gt;For a human user, this is friction. That's why open banking adoption was slow. Nobody wants to log into their banking app every time they buy something.&lt;/p&gt;

&lt;p&gt;For an AI agent, this friction is a feature.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the Consent Model Fits AI Agents
&lt;/h2&gt;

&lt;p&gt;Think about what you actually want when an AI agent makes a payment on your behalf.&lt;/p&gt;

&lt;p&gt;You want:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It to charge exactly the amount you authorised&lt;/li&gt;
&lt;li&gt;The scope to be limited to what you asked it to do&lt;/li&gt;
&lt;li&gt;The ability to revoke access without cancelling your card&lt;/li&gt;
&lt;li&gt;A clear audit trail showing what was authorised and when&lt;/li&gt;
&lt;li&gt;The payment to fail loudly if anything is out of scope — not silently proceed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Open banking gives you all of that by default.&lt;/p&gt;

&lt;p&gt;Cards give you none of it by default, and you have to engineer it in.&lt;/p&gt;

&lt;p&gt;The FCA even made it better recently. They removed the 90-day re-authentication requirement that was causing 20-40% customer drop-off for third-party payment providers. Persistent consent — once granted to an agent — can now remain valid without forcing a re-authentication loop.&lt;/p&gt;

&lt;p&gt;That's a massive unlock for agentic payment flows.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Real Engineering Challenge: Consent Lifecycle for Agents
&lt;/h2&gt;

&lt;p&gt;Here's where it gets genuinely hard.&lt;/p&gt;

&lt;p&gt;When a human completes an open banking payment, the flow is synchronous: they go to the bank, they approve, they come back. Done.&lt;/p&gt;

&lt;p&gt;When an AI agent initiates a payment, the flow might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User: "Book the Bangalore to London flight if the price drops below £600"

Agent: [Monitors prices for 3 days]
Agent: [Price hits £598 on a Wednesday morning]
Agent: [Attempts to initiate payment]
         → But the user's open banking consent was granted for a specific session
         → That session token expired 6 hours ago
         → Payment fails

Result: Agent missed the window. User wakes up to a "couldn't book your flight" message.
        Price is now £640.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Consent that's synchronous and session-bound doesn't work for agents that act asynchronously.&lt;/p&gt;

&lt;p&gt;This is the real engineering problem. Not "can AI agents make payments?" — they clearly can. But "what does the consent model look like for an agent that might act hours or days after the user gave permission?"&lt;/p&gt;

&lt;p&gt;There are a few approaches:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 1: Pre-authorised payment mandates&lt;/strong&gt;&lt;br&gt;
Open banking supports Variable Recurring Payments (VRPs) — essentially mandates where the user sets a maximum amount and time window, and the payment provider can initiate within those bounds without re-authentication.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Conceptual structure of a VRP mandate for an agent&lt;/span&gt;
&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;AgentPaymentMandate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;agentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;maxAmountPence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// Agent cannot exceed this&lt;/span&gt;
  &lt;span class="nl"&gt;validUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;// Time-bounded consent&lt;/span&gt;
  &lt;span class="nl"&gt;allowedMerchants&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt; &lt;span class="c1"&gt;// Scope: only these merchants&lt;/span&gt;
  &lt;span class="nl"&gt;purpose&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// What this mandate is for&lt;/span&gt;
  &lt;span class="nl"&gt;requiresConfirmation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Some actions still need approval&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent operates within a pre-defined envelope. The user sets the boundaries once. The agent acts within them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 2: Payment intent + human gate&lt;/strong&gt;&lt;br&gt;
Agent identifies a payment opportunity, creates a payment intent, notifies the user. User approves in one tap. Agent executes.&lt;/p&gt;

&lt;p&gt;This is the pattern we're building toward at Atoa — merchant describes what they need in natural language, agent proposes the payment, human approves in one tap, open banking rails execute it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// What an agent workflow might look like&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;intent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;paymentAgent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;proposePayment&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;merchant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flight-booking-service&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;59800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// £598.00 in pence&lt;/span&gt;
  &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BLR→LHR flight, price hit target of £600&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// 30 min window&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// User gets notified: "Your agent wants to book your flight for £598. Approve?"&lt;/span&gt;
&lt;span class="c1"&gt;// One tap. Payment executes.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Option 3: Programmatic consent with audit trail&lt;/strong&gt;&lt;br&gt;
For fully autonomous agents — the Visa Intelligent Commerce model — the agent holds delegated credentials scoped to specific actions, with every payment logged against the authorisation that permitted it.&lt;/p&gt;

&lt;p&gt;We're not here yet in open banking. But the architecture exists to get there.&lt;/p&gt;




&lt;h2&gt;
  
  
  What We're Thinking About at Atoa
&lt;/h2&gt;

&lt;p&gt;We build open banking payment infrastructure. POS terminals, payment links, invoicing, online checkouts. Everything goes through bank payment rails.&lt;/p&gt;

&lt;p&gt;We're thinking about this differently to most — our payment surfaces each have different agent-readiness, and that's shaped how we're approaching the consent problem. When Visa announced Intelligent Commerce, our first question wasn't "can we compete with this?" It was: "which of our surfaces are ready right now, and which ones need the architecture to change?"&lt;/p&gt;

&lt;p&gt;Here's our honest assessment:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pay by Link — probably the most agent-ready thing we have.&lt;/strong&gt; An agent could generate a payment link, send it to a customer, and monitor completion. The consent event is triggered by the link recipient, not the agent. The agent just facilitates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Payment Pages — also strong.&lt;/strong&gt; A merchant's agent could build and publish a payment page with specific parameters. No card infrastructure needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;POS Terminal — hardest.&lt;/strong&gt; The consent flow requires physical presence for SCA. An agent isn't physically present. This one needs new thinking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Invoicing — interesting.&lt;/strong&gt; An agent managing a merchant's books could issue invoices and track payment status. The open banking payment confirmation is machine-readable. This is real today.&lt;/p&gt;

&lt;p&gt;The shape of "agentic commerce" looks different for each surface. There's no one-size-fits-all answer.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Question Every Open Banking Developer Should Be Asking
&lt;/h2&gt;

&lt;p&gt;GoCardless shipping an MCP server tells you something important: &lt;strong&gt;payment infrastructure companies are now thinking about developers' AI workflows as a first-class use case.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not just "can humans use our API?" but "can an AI agent use our API safely?"&lt;/p&gt;

&lt;p&gt;That's a different design question. An API designed for humans assumes there's a human reading error messages, handling edge cases, making judgment calls. An API designed for agents needs those things to be machine-readable, scoped, and predictable.&lt;/p&gt;

&lt;p&gt;Open banking has a head start here. The consent model is explicit. The amounts are bounded. The authorisation chain is auditable. Every payment has a "why" attached to it.&lt;/p&gt;

&lt;p&gt;The engineers who figure out the consent lifecycle problem — how do you grant an agent payment permissions that are time-bounded, amount-bounded, and purpose-bounded, without requiring the human to be present at the moment of execution — will be building the infrastructure that the next decade of agentic commerce runs on.&lt;/p&gt;

&lt;p&gt;That's the problem I'm thinking about.&lt;/p&gt;

&lt;p&gt;What's your take — does the card world catch up to open banking here, or does the consent model give open banking a structural advantage in the agent era?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Arun Rajkumar is CTO &amp;amp; Co-Founder of &lt;a href="https://paywithatoa.co.uk" rel="noopener noreferrer"&gt;Atoa&lt;/a&gt;, an FCA-authorised open banking payments platform in the UK. He writes about payments, fintech engineering, and building for the UK from India. &lt;a href="https://dev.to/mickyarun"&gt;@mickyarun&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>openbanking</category>
      <category>fintech</category>
    </item>
    <item>
      <title>How Open Banking APIs Actually Work — A Developer's Guide</title>
      <dc:creator>arun rajkumar</dc:creator>
      <pubDate>Wed, 01 Apr 2026 15:41:55 +0000</pubDate>
      <link>https://dev.to/mickyarun/how-open-banking-apis-actually-work-a-developers-guide-4mio</link>
      <guid>https://dev.to/mickyarun/how-open-banking-apis-actually-work-a-developers-guide-4mio</guid>
      <description>&lt;p&gt;You've integrated Stripe. You've wired up PayPal. You've copy-pasted card tokenisation code from Stack Overflow at 2am.&lt;/p&gt;

&lt;p&gt;But have you ever looked at what happens when a customer pays directly from their bank account — no card network, no Visa, no Mastercard — just a bank-to-bank transfer that settles in seconds?&lt;/p&gt;

&lt;p&gt;That's open banking. And if you're building for the UK market, you need to understand how it works under the hood. Not the marketing version. The API version.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture in 30 Seconds
&lt;/h2&gt;

&lt;p&gt;Open banking in the UK follows a simple three-party model:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;ASPSP&lt;/strong&gt; (Account Servicing Payment Service Provider) — the customer's bank (Barclays, Monzo, Revolut, etc.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TPP&lt;/strong&gt; (Third Party Provider) — that's you (or the payment platform you integrate with)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open Banking Directory&lt;/strong&gt; — the trust layer that verifies everyone is who they say they are&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When a customer initiates a payment, the flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your App → TPP (e.g. Atoa) → Open Banking API → Customer's Bank → Auth (SCA) → Payment Executed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every connection is secured with mutual TLS certificates and OAuth 2.0. The Open Banking Directory acts as the certificate authority. If you're not in the directory, you don't get to play.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Payment Initiation Flow (PISP)
&lt;/h2&gt;

&lt;p&gt;Here's what actually happens when you trigger an open banking payment. I'll walk through it step by step because this is where most developers get confused.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Create a Payment Consent
&lt;/h3&gt;

&lt;p&gt;Before you can move money, you need consent. This isn't a form checkbox — it's a structured API request that tells the bank exactly what's about to happen.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/open-banking/v&lt;/span&gt;&lt;span class="mf"&gt;3.1&lt;/span&gt;&lt;span class="err"&gt;/pisp/domestic-payment-consents&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Initiation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"InstructionIdentification"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ACME-PAY-001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"EndToEndIdentification"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"E2E-REF-12345"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"InstructedAmount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"Amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"49.99"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"Currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GBP"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"CreditorAccount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"SchemeName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"UK.OBIE.SortCodeAccountNumber"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"Identification"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"11223312345678"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"Name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ACME Coffee Ltd"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Risk"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PaymentContextCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"EcommerceGoods"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bank returns a &lt;code&gt;ConsentId\&lt;/code&gt;. You'll need this for the next step.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Redirect for Strong Customer Authentication (SCA)
&lt;/h3&gt;

&lt;p&gt;This is the part that trips up developers coming from card-land. There's no "enter your card number" form. Instead, you redirect the customer to their bank's authentication page.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://auth.bank.co.uk/authorize?
  response_type=code
  &amp;amp;client_id=your-tpp-client-id
  &amp;amp;redirect_uri=https://yourapp.com/callback
  &amp;amp;scope=payments
  &amp;amp;state=random-csrf-token
  &amp;amp;request=&amp;lt;signed-JWT-with-consent-id&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The customer logs into their bank. Approves the payment. Gets redirected back to your app with an authorization code. Standard OAuth 2.0 — nothing exotic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Execute the Payment
&lt;/h3&gt;

&lt;p&gt;Exchange the auth code for an access token, then hit the payment execution endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/open-banking/v&lt;/span&gt;&lt;span class="mf"&gt;3.1&lt;/span&gt;&lt;span class="err"&gt;/pisp/domestic-payments&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Authorization:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Bearer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;lt;access-token&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ConsentId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"58923"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Initiation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;same&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;as&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;consent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;*/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. The bank debits the customer. The money lands in the merchant's account. No interchange fee. No scheme fee. No acquirer. No gateway skimming a percentage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Matters (The Developer Economics)
&lt;/h2&gt;

&lt;p&gt;Here's the part I care about as a CTO running a payments company.&lt;/p&gt;

&lt;p&gt;Card payments involve four intermediaries, each taking a cut. Open banking has one: the payment initiation service. At Atoa, we charge roughly half what card processors do. That's not a marketing claim — it's structural. When you remove Visa and Mastercard from the equation, the cost drops.&lt;/p&gt;

&lt;p&gt;For developers, the integration is actually simpler than cards. No PCI-DSS compliance headaches. No storing card numbers. No dealing with 3D Secure failures. The bank handles all the authentication.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Even Simpler Path: Using a Payment API
&lt;/h2&gt;

&lt;p&gt;Now, you &lt;em&gt;could&lt;/em&gt; register as a PISP with the FCA, get your certificates, integrate with every UK bank individually, and handle all the consent management yourself.&lt;/p&gt;

&lt;p&gt;Or you could use a payment initiation API that abstracts all of that.&lt;/p&gt;

&lt;p&gt;Here's what a payment request looks like with Atoa's API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.atoa.me/api/payments/process-payment &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer YOUR_API_KEY"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "amount": 49.99,
    "currency": "GBP",
    "reference": "Order-12345",
    "redirectUrl": "https://yourapp.com/payment-complete",
    "customerEmail": "customer@example.com"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five fields. One API call. The customer gets redirected to their bank, authenticates, and the payment is done. You get a webhook when the money lands.&lt;/p&gt;

&lt;p&gt;No card numbers to tokenise. No PCI audit. No 3D Secure fallback logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Coming: Variable Recurring Payments
&lt;/h2&gt;

&lt;p&gt;Here's what's got me excited right now. The first wave of commercial Variable Recurring Payments (cVRPs) went live in Q1 2026. This is the open banking equivalent of Direct Debits — but better.&lt;/p&gt;

&lt;p&gt;With cVRPs, a customer authorises a payment mandate once, and you can collect future payments within agreed limits without redirecting them to their bank every time. Think subscription billing, utility payments, or regular top-ups — all via bank transfer, no card-on-file needed.&lt;/p&gt;

&lt;p&gt;The FCA and PSR are watching adoption closely. By end of 2026, they'll evaluate whether industry-led cVRP adoption needs regulatory nudges. If you're building subscription infrastructure for the UK market, this is the API to watch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The TL;DR for Developers
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Open banking payments are OAuth 2.0 + bank redirects.&lt;/strong&gt; If you've built a "Login with Google" flow, you understand 70% of it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No card data means no PCI-DSS scope.&lt;/strong&gt; Your security surface shrinks dramatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Settlement is near-instant.&lt;/strong&gt; No T+2 waiting for card settlements.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Costs are structurally lower.&lt;/strong&gt; No interchange, no scheme fees.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VRPs are the next frontier.&lt;/strong&gt; Recurring bank payments without the friction of Direct Debit mandates.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want to try it yourself, Atoa has a sandbox environment where you can test the full payment flow without moving real money. The API docs are at &lt;a href="https://docs.atoa.me" rel="noopener noreferrer"&gt;docs.atoa.me&lt;/a&gt; — the payment initiation endpoint is the one you want to start with.&lt;/p&gt;

&lt;p&gt;Build something. Break something. Ship it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Arun Rajkumar is Co-Founder &amp;amp; CTO of Atoa, a UK open banking payments platform. He writes about payments infrastructure, developer experience, and building fintech from India for the UK market. Follow him on X &lt;a href="https://x.com/mickyarun" rel="noopener noreferrer"&gt;@mickyarun&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>openbanking</category>
      <category>api</category>
      <category>fintech</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why Open Banking Is Eating Card Payments in the UK (And the Numbers Prove It)</title>
      <dc:creator>arun rajkumar</dc:creator>
      <pubDate>Tue, 31 Mar 2026 18:01:30 +0000</pubDate>
      <link>https://dev.to/mickyarun/why-open-banking-is-eating-card-payments-in-the-uk-and-the-numbers-prove-it-34bo</link>
      <guid>https://dev.to/mickyarun/why-open-banking-is-eating-card-payments-in-the-uk-and-the-numbers-prove-it-34bo</guid>
      <description>&lt;p&gt;I've been building payment infrastructure for the last few years. Cards were the default. Visa, Mastercard, Stripe — the holy trinity of "just make it work."&lt;/p&gt;

&lt;p&gt;Then I looked at the numbers.&lt;/p&gt;

&lt;p&gt;53% growth in open banking payments year-on-year. 351 million payments in 2025 alone. 33.1 million users expected by 2026 — that's over 60% of UK adults. And account-to-account payments are projected to grow at 13.63% CAGR through 2031 — the fastest of any payment method in the UK.&lt;/p&gt;

&lt;p&gt;Meanwhile, card transaction growth has flatlined. Debit cards still hold about 42% of the UK market, but merchants are quietly migrating away. The reason isn't complicated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's the fees.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Tax You Don't See
&lt;/h2&gt;

&lt;p&gt;Here's what actually happens when a customer taps their card at your checkout:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Interchange fee&lt;/strong&gt; → goes to the card-issuing bank (0.2–0.3% for UK debit, 0.3% for credit)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheme fee&lt;/strong&gt; → goes to Visa or Mastercard (0.02–0.15% plus per-transaction)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Acquirer fee&lt;/strong&gt; → goes to your payment processor&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gateway fee&lt;/strong&gt; → goes to your payment gateway&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Stack all of that up and you're looking at around 2.8% per transaction. On a £100 sale, that's £2.80 gone before you've paid rent.&lt;/p&gt;

&lt;p&gt;For a small UK business doing £50K/month in card payments, that's £1,400/month in processing fees. £16,800 a year. Just for moving money from point A to point B.&lt;/p&gt;

&lt;p&gt;I kept staring at that number. There had to be a better way.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Open Banking Actually Works (Developer Edition)
&lt;/h2&gt;

&lt;p&gt;Open banking cuts out the middlemen. No card networks. No interchange. No scheme fees. The money moves directly from the customer's bank account to yours via the UK's Faster Payments rails.&lt;/p&gt;

&lt;p&gt;Here's the flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Customer → Clicks "Pay by Bank" → Redirected to their bank app
→ Authenticates (biometrics/PIN) → Confirms payment
→ Funds move instantly via Faster Payments → Merchant receives funds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From a developer's perspective, the integration looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Initiate a payment via open banking API&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.youropenbanking.provider/v1/payments&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;{access_token}&lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="s2"&gt;,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    amount: {
      currency: 'GBP',
      value: '49.99'
    },
    creditor: {
      account: {
        sortCode: '123456',
        accountNumber: '12345678'
      },
      name: 'Your Business Ltd'
    },
    reference: 'ORDER-2026-0331',
    redirect_url: 'https://yoursite.com/payment/callback'
  })
});

// Response includes a bank authorization URL
const { authorizationUrl, paymentId } = await payment.json();
// Redirect customer to their bank for SCA
window.location.href = authorizationUrl;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The customer gets redirected to their bank, authenticates with Strong Customer Authentication (usually biometrics on their phone), confirms the payment, and gets redirected back. The whole flow takes under 10 seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost?&lt;/strong&gt; Around 0.8%. On that same £100 transaction, you're paying £0.80 instead of £2.80.&lt;/p&gt;

&lt;p&gt;That's not an optimisation. That's different economics entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Developer Experience Gap (And Why It's Closing)
&lt;/h2&gt;

&lt;p&gt;I'll be honest — two years ago, integrating open banking was painful. Multiple bank APIs, inconsistent standards, redirect flows that broke on mobile. Stripe was easier, and "easier" wins in developer land.&lt;/p&gt;

&lt;p&gt;That's changed. Fast.&lt;/p&gt;

&lt;p&gt;The UK Open Banking Standard (maintained by the OBIE) has matured. Payment Initiation Service Provider (PISP) APIs now follow consistent patterns. You don't need to integrate with each bank individually — providers aggregate the bank connections and give you a single API.&lt;/p&gt;

&lt;p&gt;At Atoa, this is exactly what we built. One API. All UK banks. Pay by Link, QR code, eCommerce checkout, even POS terminals. We're FCA-authorised, ISO-27001 and SOC2 certified, because when you're moving money, "it works on my machine" doesn't cut it.&lt;/p&gt;

&lt;p&gt;Here's what our integration looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Create a payment link via Atoa API&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.atoa.me/api/v1/payments &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer YOUR_API_KEY"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "amount": 49.99,
    "currency": "GBP",
    "description": "Order #2026-0331",
    "redirectUrl": "https://yoursite.com/thanks",
    "customerEmail": "customer@example.com"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No card tokenisation. No PCI-DSS scope expansion. No 3D Secure headaches. The customer pays directly from their bank.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Developers Get Wrong About Open Banking
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Myth 1: "Customers won't trust it."&lt;/strong&gt;&lt;br&gt;
33 million UK adults are already using it. Every major UK bank supports it. The authentication happens inside your own banking app — it's actually &lt;em&gt;more&lt;/em&gt; secure than typing card numbers into a web form.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Myth 2: "It's only for big transactions."&lt;/strong&gt;&lt;br&gt;
Wrong. The fastest growth is in everyday payments. Coffee shops, retail, subscriptions. The flat-fee model makes it &lt;em&gt;more&lt;/em&gt; cost-effective for small transactions than cards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Myth 3: "The UX is worse than cards."&lt;/strong&gt;&lt;br&gt;
Have you tried Apple Pay recently? Open banking checkout is the same number of taps. Select bank → authenticate → done. No card number entry. No expiry dates. No CVV.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Myth 4: "It's a UK-only thing."&lt;/strong&gt;&lt;br&gt;
PSD2 covers all of Europe. Open banking frameworks are launching in Brazil, Australia, India (UPI is essentially open banking on steroids), Saudi Arabia, and Nigeria. If you build for open banking now, you're building for the global rails of tomorrow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Numbers That Changed My Mind
&lt;/h2&gt;

&lt;p&gt;Let me put this plainly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Card Payments&lt;/th&gt;
&lt;th&gt;Open Banking&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cost per £100 txn&lt;/td&gt;
&lt;td&gt;£2.80 (2.8%)&lt;/td&gt;
&lt;td&gt;£0.80 (0.8%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Settlement time&lt;/td&gt;
&lt;td&gt;1–3 business days&lt;/td&gt;
&lt;td&gt;Instant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chargebacks&lt;/td&gt;
&lt;td&gt;Yes (costly)&lt;/td&gt;
&lt;td&gt;No (irrevocable)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PCI-DSS scope&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failed payment rate&lt;/td&gt;
&lt;td&gt;5–15% (expired cards)&lt;/td&gt;
&lt;td&gt;&amp;lt;2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Integration complexity&lt;/td&gt;
&lt;td&gt;Moderate&lt;/td&gt;
&lt;td&gt;Simple (single API)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The chargeback point alone is massive. If you've ever dealt with friendly fraud on card payments, you know how much time, money, and sanity it costs. Open banking payments are irrevocable — once the customer authenticates with their bank, the payment is final.&lt;/p&gt;

&lt;h2&gt;
  
  
  So Why Hasn't Everyone Switched?
&lt;/h2&gt;

&lt;p&gt;Awareness. That's the honest answer.&lt;/p&gt;

&lt;p&gt;Here's a stat that blew my mind: only 38% of UK consumers recognise the phrase "Pay by Bank" — &lt;em&gt;down&lt;/em&gt; from 55% in 2025. Usage is up 53%, but brand recognition is falling. The payments industry has a marketing problem, not a technology problem.&lt;/p&gt;

&lt;p&gt;And that's actually the opportunity for developers. The infrastructure is ready. The economics are compelling. The UX is mature. What's missing is more developers building with it, more merchants offering it, and more consumers seeing it at checkout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting Started
&lt;/h2&gt;

&lt;p&gt;If you want to try this yourself:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Sandbox first:&lt;/strong&gt; Most open banking providers offer sandbox environments. At Atoa, ours is at &lt;a href="https://docs.atoa.me" rel="noopener noreferrer"&gt;docs.atoa.me&lt;/a&gt; — you can test payment flows without moving real money.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start with Pay by Link:&lt;/strong&gt; It's the simplest integration. Generate a link, send it to a customer, they pay. No frontend changes needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add to checkout:&lt;/strong&gt; Once you're comfortable, add a "Pay by Bank" button alongside your card option. A/B test it. Watch the conversion rates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Go deeper:&lt;/strong&gt; Webhooks for real-time payment notifications, recurring payments via Variable Recurring Payments (VRP), and batch payments for payroll or marketplace payouts.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The docs are at &lt;a href="https://docs.atoa.me/api-reference/Payment/process-payment" rel="noopener noreferrer"&gt;docs.atoa.me/api-reference&lt;/a&gt;. If you're on WordPress/WooCommerce, there's a plugin that takes about 5 minutes to set up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;Card payments aren't going to vanish overnight. But the trajectory is clear. 53% growth. Instant settlement. 0.8% vs 2.8%. No chargebacks. Simpler compliance.&lt;/p&gt;

&lt;p&gt;I'm biased — I've spent the last few years building this. But the numbers aren't biased. They just are.&lt;/p&gt;

&lt;p&gt;If you're building payments in the UK and you're still defaulting to cards, you're leaving money on the table. Literally.&lt;/p&gt;

&lt;p&gt;Try the sandbox. Run the numbers for your use case. Then decide.&lt;/p&gt;

&lt;p&gt;→ &lt;strong&gt;&lt;a href="https://docs.atoa.me" rel="noopener noreferrer"&gt;docs.atoa.me&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Arun Rajkumar is Co-Founder &amp;amp; CTO of Atoa, a UK open banking payments platform backed by a16z. He writes about payments, developer experience, and building fintech from India for the UK market. Follow him on X &lt;a href="https://x.com/mickyarun" rel="noopener noreferrer"&gt;@mickyarun&lt;/a&gt; and &lt;a href="https://dev.to/mickyarun"&gt;dev.to/mickyarun&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>openbanking</category>
      <category>fintech</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>How We Built Atoa's Payment Infrastructure with 15 NestJS Microservices (And What Took the Most Figuring Out)</title>
      <dc:creator>arun rajkumar</dc:creator>
      <pubDate>Wed, 25 Mar 2026 05:44:58 +0000</pubDate>
      <link>https://dev.to/mickyarun/how-we-built-atoas-payment-infrastructure-with-15-nestjs-microservices-and-what-took-the-most-3ob6</link>
      <guid>https://dev.to/mickyarun/how-we-built-atoas-payment-infrastructure-with-15-nestjs-microservices-and-what-took-the-most-3ob6</guid>
      <description>&lt;p&gt;Open banking in the UK just crossed 24 billion successful API calls in 2025. Payment initiation grew 53% year-on-year. That's not a trend — that's a tectonic shift.&lt;/p&gt;

&lt;p&gt;We've been building in this space for years now. Atoa processes open banking payments for UK merchants — Pay by Link, QR codes, POS terminals, online checkouts. Half the cost of card payments. Instant settlement. No Visa or Mastercard in the middle.&lt;/p&gt;

&lt;p&gt;And our entire payment infrastructure runs on 15 NestJS microservices.&lt;/p&gt;

&lt;p&gt;Here's what we got right. And what took the most figuring out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why NestJS for Payments
&lt;/h2&gt;

&lt;p&gt;When I started architecting Atoa's backend, the decision came down to two things: developer velocity and reliability.&lt;/p&gt;

&lt;p&gt;We were a small team. Mostly freshers and interns — people I'd bet on because of intent, not because of their resume. One of our earliest hires joined as a fresher. Five years later, he's our Technology Architect making every major tech decision. Another started as an intern. He coded our Open Banking module end-to-end.&lt;/p&gt;

&lt;p&gt;These people needed a framework that was opinionated enough to enforce structure but flexible enough to let them move fast. NestJS gave us that. Decorators, dependency injection, modular architecture — it reads like a blueprint, not spaghetti.&lt;/p&gt;

&lt;p&gt;We chose TypeScript everywhere. Zod for runtime validation. If a payment request hits our API with a malformed amount or missing merchant ID, it dies at the gate. In payments, a silent failure isn't a bug — it's someone's revenue disappearing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 15-Service Architecture
&lt;/h2&gt;

&lt;p&gt;Here's a simplified view of our service boundaries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────┐
│                API Gateway                   │
│            (Traefik v3 + Auth)               │
└──────────┬──────────┬──────────┬────────────┘
           │          │          │
    ┌──────▼──┐ ┌─────▼────┐ ┌──▼──────────┐
    │ Payment │ │ Merchant │ │ Notification │
    │ Service │ │ Service  │ │   Service    │
    └──────┬──┘ └──────────┘ └─────────────┘
           │
    ┌──────▼──────────────────────────────┐
    │        Open Banking Gateway          │
    │  (Bank API adapters, token mgmt,     │
    │   consent flows, SCA handling)       │
    └──────┬──────────────────────────────┘
           │
    ┌──────▼──┐ ┌──────────┐ ┌────────────┐
    │ Ledger  │ │ Webhook  │ │ Settlement │
    │ Service │ │ Service  │ │  Service   │
    └─────────┘ └──────────┘ └────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each service owns its domain. The Payment Service doesn't know how settlements work. The Merchant Service doesn't touch bank APIs. Clean boundaries.&lt;/p&gt;

&lt;p&gt;We use Traefik v3 as our API gateway — routing, rate limiting, TLS termination, health checks. It plays beautifully with Docker and our Kubernetes setup. Our DevOps lead (Kubestronaut certified, by the way) architected the infra. The only downtime we've ever had? AWS London went down during the UK heatwave about two years ago. That wasn't on us. Everything else — 100% uptime.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Hard Part: Every Bank is Different
&lt;/h2&gt;

&lt;p&gt;Here's what the "open banking is easy" crowd doesn't tell you.&lt;/p&gt;

&lt;p&gt;Every UK bank implements authentication differently. What works in sandbox breaks in production. Extra consent screens. Different redirect logic. Strong Customer Authentication flows that behave one way on mobile, another on desktop.&lt;/p&gt;

&lt;p&gt;We built an adapter layer inside our Open Banking Gateway. Each bank gets its own adapter that normalises the authentication flow into a consistent interface. When a merchant's customer pays via Atoa, they don't know (or care) that Barclays handles redirects differently than Monzo.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simplified bank adapter pattern&lt;/span&gt;
&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;BankAdapter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;initiatePayment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PaymentInitParams&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ConsentUrl&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;handleCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bankResponse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;PaymentResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;getPaymentStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;paymentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;PaymentStatus&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Each bank gets its own implementation&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BarclaysAdapter&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;BankAdapter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;initiatePayment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PaymentInitParams&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Barclays-specific consent flow&lt;/span&gt;
    &lt;span class="c1"&gt;// Handles their unique SCA requirements&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;validated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;PaymentInitSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Zod validation&lt;/span&gt;
    &lt;span class="c1"&gt;// ... bank-specific logic&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This adapter pattern saved us hundreds of hours. New bank? New adapter. Same interface. No touching the Payment Service.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Took the Most Figuring Out: Local Development
&lt;/h2&gt;

&lt;p&gt;Fifteen microservices. Each with its own database connection, environment variables, and dependencies. Onboarding a new developer used to take two weeks. Two weeks of "why isn't this service connecting" and "which env file do I need."&lt;/p&gt;

&lt;p&gt;We fixed this. I wrote about it in detail on dev.to — &lt;a href="https://dev.to/mickyarun/we-had-15-microservices-and-it-took-2-weeks-to-onboard-a-developer-heres-how-we-fixed-it-in-a-258a"&gt;how we went from 2 weeks to 1 day for developer onboarding&lt;/a&gt;. The short version: Docker Compose orchestration, shared environment templates, and a single &lt;code&gt;make dev&lt;/code&gt; command that spins up the entire stack.&lt;/p&gt;

&lt;p&gt;One of our developers joined with a B.Sc and "Googling" as his only listed skill. He was shipping code within days, not weeks. That's the real test of your developer experience. Not whether your senior architect can navigate it. Whether someone brand new can.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons for Developers Building Payment Systems
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Validate at every boundary.&lt;/strong&gt; Zod on the API layer. Zod between services. Payments don't forgive data inconsistencies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Idempotency is not optional.&lt;/strong&gt; Network retries happen. Bank callbacks come twice. Every payment mutation needs an idempotency key. We learned this the hard way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Treat webhooks as first-class citizens.&lt;/strong&gt; Merchants need real-time payment status. We built a dedicated Webhook Service with retry logic, dead-letter queues, and delivery receipts. It's not glamorous. It's essential.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Abstract your bank integrations.&lt;/strong&gt; The adapter pattern isn't clever engineering — it's survival. Banks change APIs. New banks join. Your payment logic should never care.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Invest in local dev early.&lt;/strong&gt; The time you save on onboarding compounds. Every developer you hire benefits. Every feature ships faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Open Banking Over Cards
&lt;/h2&gt;

&lt;p&gt;I'll be direct. If you're building a payment flow for the UK market in 2026, you should seriously consider open banking.&lt;/p&gt;

&lt;p&gt;Card payments: ~1.5-2.5% processing fees, T+2 settlement, chargebacks, PCI-DSS compliance overhead.&lt;/p&gt;

&lt;p&gt;Open banking via Atoa: lower fees, instant settlement, no chargebacks (because the customer authenticates with their bank), and simpler compliance.&lt;/p&gt;

&lt;p&gt;We're FCA-authorised. ISO-27001 and SOC2 certified. We have SDKs for Flutter (&lt;code&gt;atoa_sdk&lt;/code&gt;, &lt;code&gt;atoa_flutter_sdk&lt;/code&gt;), a Vue-based Web Client SDK, a WooCommerce plugin, and full API docs at &lt;a href="https://docs.atoa.me" rel="noopener noreferrer"&gt;docs.atoa.me&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you want to test it yourself: &lt;a href="https://docs.atoa.me/api-reference/Payment/process-payment" rel="noopener noreferrer"&gt;docs.atoa.me/api-reference/Payment/process-payment&lt;/a&gt;. Sandbox is free. Takes about 10 minutes to get your first payment flowing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;We're investing heavily in AI-assisted code migration and developer tooling. The 15-service architecture is growing. But the principles stay the same: clean boundaries, validate everything, and build for the developer who joins tomorrow, not just the one who built it yesterday.&lt;/p&gt;

&lt;p&gt;If you're building in payments — especially in the UK open banking space — I'd love to hear how you're approaching it. Drop a comment or find me on X: &lt;a href="https://x.com/mickyarun" rel="noopener noreferrer"&gt;@mickyarun&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nestjs</category>
      <category>microservices</category>
      <category>openbanking</category>
      <category>fintech</category>
    </item>
    <item>
      <title>We Had 15 Microservices and It Took 2 Weeks to Onboard a Developer. Here's How We Fixed It in a Weekend.</title>
      <dc:creator>arun rajkumar</dc:creator>
      <pubDate>Tue, 24 Mar 2026 07:29:26 +0000</pubDate>
      <link>https://dev.to/mickyarun/we-had-15-microservices-and-it-took-2-weeks-to-onboard-a-developer-heres-how-we-fixed-it-in-a-258a</link>
      <guid>https://dev.to/mickyarun/we-had-15-microservices-and-it-took-2-weeks-to-onboard-a-developer-heres-how-we-fixed-it-in-a-258a</guid>
      <description>&lt;p&gt;&lt;em&gt;How we went from "ask someone for the .env" to one-click local development for our entire microservice stack.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;If you're running microservices, you've probably been here:&lt;/p&gt;

&lt;p&gt;A new developer joins. You point them at the repos. Then begins the ritual. Clone this. Run migrations on that. Ask Slack for the latest &lt;code&gt;.env&lt;/code&gt;. Debug why nginx isn't routing. Realize they're on Node 20 but this service needs Node 23. Spend two hours figuring out why the queue consumer isn't connecting.&lt;/p&gt;

&lt;p&gt;Two weeks later, they write their first line of actual code.&lt;/p&gt;

&lt;p&gt;We had 15 NestJS microservices. Each with its own repo, its own &lt;code&gt;.env&lt;/code&gt;, its own database schema, migrations, queues, and inter-service dependencies. Every developer had their own frankensteined local setup — commented-out code, hardcoded URLs, an nginx config held together with hope.&lt;/p&gt;

&lt;p&gt;Integration testing? People just tested directly against the shared dev database. New joiners spent their first week or two just getting things running.&lt;/p&gt;

&lt;p&gt;I'm the CTO. I'm hands-on, but lately I only code two or three times a month. The last time I tried to pick up a feature, I had to pull the code, run migrations, ask someone for the latest env vars, debug why things weren't connecting, fix my local nginx config — and by the time I had a working setup, I'd lost half a day and gotten pulled into something else.&lt;/p&gt;

&lt;p&gt;That weekend, I decided to fix this. For everyone. Forever.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem Isn't Microservices. It's Environment Chaos.
&lt;/h2&gt;

&lt;p&gt;Here's what we found when we audited our 15 repos:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Env variable naming was a mess.&lt;/strong&gt; The same database connection string was called &lt;code&gt;DB_HOST&lt;/code&gt; in one repo, &lt;code&gt;DATABASE_HOST&lt;/code&gt; in another, and &lt;code&gt;POSTGRES_HOST&lt;/code&gt; in a third. Some were just plural changes — &lt;code&gt;QUEUE_URL&lt;/code&gt; vs &lt;code&gt;QUEUES_URL&lt;/code&gt;. One service used &lt;code&gt;DB_HOST_CREDENTIALS&lt;/code&gt; for a secondary database, another used &lt;code&gt;DB_HOST_CREDENTIAL&lt;/code&gt; (singular). Multiply this across 15 repos and you get a combinatorial nightmare.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. No single source of truth.&lt;/strong&gt; Each repo had its own &lt;code&gt;.env.example&lt;/code&gt; that was perpetually outdated. Developers copied &lt;code&gt;.env&lt;/code&gt; files from each other over Slack. Some had AWS credentials hardcoded. Others had localhost URLs that only worked on one person's machine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Node version drift.&lt;/strong&gt; Some services were on Node 20, others on Node 23. The &lt;code&gt;package.json&lt;/code&gt; didn't enforce this, so things would break silently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. AWS services in local dev.&lt;/strong&gt; Some services connected to real AWS SQS queues locally. Others mocked them. There was no standard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Nginx configuration hell.&lt;/strong&gt; Every developer maintained their own nginx config to route between services. One person's config looked nothing like another's. New joiners spent days getting this right.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: A Shared Env Schema with Zod (The Hard Part)
&lt;/h2&gt;

&lt;p&gt;The first thing we built was a centralized env schema package — a single source of truth for every environment variable across all services.&lt;/p&gt;

&lt;p&gt;Sounds simple. It wasn't.&lt;/p&gt;

&lt;p&gt;We had to map every &lt;code&gt;.env&lt;/code&gt; file across 15 repos, find the overlaps, resolve the naming conflicts, and split variables into shared building blocks and service-specific schemas.&lt;/p&gt;

&lt;p&gt;This is where AI agents saved us hours. I spawned multiple agents to do a retrospective across all repos — mapping every env variable, finding common ones, identifying naming conflicts, and generating a unified schema. What would have taken a team days of grep-and-spreadsheet work took a couple of hours.&lt;/p&gt;

&lt;p&gt;The result: a shared npm package using &lt;strong&gt;Zod&lt;/strong&gt; for runtime validation. Here's the actual pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// shared.schema.ts — Reusable building blocks&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DatabaseConfigSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;DB_HOST&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;localhost&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;DB_PORT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coerce&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5432&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;DB_USER&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;postgres&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;DB_PASSWORD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;DB_NAME&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RedisConfigSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;REDIS_HOST&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;localhost&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;REDIS_PORT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coerce&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6379&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;REDIS_PASSWORD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;QueueConfigSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;SQS_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:9324&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;SQS_REGION&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;us-east-1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;AWS_ACCESS_KEY_ID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;local&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;AWS_SECRET_ACCESS_KEY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;local&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;JWTConfigSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;JWT_ACCESS_TOKEN_EXPIRY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;15m&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;InterServiceAuthSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;INTER_SERVICE_SECRET&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Base schema every backend service inherits&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SharedBackendSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;NODE_ENV&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dev-local&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dev&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;uat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
  &lt;span class="na"&gt;PORT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coerce&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DatabaseConfigSchema&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;RedisConfigSchema&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JWTConfigSchema&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each service &lt;strong&gt;composes&lt;/strong&gt; its schema from these shared blocks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// services/payments.schema.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PaymentsEnvSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;SharedBackendSchema&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;QueueConfigSchema&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;InterServiceAuthSchema&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;PAYMENT_PROVIDER_API_KEY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;PAYMENT_ENCRYPTION_KEY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;WEBHOOK_SIGNING_SECRET&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key insight: &lt;strong&gt;composition via &lt;code&gt;.merge()&lt;/code&gt;&lt;/strong&gt;. When we renamed &lt;code&gt;DATABASE_HOST&lt;/code&gt; to &lt;code&gt;DB_HOST&lt;/code&gt;, we only changed it in one place. Every service that imports &lt;code&gt;DatabaseConfigSchema&lt;/code&gt; gets the fix automatically.&lt;/p&gt;

&lt;p&gt;We published this as an internal npm package. Each service declares it as a dependency and validates on startup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Any service's index.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;validateEnv&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@company/env-schema&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;validateEnv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;payments&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// Throws with clear error messages if anything is missing&lt;/span&gt;
&lt;span class="c1"&gt;// Returns a frozen, type-safe env object&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Environment-aware strictness&lt;/strong&gt; was crucial. In &lt;code&gt;dev-local&lt;/code&gt; mode, missing optional vars log warnings but don't block startup — so developers can run just the services they need. In &lt;code&gt;dev&lt;/code&gt;, &lt;code&gt;uat&lt;/code&gt;, and &lt;code&gt;production&lt;/code&gt;, missing required vars call &lt;code&gt;process.exit(1)&lt;/code&gt;. No silent failures in deployed environments.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Auto-Generate .env Files (The CLI)
&lt;/h2&gt;

&lt;p&gt;Having a schema is useless if developers still have to manually create &lt;code&gt;.env&lt;/code&gt; files. So we built a CLI that generates them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Generate .env for all 15 services&lt;/span&gt;
npx env-schema init &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--base-path&lt;/span&gt; ~/code

&lt;span class="c"&gt;# Generate for a single service&lt;/span&gt;
npx env-schema init &lt;span class="nt"&gt;--service&lt;/span&gt; payments

&lt;span class="c"&gt;# Preview without writing&lt;/span&gt;
npx env-schema init &lt;span class="nt"&gt;--service&lt;/span&gt; payments &lt;span class="nt"&gt;--stdout&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generator:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fills in &lt;strong&gt;safe local defaults&lt;/strong&gt; (localhost URLs, local Redis passwords, sandbox API keys)&lt;/li&gt;
&lt;li&gt;Reuses &lt;strong&gt;shared secrets&lt;/strong&gt; across services (same JWT secret, same inter-service auth token)&lt;/li&gt;
&lt;li&gt;Comments out &lt;strong&gt;optional fields&lt;/strong&gt; so developers know they exist&lt;/li&gt;
&lt;li&gt;Is &lt;strong&gt;idempotent&lt;/strong&gt; — safe to re-run, merges new keys without overwriting existing values&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No more Slack messages asking "can someone send me the .env for the notification service?"&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Prevent Future Drift (The Regex Scanner)
&lt;/h2&gt;

&lt;p&gt;Fixing the current mess was one thing. Preventing it from coming back was another.&lt;/p&gt;

&lt;p&gt;We built a &lt;strong&gt;drift checker&lt;/strong&gt; that scans source code for &lt;code&gt;process.env&lt;/code&gt; references and compares them against the schema registry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// check-drift.ts — simplified version&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;extractEnvVars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf-8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;matches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/process&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="sr"&gt;.env&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="sr"&gt;.&lt;/span&gt;&lt;span class="se"&gt;(\\&lt;/span&gt;&lt;span class="sr"&gt;w+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/process&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="sr"&gt;.env&lt;/span&gt;&lt;span class="se"&gt;\\[&lt;/span&gt;&lt;span class="sr"&gt;'(&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="sr"&gt;w+)'&lt;/span&gt;&lt;span class="se"&gt;\\]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;checkDrift&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;serviceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;schemaKeys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schemaRegistry&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;serviceId&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;codeKeys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;walkDir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;src/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;extractEnvVars&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unregistered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;codeKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;schemaKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;IGNORED_VARS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;unregistered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Env drift detected! Unregistered vars: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;unregistered&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This runs as:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pre-commit hook&lt;/strong&gt; — blocks commits with unregistered env vars&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI check&lt;/strong&gt; — PRs can't merge if drift is detected&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-startup check&lt;/strong&gt; — each service runs &lt;code&gt;npm run check-env&lt;/code&gt; before starting
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;package.json&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;any&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;service&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"check-env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx env-schema check payments"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"check-infra"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx env-schema infra"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"start:local"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm run check-env &amp;amp;&amp;amp; npm run check-infra &amp;amp;&amp;amp; cross-env NODE_ENV=dev-local tsnd src/index.ts"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We know how teams work. Lint rules get ignored, pre-commit hooks get bypassed with &lt;code&gt;--no-verify&lt;/code&gt;. That's why the same check runs in CI. The PR won't merge if there's env drift. No exceptions.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Kill Nginx with Traefik
&lt;/h2&gt;

&lt;p&gt;This was the game-changer.&lt;/p&gt;

&lt;p&gt;Every developer had a custom nginx config to route API calls between services locally. &lt;code&gt;/api/payments&lt;/code&gt; -&amp;gt; port 3001, &lt;code&gt;/api/users&lt;/code&gt; -&amp;gt; port 3002, and so on. When a new service was added, everyone had to update their nginx config manually. Nobody's config was the same.&lt;/p&gt;

&lt;p&gt;We replaced all of it with &lt;strong&gt;Traefik v3&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Traefik is a reverse proxy that auto-discovers services. We use a file-based dynamic provider that watches a config directory for changes — hot reload, no restart needed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;traefik&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik:v3.0&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9090:9090"&lt;/span&gt;    &lt;span class="c1"&gt;# API Gateway&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8080:8080"&lt;/span&gt;    &lt;span class="c1"&gt;# Dashboard&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./traefik/traefik.yml:/etc/traefik/traefik.yml&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./traefik/dynamic:/etc/traefik/dynamic&lt;/span&gt;  &lt;span class="c1"&gt;# Hot-reload configs&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;app-network&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No more per-developer nginx configs. One shared Traefik config in the repo. Add a new service? Add 5 lines to &lt;code&gt;services.yml&lt;/code&gt;. Traefik picks it up automatically via hot reload. Everyone gets the same routing.&lt;/p&gt;

&lt;p&gt;The dashboard at &lt;code&gt;localhost:8080&lt;/code&gt; gives you a visual map of every route, middleware, and service — something nginx never offered out of the box.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: One Command to Rule Them All
&lt;/h2&gt;

&lt;p&gt;With the env schema, Traefik, and local service mocking in place, we built the orchestration layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bootstrap for new developers&lt;/strong&gt; — a single script that handles everything from zero:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# New developer runs this on day one&lt;/span&gt;
./bootstrap.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This 10-step wizard:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Checks prerequisites (git, Docker, Node.js, VS Code)&lt;/li&gt;
&lt;li&gt;Collects git identity&lt;/li&gt;
&lt;li&gt;Configures workspace directory&lt;/li&gt;
&lt;li&gt;Clones all 15 repos in parallel (4 concurrent)&lt;/li&gt;
&lt;li&gt;Sets up git config in each repo&lt;/li&gt;
&lt;li&gt;Configures npm registry for private packages&lt;/li&gt;
&lt;li&gt;Runs &lt;code&gt;npm install&lt;/code&gt; in parallel (3 concurrent)&lt;/li&gt;
&lt;li&gt;Generates all &lt;code&gt;.env&lt;/code&gt; files from the shared schema&lt;/li&gt;
&lt;li&gt;Provisions infrastructure (Docker containers, databases, migrations)&lt;/li&gt;
&lt;li&gt;Installs the VS Code extension + generates workspace file&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;For existing developers&lt;/strong&gt; — the daily startup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--- Infrastructure Check ---

  [OK] PostgreSQL is responding on port 5432
  [OK] Redis/Valkey is responding on port 6379
  [OK] ElasticMQ (SQS) is responding on port 9324
  [OK] Traefik (API Gateway) is responding on port 9090

Select services to start (SPACE=toggle, A=all, N=none, ENTER=confirm):
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The infrastructure check does TCP port scanning with 2-second timeouts. If something's down, it offers to auto-start it via Docker. Then you select which services you need.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Smart terminal detection&lt;/strong&gt; — the startup script auto-detects your terminal and adapts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;tmux&lt;/strong&gt;: Grid layout with split panes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;iTerm2&lt;/strong&gt;: Native AppleScript-driven split panes (up to 8 per tab)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terminal.app&lt;/strong&gt;: Opens tabs per service&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fallback&lt;/strong&gt;: Color-coded concurrent output in a single terminal&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each service gets a color-coded label. Health monitoring polls every service in real-time — green when healthy, yellow when starting, red when unhealthy.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Team Took It Further
&lt;/h2&gt;

&lt;p&gt;I built the core over a weekend and handed it to my tech lead. "Check and deploy," I said.&lt;/p&gt;

&lt;p&gt;What they shipped blew me away. They didn't just deploy it — they built a &lt;strong&gt;VS Code extension&lt;/strong&gt; on top:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A welcome page&lt;/strong&gt; with a 5-step onboarding flow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run Preflight Checks -&amp;gt; Start Your First Service -&amp;gt; Manage Branches -&amp;gt; Explore Utilities -&amp;gt; Keyboard Shortcuts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;A Services Dashboard&lt;/strong&gt; (&lt;code&gt;Cmd+Alt+S&lt;/code&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Init All Envs, Start All (Dev), Start All (Build), Stop All&lt;/li&gt;
&lt;li&gt;Real-time status: &lt;code&gt;0/15 running | 0/15 healthy | 0 missing env&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Click a service to see logs, restart, or open its Swagger docs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;A Preflight Diagnostics panel&lt;/strong&gt; (&lt;code&gt;Cmd+Alt+P&lt;/code&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The dependency graph visualization — 237 checks passing across all services&lt;/li&gt;
&lt;li&gt;Shows which services depend on which, what infrastructure they need&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;A Branch Manager&lt;/strong&gt; (&lt;code&gt;Cmd+Alt+B&lt;/code&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;View and switch branches across all 15 repos from one UI&lt;/li&gt;
&lt;li&gt;No more cd-ing into each repo to check what branch you're on&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;A Web Portal&lt;/strong&gt; (Vue 3 + Vite):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Swagger UI aggregator for all service APIs&lt;/li&gt;
&lt;li&gt;ElasticMQ queue inspector&lt;/li&gt;
&lt;li&gt;Real-time service status monitoring&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now anyone — including our product managers — can run all 15 services with millions of lines of code in under 5 minutes. They can test features end-to-end on their local machine. They ask AI to check if a design is practical. They run the code and see for themselves.&lt;/p&gt;

&lt;p&gt;A new developer's first day? Clone, click, code. Not clone, cry, configure.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to Avoid This at Your Startup (Before It's Too Late)
&lt;/h2&gt;

&lt;p&gt;If you're at 3-5 microservices, here's what to do now before it becomes a 15-service nightmare:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Start with a shared env schema from day one.&lt;/strong&gt; Use Zod (or Joi, or JSON Schema). Even with 2 services, standardize your variable names. &lt;code&gt;DB_HOST&lt;/code&gt; everywhere, not &lt;code&gt;DATABASE_HOST&lt;/code&gt; in some and &lt;code&gt;POSTGRES_HOST&lt;/code&gt; in others. Compose shared blocks with &lt;code&gt;.merge()&lt;/code&gt; so naming changes propagate automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Pin your runtimes.&lt;/strong&gt; &lt;code&gt;.nvmrc&lt;/code&gt; + &lt;code&gt;engines&lt;/code&gt; in &lt;code&gt;package.json&lt;/code&gt;. Enforce in CI. It takes 5 minutes and saves weeks of debugging.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Mock external services locally.&lt;/strong&gt; Use ElasticMQ instead of real SQS, MinIO instead of real S3. Your env schema should auto-switch endpoints based on &lt;code&gt;NODE_ENV=dev-local&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Use Traefik instead of nginx from the start.&lt;/strong&gt; File-based dynamic provider + hot reload beats editing nginx.conf every time a service changes. Your future self will thank you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Add env drift detection to CI.&lt;/strong&gt; A regex scanner that checks &lt;code&gt;process.env&lt;/code&gt; references against your schema catches problems before they spread. Run it in pre-commit hooks AND CI — belt and suspenders.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Invest in the "first 5 minutes" experience.&lt;/strong&gt; If a new developer can't run your entire stack in 5 minutes, you have a problem. It will only get worse. Build a bootstrap script. Make it idempotent. Make it parallel.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Before and After
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Onboarding&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1-2 weeks&lt;/td&gt;
&lt;td&gt;5 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Env setup&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ask on Slack, copy-paste&lt;/td&gt;
&lt;td&gt;Auto-generated from schema&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Env validation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Crash at runtime&lt;/td&gt;
&lt;td&gt;Fail fast on startup with clear errors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Routing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Manual nginx per developer&lt;/td&gt;
&lt;td&gt;Traefik with hot-reload config&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Integration testing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Against shared dev DB&lt;/td&gt;
&lt;td&gt;Full local stack, end-to-end&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Starting services&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Manual, per-service, per-developer&lt;/td&gt;
&lt;td&gt;One command, interactive selection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Node version&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Whatever was installed&lt;/td&gt;
&lt;td&gt;Pinned in &lt;code&gt;.nvmrc&lt;/code&gt;, enforced in CI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;New service added&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Update everyone's nginx, share new .env&lt;/td&gt;
&lt;td&gt;Add 5 lines to Traefik config, schema auto-generates .env&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Drift prevention&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;None (hope-based)&lt;/td&gt;
&lt;td&gt;Pre-commit + CI drift checks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Who can run the stack&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Senior devs only&lt;/td&gt;
&lt;td&gt;Anyone, including PMs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;The tools matter less than the principle: &lt;strong&gt;your local development environment is a product.&lt;/strong&gt; Treat it like one. Your developers are the users. If the onboarding experience is painful, every day after that is a little painful too.&lt;/p&gt;

&lt;p&gt;We used NestJS, TypeScript, Zod, Traefik, ElasticMQ, Docker, and VS Code. You might use different tools. The pattern is the same: centralize config, validate on startup, auto-generate defaults, prevent drift, make it one click.&lt;/p&gt;

&lt;p&gt;Build it once. Fix it for everyone. Forever.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Arun, CTO at a fintech startup. We're a team of 15 engineers in India building payment infrastructure for the UK. I write about the messy reality of scaling engineering teams and systems. Find me on &lt;a href="https://x.com/mickyarun" rel="noopener noreferrer"&gt;X @mickyarun&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>microservices</category>
      <category>devops</category>
      <category>typescript</category>
      <category>startup</category>
    </item>
  </channel>
</rss>
