<?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: Ayush Soni</title>
    <description>The latest articles on DEV Community by Ayush Soni (@ayushwing).</description>
    <link>https://dev.to/ayushwing</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4210786%2F105aad99-2265-499a-8c6f-fa58f937f3cb.png</url>
      <title>DEV Community: Ayush Soni</title>
      <link>https://dev.to/ayushwing</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ayushwing"/>
    <language>en</language>
    <item>
      <title>Choose a database by access pattern, not by hype</title>
      <dc:creator>Ayush Soni</dc:creator>
      <pubDate>Mon, 02 Nov 2026 09:25:05 +0000</pubDate>
      <link>https://dev.to/ayushwing/choose-a-database-by-access-pattern-not-by-hype-1o9l</link>
      <guid>https://dev.to/ayushwing/choose-a-database-by-access-pattern-not-by-hype-1o9l</guid>
      <description>&lt;p&gt;Across ten years of backend work I've shipped production systems on MySQL, PostgreSQL, DynamoDB, MongoDB, Elasticsearch, Neo4j, Redis, Redshift and BigQuery. The rule I keep coming back to: write down the &lt;strong&gt;queries&lt;/strong&gt; before you pick the database.&lt;/p&gt;

&lt;p&gt;"We need something that scales" isn't a requirement. "Fetch an order by ID in under 10 ms at 5,000 requests per second", "find products similar to this one by shared attributes", "sum revenue by region for the last two years" — those are requirements, and each one points at a different kind of store.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with an access-pattern list
&lt;/h2&gt;

&lt;p&gt;Before choosing, list for each entity:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Example answer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;How is it looked up?&lt;/td&gt;
&lt;td&gt;By &lt;code&gt;orderId&lt;/code&gt;; by &lt;code&gt;customerId&lt;/code&gt; + date range&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What filters and sorts?&lt;/td&gt;
&lt;td&gt;Status, created date, newest first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read/write ratio and peak rate&lt;/td&gt;
&lt;td&gt;20:1, 3,000 reads/s at peak&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consistency needs&lt;/td&gt;
&lt;td&gt;Read-your-own-writes for the owner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Relationships&lt;/td&gt;
&lt;td&gt;Order → items → products&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Analytics&lt;/td&gt;
&lt;td&gt;Daily revenue by category, two years of history&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ten minutes on this table saves months of fighting the wrong store.&lt;/p&gt;

&lt;h2&gt;
  
  
  The main families, by the query they're good at
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Relational (PostgreSQL, MySQL) — the default.&lt;/strong&gt; Joins, transactions, constraints, flexible queries you didn't predict. Most systems should start here and leave only when a specific access pattern demands it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PAID'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'7 days'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key-value / wide-column (DynamoDB, Cassandra) — known patterns at huge scale.&lt;/strong&gt; Predictable single-digit-millisecond lookups by key, practically unlimited throughput, and you must design keys around your queries up front. Ad-hoc queries are expensive or impossible.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GetItem   PK = "TENANT#acme"   SK = "PROFILE"
Query     PK = "TENANT#acme"   SK begins_with "EVENT#2026-10"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Document (MongoDB) — aggregates read and written whole.&lt;/strong&gt; Good when an entity is naturally a nested document (a product with variable attributes, a form submission) and you mostly load it by ID.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Search (Elasticsearch, OpenSearch) — text, filters, ranking.&lt;/strong&gt; Full-text relevance, faceted filters, fuzzy matching, aggregations for filter counts. Treat it as a &lt;strong&gt;derived index&lt;/strong&gt;, fed from your source of truth, not as the source of truth itself.&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;"query"&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;"bool"&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;"must"&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;"match"&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;"oak dining table"&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;"filter"&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;"term"&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;"in_stock"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;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;"range"&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;"price"&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;"lte"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;40000&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="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;&lt;strong&gt;Graph (Neo4j) — relationships several hops deep.&lt;/strong&gt; "Which products share categories and attributes with this one?", "what depends on this feature?". In SQL these become recursive joins; in a graph database they're a short pattern. I've used it for product recommendations and for ML metadata lineage.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;p:&lt;/span&gt;&lt;span class="n"&gt;Product&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;id:&lt;/span&gt; &lt;span class="n"&gt;$id&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:HAS_ATTRIBUTE&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;IN_CATEGORY&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:HAS_ATTRIBUTE&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;IN_CATEGORY&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;other:&lt;/span&gt;&lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;other&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;other.id&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;shared&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;shared&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="ss"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Cache (Redis) — hot reads, counters, short-lived state.&lt;/strong&gt; Sessions, rate limits, leaderboards, cached responses. Plan for it being empty after a restart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Warehouse (BigQuery, Redshift, Snowflake) — scans over history.&lt;/strong&gt; Columnar storage that makes "sum revenue by month and category over two years" cheap. Not for serving user requests.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;DATE_TRUNC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;MONTH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;analytics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;order_date&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;DATE_SUB&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;CURRENT_DATE&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="nb"&gt;YEAR&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt;  &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  When you need two (or three)
&lt;/h2&gt;

&lt;p&gt;Real systems often combine stores: Postgres for orders, Elasticsearch for product search, a warehouse for reporting. That's fine — if you decide three things explicitly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Which store is the source of truth&lt;/strong&gt; for each piece of data. Everything else is derived and can be rebuilt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How derived stores are fed&lt;/strong&gt;: events, change data capture, or batch loads — and how stale they may be. Search results a few seconds behind are usually fine; a balance a few seconds behind usually isn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How you rebuild&lt;/strong&gt; a derived store from scratch. If you can't re-index search from the source of truth, it has quietly become a second source of truth.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The anti-pattern is the one in the picture at the end of this post: each new feature adds a store because it's fashionable, with no answer to those three questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick decision guide
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;If your dominant query is…&lt;/th&gt;
&lt;th&gt;Start with&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Transactions, joins, flexible filters&lt;/td&gt;
&lt;td&gt;PostgreSQL / MySQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lookups by key at very high, predictable scale&lt;/td&gt;
&lt;td&gt;DynamoDB / Cassandra&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whole nested documents by ID&lt;/td&gt;
&lt;td&gt;MongoDB (or JSONB in Postgres)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full-text search, facets, ranking&lt;/td&gt;
&lt;td&gt;Elasticsearch / OpenSearch, fed from the source of truth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-hop relationships&lt;/td&gt;
&lt;td&gt;Neo4j&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aggregations over large history&lt;/td&gt;
&lt;td&gt;BigQuery / Redshift / Snowflake&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hot reads, counters, ephemeral state&lt;/td&gt;
&lt;td&gt;Redis&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Write the &lt;strong&gt;access-pattern list&lt;/strong&gt; before choosing a database.&lt;/li&gt;
&lt;li&gt;Start &lt;strong&gt;relational&lt;/strong&gt;; move a workload out when a specific query demands it.&lt;/li&gt;
&lt;li&gt;Search indexes and caches are &lt;strong&gt;derived&lt;/strong&gt; — feed them from the source of truth and be able to rebuild them.&lt;/li&gt;
&lt;li&gt;When you use several stores, name the &lt;strong&gt;source of truth&lt;/strong&gt; for every piece of data.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://ayushwing.com/blog/choose-a-database-by-access-pattern" rel="noopener noreferrer"&gt;ayushwing.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>architecture</category>
      <category>systemdesign</category>
      <category>backend</category>
    </item>
    <item>
      <title>Durable workflows vs. a queue and a cron job</title>
      <dc:creator>Ayush Soni</dc:creator>
      <pubDate>Sun, 01 Nov 2026 07:25:02 +0000</pubDate>
      <link>https://dev.to/ayushwing/durable-workflows-vs-a-queue-and-a-cron-job-1p4h</link>
      <guid>https://dev.to/ayushwing/durable-workflows-vs-a-queue-and-a-cron-job-1p4h</guid>
      <description>&lt;p&gt;Some work doesn't fit in a request. Moving a customer's data to new infrastructure. Onboarding a merchant across five systems. Generating, approving and sending a large batch of documents. These jobs have many steps, take minutes to days, call systems that fail, and must survive deploys and crashes.&lt;/p&gt;

&lt;p&gt;Most teams start the same way: a queue, a cron job and a status column.&lt;/p&gt;

&lt;h2&gt;
  
  
  What queue + cron becomes
&lt;/h2&gt;

&lt;p&gt;Version one is clean: a job row with &lt;code&gt;status = PENDING&lt;/code&gt;, a worker that picks it up, does the work, sets &lt;code&gt;DONE&lt;/code&gt;. Then reality adds requirements, one at a time:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A step fails transiently → add a &lt;code&gt;retries&lt;/code&gt; column and a &lt;code&gt;RETRYING&lt;/code&gt; status.&lt;/li&gt;
&lt;li&gt;A worker dies mid-job → add &lt;code&gt;locked_by&lt;/code&gt; and &lt;code&gt;locked_at&lt;/code&gt;, and a cron that releases stale locks.&lt;/li&gt;
&lt;li&gt;Step 4 must not run twice → add per-step status columns.&lt;/li&gt;
&lt;li&gt;A step needs to wait for a human approval or a 24-hour cool-down → add &lt;code&gt;resume_after&lt;/code&gt; and another cron.&lt;/li&gt;
&lt;li&gt;Step 6 fails permanently after steps 1–5 succeeded → write undo logic, scattered across the codebase.&lt;/li&gt;
&lt;li&gt;Nobody can tell where a job is → build an admin page that reads all these columns.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each addition is reasonable. Together they're a workflow engine — one with no tests for its failure paths, state spread across columns, and behaviour that only one engineer fully understands. (The picture at the end of this post is only slightly exaggerated.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What a durable workflow engine gives you
&lt;/h2&gt;

&lt;p&gt;A durable workflow engine — &lt;a href="https://temporal.io" rel="noopener noreferrer"&gt;Temporal&lt;/a&gt; is the one I've used; Cadence, AWS Step Functions and others share the idea — lets you write the process as ordinary code while the engine persists every step's progress:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@WorkflowInterface&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;DataMoveWorkflow&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@WorkflowMethod&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;move&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;accountId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DataMoveWorkflowImpl&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;DataMoveWorkflow&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;MoveActivities&lt;/span&gt; &lt;span class="n"&gt;act&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Workflow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newActivityStub&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MoveActivities&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="nc"&gt;ActivityOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newBuilder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setStartToCloseTimeout&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofMinutes&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="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setRetryOptions&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RetryOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newBuilder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setInitialInterval&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofSeconds&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
                            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setBackoffCoefficient&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setMaximumAttempts&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;move&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;accountId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Saga&lt;/span&gt; &lt;span class="n"&gt;saga&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Saga&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Saga&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Options&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Builder&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;act&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;preflightChecks&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accountId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

            &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;act&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;provisionTarget&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accountId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;saga&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addCompensation&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;act:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;releaseTarget&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

            &lt;span class="n"&gt;act&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;copySnapshot&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accountId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;act&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;syncChanges&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accountId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;       &lt;span class="c1"&gt;// catch up writes made during the copy&lt;/span&gt;
            &lt;span class="n"&gt;act&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;verifyChecksums&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accountId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

            &lt;span class="n"&gt;act&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;switchTraffic&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accountId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;saga&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addCompensation&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;act:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;switchTrafficBack&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;accountId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

            &lt;span class="nc"&gt;Workflow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sleep&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofHours&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;     &lt;span class="c1"&gt;// soak period; costs nothing while waiting&lt;/span&gt;
            &lt;span class="n"&gt;act&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;decommissionSource&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accountId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ActivityFailure&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;saga&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;compensate&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;                        &lt;span class="c1"&gt;// undo completed steps in reverse order&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What the engine handles for you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Durability.&lt;/strong&gt; Each completed activity is recorded. If the worker crashes after &lt;code&gt;copySnapshot&lt;/code&gt;, a new worker replays the history and continues at &lt;code&gt;syncChanges&lt;/code&gt; — it doesn't copy again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retries and timeouts per step&lt;/strong&gt;, as configuration rather than columns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long waits.&lt;/strong&gt; &lt;code&gt;Workflow.sleep(Duration.ofHours(24))&lt;/code&gt; holds no thread and no lock; the engine wakes the workflow when the timer fires. The same goes for waiting on a &lt;strong&gt;signal&lt;/strong&gt;, such as a human approval.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compensation.&lt;/strong&gt; The &lt;code&gt;Saga&lt;/code&gt; helper records how to undo each step and runs the compensations in reverse if a later step fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Visibility.&lt;/strong&gt; Every workflow's current step, history and inputs are queryable — the admin page you'd otherwise build.&lt;/li&gt;
&lt;/ul&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffg5po187s8y2n2r78ffn.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffg5po187s8y2n2r78ffn.png" alt="Workflow steps: preflight, provision, copy, sync, verify, switch traffic, soak, decommission. Failure after provisioning or switching triggers compensation in reverse order." width="800" height="411"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Steps run forward; compensations run backward. &lt;a href="https://ayushwing.com/diagrams/durable-workflow.html" rel="noopener noreferrer"&gt;Explore the interactive diagram&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The price: determinism
&lt;/h2&gt;

&lt;p&gt;Workflow code is &lt;strong&gt;replayed&lt;/strong&gt; to rebuild state after a crash, so it must make the same decisions every time it runs with the same history. In practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No &lt;code&gt;Thread.sleep&lt;/code&gt;, &lt;code&gt;System.currentTimeMillis()&lt;/code&gt;, &lt;code&gt;UUID.randomUUID()&lt;/code&gt; or &lt;code&gt;Random&lt;/code&gt; in workflow code — use &lt;code&gt;Workflow.sleep&lt;/code&gt;, &lt;code&gt;Workflow.currentTimeMillis()&lt;/code&gt;, &lt;code&gt;Workflow.randomUUID()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;No I/O in workflow code. Database calls, HTTP calls and file access go in &lt;strong&gt;activities&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Changing a running workflow's logic needs &lt;strong&gt;versioning&lt;/strong&gt; (&lt;code&gt;Workflow.getVersion&lt;/code&gt;), because in-flight workflows replay against the old history.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Activities, on the other hand, are normal code — and because they're retried, they must be &lt;strong&gt;idempotent&lt;/strong&gt; (see &lt;a href="https://ayushwing.com/blog/idempotency-keys" rel="noopener noreferrer"&gt;Idempotency keys&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;These rules take a week to internalise. They also force a clean separation between "what happens in what order" (workflow) and "how each step talks to the world" (activities), which tends to make the code easier to read.&lt;/p&gt;

&lt;h2&gt;
  
  
  When it's worth it — and when it isn't
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Queue + worker&lt;/th&gt;
&lt;th&gt;Durable workflow&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One step, idempotent, seconds long&lt;/td&gt;
&lt;td&gt;✅ Simple and enough&lt;/td&gt;
&lt;td&gt;Overkill&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2–3 steps, no waits, rare failures&lt;/td&gt;
&lt;td&gt;✅ Usually fine&lt;/td&gt;
&lt;td&gt;Optional&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Many steps, external systems, partial failure needs undo&lt;/td&gt;
&lt;td&gt;Grows into a hand-built engine&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Waits of hours or days, human approvals&lt;/td&gt;
&lt;td&gt;Crons and &lt;code&gt;resume_after&lt;/code&gt; columns&lt;/td&gt;
&lt;td&gt;✅ Timers and signals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You must answer "where exactly is job X?"&lt;/td&gt;
&lt;td&gt;Build an admin page&lt;/td&gt;
&lt;td&gt;✅ Built in&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The real cost is operational: you run (or pay for) the engine's cluster and learn its model. For one small job, that cost dominates. For a platform that runs long, multi-step processes every day — migrations, onboarding, provisioning — it pays for itself quickly, because every new process gets retries, timers, visibility and compensation for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A queue + cron + status table grows into a workflow engine; decide early whether to build one or adopt one.&lt;/li&gt;
&lt;li&gt;Durable workflows give &lt;strong&gt;resumability, per-step retries, cheap long waits, compensation and visibility&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The price is &lt;strong&gt;determinism&lt;/strong&gt; in workflow code and &lt;strong&gt;idempotency&lt;/strong&gt; in activities.&lt;/li&gt;
&lt;li&gt;Use it for &lt;strong&gt;long, multi-step, failure-prone&lt;/strong&gt; processes; keep simple jobs simple.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://ayushwing.com/blog/durable-workflows-vs-queue-and-cron" rel="noopener noreferrer"&gt;ayushwing.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>temporal</category>
      <category>distributedsystems</category>
      <category>java</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Idempotency keys: making retries safe for money</title>
      <dc:creator>Ayush Soni</dc:creator>
      <pubDate>Sat, 31 Oct 2026 16:31:17 +0000</pubDate>
      <link>https://dev.to/ayushwing/idempotency-keys-making-retries-safe-for-money-383c</link>
      <guid>https://dev.to/ayushwing/idempotency-keys-making-retries-safe-for-money-383c</guid>
      <description>&lt;p&gt;A request that times out may still have succeeded. That single fact is why wallet and refund systems — I've built both — have to be designed around retries from day one.&lt;/p&gt;

&lt;p&gt;Here is the sequence. A client calls &lt;code&gt;POST /refunds&lt;/code&gt;. The server debits the merchant, credits the customer, commits — and then the response is lost: a load balancer timeout, a pod restart, a mobile network drop. The client sees an error and does the reasonable thing: it retries. The server, seeing what looks like a new request, refunds again.&lt;/p&gt;

&lt;p&gt;Retries are not the bug. Retries are essential; networks fail all the time. The bug is an API that can't tell a retry from a new request.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;An &lt;strong&gt;idempotency key&lt;/strong&gt; is a unique value the client generates once per &lt;em&gt;operation&lt;/em&gt; — not per HTTP attempt — and sends with every attempt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /refunds
Idempotency-Key: 6f1c2a9e-7d3b-4f0a-9a51-2c8e0b7d4e11
Content-Type: application/json

{"orderId": "A-1042", "amount": 1499}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server promises: for a given key, the operation happens &lt;strong&gt;at most once&lt;/strong&gt;, and every attempt gets &lt;strong&gt;the same response&lt;/strong&gt;. Stripe popularised this design for its public API, and it applies to any endpoint that moves money, sends a message or creates something a user would notice twice.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0sf08h3t6ufcf1fwayoy.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0sf08h3t6ufcf1fwayoy.png" alt="First attempt: reserve the key, do the work, store the response. Retry with the same key: find the stored response and return it without doing the work again." width="800" height="503"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Reserve first, work second, replay on retry. &lt;a href="https://ayushwing.com/diagrams/idempotency-keys.html" rel="noopener noreferrer"&gt;Explore the interactive diagram&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The table
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;idempotency_keys&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;key&lt;/span&gt;            &lt;span class="nb"&gt;TEXT&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;client_id&lt;/span&gt;      &lt;span class="nb"&gt;TEXT&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;request_hash&lt;/span&gt;   &lt;span class="nb"&gt;TEXT&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;-- SHA-256 of method + path + body&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;         &lt;span class="nb"&gt;TEXT&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;-- IN_PROGRESS | COMPLETED&lt;/span&gt;
    &lt;span class="n"&gt;response_code&lt;/span&gt;  &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;response_body&lt;/span&gt;  &lt;span class="n"&gt;JSONB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;     &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;key&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;Scope keys per client: two different clients may well generate the same string, and that must not leak one client's response to the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  The flow, with the edge cases
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@PostMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/refunds"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;RefundResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;refund&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@RequestHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Idempotency-Key"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                                             &lt;span class="nd"&gt;@RequestBody&lt;/span&gt; &lt;span class="nc"&gt;RefundRequest&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                                             &lt;span class="nc"&gt;Principal&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// 1. Reserve the key BEFORE doing any work. The primary key makes this atomic.&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;inserted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jdbc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""
        INSERT INTO idempotency_keys (key, client_id, request_hash, status)
        VALUES (?, ?, ?, 'IN_PROGRESS')
        ON CONFLICT (client_id, key) DO NOTHING
        """&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;hash&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inserted&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;StoredKey&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;find&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestHash&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hash&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;unprocessableEntity&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// same key, different body&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;IN_PROGRESS&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;409&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;              &lt;span class="c1"&gt;// concurrent retry: try again later&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toResponse&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;                               &lt;span class="c1"&gt;// replay the stored result&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// 2. Do the work and record the result in ONE transaction.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;execute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;RefundResponse&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;refunds&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;issue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;complete&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;});&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each branch exists because of a real failure mode:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reserve before you work.&lt;/strong&gt; If you check for the key, do the refund, and only then insert the key, two retries arriving 5 ms apart both pass the check. The &lt;code&gt;INSERT ... ON CONFLICT DO NOTHING&lt;/code&gt; against the primary key is the lock: exactly one attempt wins the reservation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Same key, different body.&lt;/strong&gt; A client bug that reuses a key for a different refund must not get the first refund's response — that would silently hide the second operation. Hash the request and reject mismatches with a &lt;code&gt;422&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In progress.&lt;/strong&gt; A retry that arrives while the first attempt is still running gets a &lt;code&gt;409&lt;/code&gt;. The client backs off and retries; by then the first attempt has finished and the retry gets the stored response.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Work and result commit together.&lt;/strong&gt; If the refund commits but storing the response doesn't, the next retry finds an &lt;code&gt;IN_PROGRESS&lt;/code&gt; row forever. Keep the business write and the key update in the same database transaction.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the work isn't in your database
&lt;/h2&gt;

&lt;p&gt;The single-transaction trick only works when the side effect lives in the same database as the key table. When step 2 calls an external payment provider, you can't roll that back. Two things help:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pass the key downstream.&lt;/strong&gt; Most payment providers accept their own idempotency key. Derive it from yours (for example &lt;code&gt;refund:{clientId}:{key}&lt;/code&gt;), so a retry after a crash asks the provider for the same operation instead of a new one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recover stuck rows.&lt;/strong&gt; A process can die between the provider call and the commit. A sweeper that finds rows stuck &lt;code&gt;IN_PROGRESS&lt;/code&gt; for longer than your longest timeout can query the provider by the derived key and complete or release the row.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How long to keep keys
&lt;/h2&gt;

&lt;p&gt;Keep keys at least as long as any client will retry, plus a margin. Stripe keeps them for 24 hours; for internal services where queues can redeliver messages days later, I'd lean longer. Delete old rows with a scheduled job — not inside the request path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where else this applies
&lt;/h2&gt;

&lt;p&gt;The same idea shows up whenever "at least once" delivery meets "exactly once" side effects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Queue consumers.&lt;/strong&gt; Kafka and SQS redeliver. Use the message ID (or a business ID) as the key and record it in the same transaction as the effect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webhooks.&lt;/strong&gt; Providers retry webhooks; store the event ID before acting on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduled jobs.&lt;/strong&gt; A job that runs twice after a deploy should find its own key and stop.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Retries are necessary; duplicate side effects are optional.&lt;/li&gt;
&lt;li&gt;The key belongs to the &lt;strong&gt;operation&lt;/strong&gt;, generated by the client once and reused on every attempt.&lt;/li&gt;
&lt;li&gt;Reserve the key atomically &lt;strong&gt;before&lt;/strong&gt; the work, store the response &lt;strong&gt;with&lt;/strong&gt; the work, and replay it on retry.&lt;/li&gt;
&lt;li&gt;Reject a reused key with a different body, and pass keys to every downstream system that moves money.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://ayushwing.com/blog/idempotency-keys" rel="noopener noreferrer"&gt;ayushwing.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>distributedsystems</category>
      <category>payments</category>
      <category>java</category>
      <category>api</category>
    </item>
    <item>
      <title>Same AI, two developers: why the experienced one spends fewer tokens</title>
      <dc:creator>Ayush Soni</dc:creator>
      <pubDate>Fri, 30 Oct 2026 03:36:05 +0000</pubDate>
      <link>https://dev.to/ayushwing/same-ai-two-developers-why-the-experienced-one-spends-fewer-tokens-gd8</link>
      <guid>https://dev.to/ayushwing/same-ai-two-developers-why-the-experienced-one-spends-fewer-tokens-gd8</guid>
      <description>&lt;p&gt;Everyone is building apps with AI now. My cousin built one. Your manager built one. Somebody built one during a meeting about whether we should build one.&lt;/p&gt;

&lt;p&gt;That's genuinely great. But it raises a fair question: if a first-time builder and an engineer with ten years of experience use &lt;strong&gt;the same model&lt;/strong&gt; with &lt;strong&gt;the same prompt&lt;/strong&gt;, what's actually different?&lt;/p&gt;

&lt;p&gt;Let's run the experiment in our heads. Two developers, same AI, same request:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Build me a task-tracker app with user login."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Meet &lt;strong&gt;Nia&lt;/strong&gt;, the native AI builder: smart, fast, has never shipped software before. And &lt;strong&gt;Sam&lt;/strong&gt;, the seasoned engineer, who has broken production enough times to be careful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Day 1: no difference at all
&lt;/h2&gt;

&lt;p&gt;Both get a working demo in an afternoon. Login page, task list, a nice dark theme, a little confetti when you finish a task. Both post a screenshot. Both get 40 likes.&lt;/p&gt;

&lt;p&gt;If you only look at day 1, experience doesn't matter. This is the part of the story everyone's excited about, and they're right to be. The AI did in an afternoon what used to take a week.&lt;/p&gt;

&lt;p&gt;The difference shows up later, the way it always does in software: &lt;strong&gt;in the second month, when you change things.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the tokens go
&lt;/h2&gt;

&lt;p&gt;Here's Nia's day 3. A user reports that tasks sometimes disappear.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Nia:  tasks disappear sometimes, fix it
AI:   (rewrites the storage layer)
Nia:  now login is broken
AI:   (rewrites the auth flow)
Nia:  login works but tasks still disappear
AI:   (adds a retry, a cache, and a second database "for reliability")
Nia:  it's slower now and still broken. FIX IT PROPERLY
AI:   (apologises, rewrites the storage layer again)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every one of those messages drags the whole conversation and a growing chunk of the codebase into context again. Every fix touches more files than the bug lives in. After a week the app works most of the time, nobody knows why, and the token bill looks like a phone number.&lt;/p&gt;

&lt;p&gt;Sam's day 3, same bug:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Sam:  Read CLAUDE.md. Tasks occasionally disappear after editing.
      Don't change code yet: find where tasks are written and list
      the ways two writes could race.
AI:   Two tabs saving the same task: last write wins, no version check.
Sam:  Write a failing test that reproduces it with two concurrent saves.
AI:   (test fails, as expected)
Sam:  Fix it with a version column and a conditional update. Keep the diff small.
AI:   (12-line change, test passes)
Sam:  (reads the diff, commits)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four messages. One small diff. A test that will catch the bug if it ever comes back. Sam spent &lt;strong&gt;fewer&lt;/strong&gt; tokens than Nia, not more. Experience didn't make Sam write better prompts in some magic way. It made Sam ask for &lt;strong&gt;smaller, checkable steps&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;(If that bug looks familiar, it's the same race I wrote about in &lt;a href="https://ayushwing.com/blog/business-workflows-as-state-machines" rel="noopener noreferrer"&gt;Model business workflows as state machines&lt;/a&gt;. AI doesn't remove these problems. It just reaches them faster.)&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F69aqi11w9fqsu0fpb8px.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F69aqi11w9fqsu0fpb8px.png" alt="Two loops. Prompt and pray: ask for a whole feature, run it, see something broken, say fix it, repeat. Plan, test, ship: plan, write a failing test, make one small change, read the diff, commit, then take the next step." width="799" height="380"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Same model, two loops. Only one of them ends. &lt;a href="https://ayushwing.com/diagrams/two-ai-loops.html" rel="noopener noreferrer"&gt;Explore the interactive diagram&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The side-by-side
&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;Nia (first-time builder)&lt;/th&gt;
&lt;th&gt;Sam (experienced engineer)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;First prompt&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;"Build the whole app"&lt;/td&gt;
&lt;td&gt;"Plan the app; don't write code yet"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Size of each request&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A feature, or "fix everything"&lt;/td&gt;
&lt;td&gt;One change you can review in two minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Context&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Re-explains the project every session&lt;/td&gt;
&lt;td&gt;A project instructions file the AI reads every time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Definition of done&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;"It looks like it works"&lt;/td&gt;
&lt;td&gt;A test passes, and the diff makes sense&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;When it breaks&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;"Fix it" (again)&lt;/td&gt;
&lt;td&gt;"Explain why before changing anything"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tools&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Pastes logs and docs into the chat&lt;/td&gt;
&lt;td&gt;Gives the AI tools: skills, plugins, MCP servers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dependencies&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Whatever the AI suggested&lt;/td&gt;
&lt;td&gt;Asks why each one is needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Secrets&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;API key in the code, "temporarily"&lt;/td&gt;
&lt;td&gt;Environment variables from the start&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Git&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One giant commit called "final"&lt;/td&gt;
&lt;td&gt;A commit after every green step&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Day 30&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Afraid to touch it&lt;/td&gt;
&lt;td&gt;Adds features on a Friday&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of Sam's column is about AI. It's just engineering: small steps, tests, version control, knowing what "done" means. &lt;strong&gt;AI is a multiplier, and it multiplies whatever habits you bring.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What makes AI-written code sustainable
&lt;/h2&gt;

&lt;p&gt;"Sustainable" is a fancy word for one question: &lt;strong&gt;can someone change this code next month without breaking it?&lt;/strong&gt; "Someone" includes you, and it includes the AI.&lt;/p&gt;

&lt;p&gt;Four things decide the answer:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Tests that describe behaviour.&lt;/strong&gt; A test suite is the only spec the AI can't misread. With tests, "refactor this" is safe. Without them, every change is a gamble the AI makes with confidence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small, readable diffs.&lt;/strong&gt; If you can't explain a change in a sentence, you can't review it, and unreviewed code is how plausible-but-wrong bugs ship. My favourite: a refund endpoint that retries on timeout and pays twice. It looks perfectly correct in a diff (&lt;a href="https://ayushwing.com/blog/idempotency-keys" rel="noopener noreferrer"&gt;here's how to prevent it&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Written-down decisions.&lt;/strong&gt; A project instructions file (&lt;code&gt;CLAUDE.md&lt;/code&gt;, &lt;code&gt;AGENTS.md&lt;/code&gt;, &lt;code&gt;.cursorrules&lt;/code&gt;, whatever your tool reads) that says: how to run the tests, what the architecture is, what not to touch. The AI stops rediscovering your codebase every session, and that's most of the token savings right there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Boring choices.&lt;/strong&gt; One database, not five. A library everyone knows, not the one the AI half-remembers. AI is very good at writing glue for popular tools and very bad at debugging an obscure one.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Skills, plugins and tools: stop pasting, start equipping
&lt;/h2&gt;

&lt;p&gt;The biggest token sink I see is &lt;strong&gt;pasting&lt;/strong&gt;: the same error log, the same docs page, the same "here's how our project works" paragraph, over and over.&lt;/p&gt;

&lt;p&gt;Experienced developers give the AI &lt;strong&gt;tools&lt;/strong&gt; instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Skills and plugins&lt;/strong&gt; package a repeatable way of doing something, such as a review checklist, a deploy process or an audit, so you don't explain it every time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCP servers&lt;/strong&gt; let the AI read your tickets, query a staging database or check a deploy itself, instead of you copying the output in. (I built &lt;a href="https://ayushwing.com/blog/java-mcp-server" rel="noopener noreferrer"&gt;one for Java services&lt;/a&gt; for exactly this reason.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your own scripts:&lt;/strong&gt; a test command, a screenshot script, a linter. The AI can run them and check its own work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A real example: this site. While building it with an AI assistant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/tt-a1i/archify" rel="noopener noreferrer"&gt;Archify&lt;/a&gt; drew the diagrams, with its own validation checks, instead of the AI hand-placing boxes.&lt;/li&gt;
&lt;li&gt;An SEO audit skill (&lt;a href="https://github.com/AgricIDaniel/claude-seo" rel="noopener noreferrer"&gt;claude-seo&lt;/a&gt;) ran its checklist against the built site and found a real bug: &lt;code&gt;robots.txt&lt;/code&gt; pointed at a sitemap that no longer existed.&lt;/li&gt;
&lt;li&gt;Every visual change was screenshotted in a headless browser before it shipped, which caught a meme overflowing on phones and a diagram that rendered as a broken image.&lt;/li&gt;
&lt;li&gt;And the homepage stack section went through two designs I didn't like. Before the third attempt, I was asked to pick a concept from four sketches. That one stuck.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one isn't a tool at all. It's the oldest engineering habit there is: &lt;strong&gt;agree on what you're building before you build it.&lt;/strong&gt; It turns out it works on AI too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signs you're vibe coding
&lt;/h2&gt;

&lt;p&gt;Score yourself honestly. One point each:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Your most-used prompt is "fix it".&lt;/li&gt;
&lt;li&gt;[ ] You've said "PLEASE" to a compiler in capital letters.&lt;/li&gt;
&lt;li&gt;[ ] Your app has 3 state-management libraries and you know the name of one.&lt;/li&gt;
&lt;li&gt;[ ] There is a file called &lt;code&gt;utils2_final_REAL.ts&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;[ ] Your git history is one commit: "init".&lt;/li&gt;
&lt;li&gt;[ ] You don't know what happens if two users click Save at the same time.&lt;/li&gt;
&lt;li&gt;[ ] The AI added a cache "for performance" and you were too scared to ask why.&lt;/li&gt;
&lt;li&gt;[ ] Your API key is in the frontend, but "only for now".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;0–2:&lt;/strong&gt; you're Sam. &lt;strong&gt;3–5:&lt;/strong&gt; you're on the way. &lt;strong&gt;6–8:&lt;/strong&gt; you're Nia, and that's fine. Every Sam started as a Nia. The good news is that every habit in Sam's column can be learned, and the AI will happily help you learn it if you ask it to explain instead of just fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The playbook
&lt;/h2&gt;

&lt;p&gt;If you want to work like Sam starting tomorrow, here's the whole thing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Plan before code.&lt;/strong&gt; Ask for a plan and push back on it before any code exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write a project instructions file&lt;/strong&gt; on day 1: how to run, how to test, the rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One change per request&lt;/strong&gt;, small enough to review in two minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test first&lt;/strong&gt; for anything that matters: write the failing test, then the fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read every diff.&lt;/strong&gt; If you don't understand it, ask "why" before you accept it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commit after every green step&lt;/strong&gt;, so you can always go back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Equip, don't paste:&lt;/strong&gt; skills, plugins, MCP servers and scripts the AI can run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When stuck, ask for a diagnosis, not a fix.&lt;/strong&gt; "Explain why this fails" costs less than five rewrites.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;On day 1, AI makes everyone equal. By day 30, habits make the difference.&lt;/li&gt;
&lt;li&gt;Experienced developers often spend &lt;strong&gt;fewer&lt;/strong&gt; tokens, because small steps, tests and saved context beat "fix it" loops.&lt;/li&gt;
&lt;li&gt;Sustainable AI-written code has &lt;strong&gt;tests, small diffs, written decisions and boring choices&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Give the AI &lt;strong&gt;tools&lt;/strong&gt;, not copy-paste.&lt;/li&gt;
&lt;li&gt;Experience isn't about typing faster anymore. It's about knowing what to ask for, and knowing when the answer is wrong.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://ayushwing.com/blog/same-ai-two-developers" rel="noopener noreferrer"&gt;ayushwing.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>softwareengineering</category>
      <category>career</category>
    </item>
    <item>
      <title>Ten years of building: the patterns that keep repeating</title>
      <dc:creator>Ayush Soni</dc:creator>
      <pubDate>Thu, 29 Oct 2026 14:14:11 +0000</pubDate>
      <link>https://dev.to/ayushwing/ten-years-of-building-the-patterns-that-keep-repeating-5o5</link>
      <guid>https://dev.to/ayushwing/ten-years-of-building-the-patterns-that-keep-repeating-5o5</guid>
      <description>&lt;p&gt;I started my career in 2015 building REST APIs for an SMB commerce platform: orders, inventory, billing, purchase orders. Since then I've worked on checkout, wallet and refund services at an e-commerce marketplace, an email platform sending around 20 million personalized emails a day at a travel company, a feature-store platform at an AI startup, and large-scale cloud infrastructure.&lt;/p&gt;

&lt;p&gt;Different domains, different stacks. The same lessons kept coming back. This post collects them, and links to everything I've written about each.&lt;/p&gt;

&lt;h2&gt;
  
  
  Change running systems one reversible step at a time
&lt;/h2&gt;

&lt;p&gt;Splitting monoliths, moving data between systems, upgrading platforms underneath live traffic: the approach that works is a series of small, reversible steps. Doing it in one go is where these projects get into trouble.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://ayushwing.com/blog/expand-contract-migrations" rel="noopener noreferrer"&gt;Zero-downtime schema and data migrations with expand–contract&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ayushwing.com/blog/strangler-fig-in-practice" rel="noopener noreferrer"&gt;The strangler fig in practice: carving a service out of a monolith&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ayushwing.com/blog/durable-workflows-vs-queue-and-cron" rel="noopener noreferrer"&gt;Durable workflows vs. a queue and a cron job&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ayushwing.com/blog/business-workflows-as-state-machines" rel="noopener noreferrer"&gt;Model business workflows as state machines&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ayushwing.com/blog/choose-a-database-by-access-pattern" rel="noopener noreferrer"&gt;Choose a database by access pattern, not by hype&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Assume the network will lie to you
&lt;/h2&gt;

&lt;p&gt;Distributed systems fail partially: a request times out after it succeeded, a message arrives twice, a dependency slows down instead of failing. Designing for that up front is far cheaper than discovering it in production.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://ayushwing.com/blog/idempotency-keys" rel="noopener noreferrer"&gt;Idempotency keys: making retries safe for money&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ayushwing.com/blog/retries-without-a-retry-storm" rel="noopener noreferrer"&gt;Retries without a retry storm&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ayushwing.com/blog/transactional-outbox" rel="noopener noreferrer"&gt;The transactional outbox&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ayushwing.com/blog/kafka-consumers-in-production" rel="noopener noreferrer"&gt;Kafka consumers in production&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The non-code half of senior engineering
&lt;/h2&gt;

&lt;p&gt;The further I've gone, the more of my impact has come from things other than code: how incidents are handled, how decisions get made and recorded, which debt gets paid, and whether my team is a queue or a platform.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://ayushwing.com/blog/debugging-production" rel="noopener noreferrer"&gt;Debugging production: a method, not a hunch&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ayushwing.com/blog/design-docs-that-get-approved" rel="noopener noreferrer"&gt;Design docs that get approved&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ayushwing.com/blog/tech-debt-as-a-portfolio" rel="noopener noreferrer"&gt;Tech debt as a portfolio&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ayushwing.com/blog/self-serve-platforms" rel="noopener noreferrer"&gt;Self-serve platforms: turn your team from a queue into a product&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ayushwing.com/blog/ai-assisted-engineering" rel="noopener noreferrer"&gt;AI-assisted engineering: what actually stuck&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ayushwing.com/blog/same-ai-two-developers" rel="noopener noreferrer"&gt;Same AI, two developers: why the experienced one spends fewer tokens&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Built in the open
&lt;/h2&gt;

&lt;p&gt;Most production work lives behind NDAs, so I try ideas out in public projects too:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://ayushwing.com/blog/java-mcp-server" rel="noopener noreferrer"&gt;Building an annotation-driven MCP server in Java&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ayushwing.com/blog/graphql-federation-java" rel="noopener noreferrer"&gt;GraphQL Federation with Java subgraphs, without the N+1&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ayushwing.com/blog/spring-boot-starter-observability" rel="noopener noreferrer"&gt;One dependency for logs, traces and metrics: writing a Spring Boot starter&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ayushwing.com/blog/yt-studio-ai" rel="noopener noreferrer"&gt;YT Studio AI: wiring Vision, NLP and LLMs into a creator tool&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ayushwing.com/blog/three-small-tools" rel="noopener noreferrer"&gt;Three small tools, three lessons&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd tell my 2015 self
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read the old code before you judge it.&lt;/strong&gt; Most "weird" code encodes a lesson someone learned the hard way. Find out which one before deleting it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Learn the database properly.&lt;/strong&gt; Indexes, transactions, isolation levels, locking. A surprising number of scaling problems turn out to be data problems.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make failure boring.&lt;/strong&gt; Timeouts, retries, idempotency, rollbacks. The systems that are calm during incidents are the ones designed around failure from the start.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure before and after.&lt;/strong&gt; "It's faster now" convinces nobody; "p99 went from 1.2 s to 300 ms" convinces everyone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automate what you repeat — and then hand the automation to others.&lt;/strong&gt; The biggest jumps in team capacity I've seen came from turning expert-only tasks into safe self-serve tools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write it down.&lt;/strong&gt; Design docs, decision records, runbooks, and now this blog. Writing is how one engineer's judgment scales to a team.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you're working on similar problems — distributed systems, platform engineering, AI infrastructure — I'd like to hear from you: &lt;a href="mailto:sayush56@gmail.com"&gt;email me&lt;/a&gt; or find me on &lt;a href="https://linkedin.com/in/sayushsoni" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://ayushwing.com/blog/ten-years-of-building" rel="noopener noreferrer"&gt;ayushwing.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>career</category>
      <category>softwareengineering</category>
      <category>distributedsystems</category>
      <category>architecture</category>
    </item>
    <item>
      <title>GraphQL Federation with Java subgraphs, without the N+1</title>
      <dc:creator>Ayush Soni</dc:creator>
      <pubDate>Wed, 28 Oct 2026 06:29:12 +0000</pubDate>
      <link>https://dev.to/ayushwing/graphql-federation-with-java-subgraphs-without-the-n1-1np6</link>
      <guid>https://dev.to/ayushwing/graphql-federation-with-java-subgraphs-without-the-n1-1np6</guid>
      <description>&lt;p&gt;When I worked on GraphQL APIs that sat in front of several backend services, the hard part was never the query language. It was the joins: one screen needs a product from one service, its reviews from another and each reviewer's name from a third. Do that in a single GraphQL server and you get a "god schema" that every team must change. Do it naively across services and you get a storm of small requests.&lt;/p&gt;

&lt;p&gt;Apollo Federation is one answer: each team owns a &lt;strong&gt;subgraph&lt;/strong&gt; for its own types, and a &lt;strong&gt;router&lt;/strong&gt; composes them into one &lt;strong&gt;supergraph&lt;/strong&gt;. I built &lt;a href="https://github.com/ayushwing/graphql-federation-playground" rel="noopener noreferrer"&gt;graphql-federation-playground&lt;/a&gt; to understand the patterns you hit in production, with Java subgraphs instead of the usual Node examples.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Port&lt;/th&gt;
&lt;th&gt;Owns&lt;/th&gt;
&lt;th&gt;Extends&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;user-subgraph&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8081&lt;/td&gt;
&lt;td&gt;&lt;code&gt;User&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;product-subgraph&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8082&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Product&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;User&lt;/code&gt; with &lt;code&gt;products&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;review-subgraph&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8083&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Review&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Product&lt;/code&gt; with &lt;code&gt;reviews&lt;/code&gt;, &lt;code&gt;averageRating&lt;/code&gt;, &lt;code&gt;reviewCount&lt;/code&gt;; &lt;code&gt;User&lt;/code&gt; with &lt;code&gt;reviews&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Apollo Router&lt;/td&gt;
&lt;td&gt;4000&lt;/td&gt;
&lt;td&gt;the supergraph&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each subgraph is a Spring Boot 3 app using the &lt;a href="https://netflix.github.io/dgs/" rel="noopener noreferrer"&gt;Netflix DGS framework&lt;/a&gt;. A single &lt;code&gt;docker compose up&lt;/code&gt; boots all three, the router and Jaeger.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5v49fibd4iq2dj9kjk0f.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5v49fibd4iq2dj9kjk0f.png" alt="A client sends one query to Apollo Router, which plans sub-queries to the user, product and review subgraphs and joins their results by entity key." width="799" height="289"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The router plans the query; each subgraph resolves only the fields it owns. &lt;a href="https://ayushwing.com/diagrams/graphql-federation.html" rel="noopener noreferrer"&gt;Explore the interactive diagram&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Entities: a type with a key
&lt;/h2&gt;

&lt;p&gt;The idea that makes federation work is the &lt;strong&gt;entity&lt;/strong&gt;: a type that several subgraphs can contribute fields to, identified by a key. The review subgraph doesn't own &lt;code&gt;Product&lt;/code&gt;, but it can add fields to it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fields&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="n"&gt;id&lt;/span&gt;&lt;span class="err"&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="n"&gt;extends&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="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ID&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="n"&gt;external&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;reviews&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="n"&gt;Review&lt;/span&gt;&lt;span class="p"&gt;!]!&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;averageRating&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Float&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;reviewCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Int&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;When a client asks for a product's name and its reviews, the router first fetches the product from the product subgraph, then sends the review subgraph an &lt;code&gt;_entities&lt;/code&gt; query containing just &lt;code&gt;{ __typename: "Product", id: "p1" }&lt;/code&gt;. The review subgraph turns that key into an object and resolves the fields it owns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@DgsEntityFetcher&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Product"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="nf"&gt;fetchProductEntity&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Product&lt;/span&gt;&lt;span class="o"&gt;((&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reverse direction is equally small. A &lt;code&gt;Review&lt;/code&gt; stores only an &lt;code&gt;authorId&lt;/code&gt;, so the review subgraph returns a &lt;strong&gt;reference&lt;/strong&gt; and lets the router fetch the rest from the user subgraph:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@DgsData&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parentType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Review"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"author"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;author&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DataFetchingEnvironment&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Review&lt;/span&gt; &lt;span class="n"&gt;review&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSource&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"__typename"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"User"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;review&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAuthorId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No service calls another service directly. Ownership stays clean: the user team can change how users are stored without the review team noticing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The N+1 problem, multiplied
&lt;/h2&gt;

&lt;p&gt;Here's the catch. Ask for 50 products with their reviews and the router sends one &lt;code&gt;_entities&lt;/code&gt; request with 50 product keys. If &lt;code&gt;Product.reviews&lt;/code&gt; runs a repository query per product, that's 50 queries for one request — and in federation the pattern repeats at every hop.&lt;/p&gt;

&lt;p&gt;The fix is a &lt;strong&gt;DataLoader&lt;/strong&gt;: resolvers ask for a key, the loader collects every key requested in the same execution tick, and fetches them in one batch.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@DgsDataLoader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"reviewsByProduct"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReviewsByProductDataLoader&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;BatchLoader&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Review&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;CompletionStage&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Review&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;productIds&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;CompletableFuture&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;supplyAsync&lt;/span&gt;&lt;span class="o"&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="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Review&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;byProduct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
                    &lt;span class="n"&gt;reviewRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByProductIds&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;copyOf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;productIds&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
            &lt;span class="c1"&gt;// results must come back in the same order as the keys&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;productIds&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;byProduct&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getOrDefault&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toList&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="o"&gt;},&lt;/span&gt; &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The resolver then becomes a one-liner returning a future:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@DgsData&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parentType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Product"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"reviews"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;CompletableFuture&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Review&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;productReviews&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DataFetchingEnvironment&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSource&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;.&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Review&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;getDataLoader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"reviewsByProduct"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;load&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details worth calling out: the batch function &lt;strong&gt;must&lt;/strong&gt; return results in key order (a missing product gets an empty list, not a gap), and keys are de-duplicated before they hit the repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure every resolver
&lt;/h2&gt;

&lt;p&gt;I added a &lt;code&gt;ResolverTimingInstrumentation&lt;/code&gt; to each subgraph. It extends graphql-java's &lt;code&gt;SimplePerformantInstrumentation&lt;/code&gt;, times every field fetch, records a Micrometer timer &lt;code&gt;graphql.resolver.duration&lt;/code&gt; tagged with parent type, field and status, and logs anything slower than a threshold (&lt;code&gt;observability.graphql.slow-threshold-ms&lt;/code&gt;, default 50 ms) at WARN.&lt;/p&gt;

&lt;p&gt;That is how I found the N+1 that's still in the code — and I've left it there as the example for this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  The N+1 I didn't fix (yet)
&lt;/h2&gt;

&lt;p&gt;Look at &lt;code&gt;averageRating&lt;/code&gt; and &lt;code&gt;reviewCount&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@DgsData&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parentType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Product"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"averageRating"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="nf"&gt;averageRating&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DataFetchingEnvironment&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSource&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Review&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;reviews&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reviewRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByProductId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both call the repository per product. Request &lt;code&gt;reviews&lt;/code&gt;, &lt;code&gt;averageRating&lt;/code&gt; and &lt;code&gt;reviewCount&lt;/code&gt; for 50 products and you get one batched query plus 100 individual ones. The timing logs show it immediately: the batched field takes one slow-ish call, the other two fields take many fast ones that add up.&lt;/p&gt;

&lt;p&gt;The fix is to derive both from the same DataLoader — load the reviews once per product and compute the average and count from the cached list. DataLoaders cache per request, so the second and third field reuse the first fetch for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently in production
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bound the DataLoader thread pool.&lt;/strong&gt; The playground uses &lt;code&gt;Executors.newCachedThreadPool()&lt;/code&gt;, which grows without limit under load. Use a fixed pool sized to your database connection pool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add query cost limits at the router.&lt;/strong&gt; Federation makes deep queries cheap to write and expensive to run. Depth and cost limits belong at the edge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat the supergraph schema as a contract.&lt;/strong&gt; Run schema composition checks in CI for every subgraph change, so a breaking change fails a build instead of the router.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Federation is about &lt;strong&gt;ownership&lt;/strong&gt;: each team owns its types and contributes fields to shared entities by key.&lt;/li&gt;
&lt;li&gt;Every field that crosses an entity boundary needs a &lt;strong&gt;DataLoader&lt;/strong&gt;, or your API slows down as lists grow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-resolver timing&lt;/strong&gt; turns "the API feels slow" into a specific field name.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full playground — three subgraphs, router config, subscriptions and tests — is on &lt;a href="https://github.com/ayushwing/graphql-federation-playground" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://ayushwing.com/blog/graphql-federation-java" rel="noopener noreferrer"&gt;ayushwing.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>graphql</category>
      <category>java</category>
      <category>microservices</category>
      <category>performance</category>
    </item>
    <item>
      <title>Building an annotation-driven MCP server in Java</title>
      <dc:creator>Ayush Soni</dc:creator>
      <pubDate>Tue, 27 Oct 2026 11:45:16 +0000</pubDate>
      <link>https://dev.to/ayushwing/building-an-annotation-driven-mcp-server-in-java-39mo</link>
      <guid>https://dev.to/ayushwing/building-an-annotation-driven-mcp-server-in-java-39mo</guid>
      <description>&lt;p&gt;Exposing a Java method to an AI agent should take one annotation. When I started using MCP servers daily with Claude Code and Cursor, I noticed that almost every example was written in Python or TypeScript — while most of the systems I'd want an agent to call are Spring Boot services.&lt;/p&gt;

&lt;p&gt;So I wrote &lt;a href="https://github.com/ayushwing/java-mcp-server" rel="noopener noreferrer"&gt;java-mcp-server&lt;/a&gt;: a small toolkit where this is the whole integration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MyTools&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@McpTool&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"greet"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Say hello to someone"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;greet&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@McpParam&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"name"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Person's name"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"Hello, "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"!"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@McpResource&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"config://app/{key}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Read a config value"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@McpParam&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"key"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getProperty&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"(not set)"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start the app, and any MCP client can list and call &lt;code&gt;greet&lt;/code&gt; and read &lt;code&gt;config://app/java.version&lt;/code&gt;. This post covers how it works, the decisions behind it, and the parts I'd change.&lt;/p&gt;

&lt;h2&gt;
  
  
  MCP in one paragraph
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt; is a JSON-RPC 2.0 protocol between an AI client (Claude Desktop, Claude Code, Cursor) and a server that offers &lt;strong&gt;tools&lt;/strong&gt; (functions the model may call), &lt;strong&gt;resources&lt;/strong&gt; (read-only data addressed by URI) and prompts. A session starts with &lt;code&gt;initialize&lt;/code&gt;, the client asks &lt;code&gt;tools/list&lt;/code&gt;, and when the model decides to use a tool the client sends &lt;code&gt;tools/call&lt;/code&gt; with a name and JSON arguments. That's the core loop — and it maps almost one-to-one onto "find a method, describe it, invoke it".&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the toolkit
&lt;/h2&gt;

&lt;p&gt;I split the project into four Maven modules, and the split turned out to be the most useful decision in the project:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Module&lt;/th&gt;
&lt;th&gt;What it holds&lt;/th&gt;
&lt;th&gt;Depends on Spring?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mcp-core&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Annotations (&lt;code&gt;@McpTool&lt;/code&gt;, &lt;code&gt;@McpResource&lt;/code&gt;, &lt;code&gt;@McpParam&lt;/code&gt;) and the JSON-RPC / tool models&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mcp-server-spring-boot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Auto-configuration, bean scanning, the &lt;code&gt;/mcp&lt;/code&gt; HTTP endpoint&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mcp-server-stdio&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Newline-delimited JSON-RPC over stdin/stdout, for Claude Desktop&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mcp-server-sample&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A runnable app with system-info and math tools&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Keeping &lt;code&gt;mcp-core&lt;/code&gt; free of Spring meant the stdio transport — which Claude Desktop uses when it launches a server as a child process — was a small module on top of the same models, not a second implementation.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn7vjim8zhf9j5wj79ioi.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn7vjim8zhf9j5wj79ioi.png" alt="An MCP client calls POST /mcp; the controller routes JSON-RPC methods to a tool registry and a resource registry, which invoke annotated Spring bean methods." width="800" height="264"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;One endpoint, two registries, plain Spring beans underneath. &lt;a href="https://ayushwing.com/diagrams/java-mcp-server.html" rel="noopener noreferrer"&gt;Explore the interactive diagram&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 1: turn methods into tool definitions
&lt;/h2&gt;

&lt;p&gt;At startup, &lt;code&gt;McpToolRegistry&lt;/code&gt; walks every bean in the &lt;code&gt;ApplicationContext&lt;/code&gt;, looks for methods carrying &lt;code&gt;@McpTool&lt;/code&gt;, and builds a &lt;code&gt;ToolDefinition&lt;/code&gt; for each one. The interesting part is the input schema, because that's what the model reads to decide how to call your method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Parameter&lt;/span&gt; &lt;span class="n"&gt;param&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getParameters&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;McpParam&lt;/span&gt; &lt;span class="n"&gt;mp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;param&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAnnotation&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;McpParam&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;paramName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mp&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;mp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;param&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;paramDesc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mp&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;mp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;isRequired&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mp&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;mp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paramName&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ParameterSchema&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jsonType&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;param&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getType&lt;/span&gt;&lt;span class="o"&gt;()),&lt;/span&gt; &lt;span class="n"&gt;paramDesc&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isRequired&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paramName&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Java types map to JSON Schema types (&lt;code&gt;int&lt;/code&gt;/&lt;code&gt;long&lt;/code&gt; → &lt;code&gt;integer&lt;/code&gt;, &lt;code&gt;double&lt;/code&gt; → &lt;code&gt;number&lt;/code&gt;, &lt;code&gt;boolean&lt;/code&gt; → &lt;code&gt;boolean&lt;/code&gt;, everything else → &lt;code&gt;string&lt;/code&gt;). It's deliberately simple. I found that &lt;strong&gt;descriptions matter more than types&lt;/strong&gt;: a model given &lt;code&gt;name: string&lt;/code&gt; guesses; a model given "Person's name, as it should appear in the greeting" gets it right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: one endpoint, a switch on the method
&lt;/h2&gt;

&lt;p&gt;Every request arrives at a single &lt;code&gt;POST /mcp&lt;/code&gt; (configurable via &lt;code&gt;mcp.endpoint&lt;/code&gt;). The controller is a switch over the JSON-RPC method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"initialize"&lt;/span&gt;     &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;handleInitialize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"tools/list"&lt;/span&gt;     &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;handleToolsList&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"tools/call"&lt;/span&gt;     &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;handleToolsCall&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getParams&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"resources/list"&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;handleResourcesList&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"resources/read"&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;handleResourcesRead&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getParams&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"notifications/initialized"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
         &lt;span class="s"&gt;"notifications/cancelled"&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;JsonRpcResponse&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;JsonRpcResponse&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;ERR_METHOD_NOT_FOUND&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Unknown method: "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Errors use the JSON-RPC codes clients expect: &lt;code&gt;-32601&lt;/code&gt; for an unknown method, &lt;code&gt;-32602&lt;/code&gt; for bad params (a missing tool name, an unknown tool, a URI that matches no resource), &lt;code&gt;-32603&lt;/code&gt; for anything that blew up inside.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: invoke, and never throw at the model
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;McpToolInvoker&lt;/code&gt; maps each JSON argument onto the Java parameter type with Jackson's &lt;code&gt;treeToValue&lt;/code&gt;, calls the method reflectively, and wraps the return value: strings pass through, anything else is serialised to JSON.&lt;/p&gt;

&lt;p&gt;The rule I settled on: &lt;strong&gt;a failing tool returns a tool error, not a protocol error.&lt;/strong&gt; If your method throws, the client receives a &lt;code&gt;ToolResult&lt;/code&gt; flagged as an error with the cause's message. The model can read that and recover ("the divisor can't be zero, let me ask the user"), which it can't do with a transport-level failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources: a tiny URI-template matcher
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@McpResource(uri = "config://app/{key}")&lt;/code&gt; registers a template. When a client reads &lt;code&gt;config://app/java.version&lt;/code&gt;, the registry splits both on &lt;code&gt;/&lt;/code&gt;, matches literal segments, and captures &lt;code&gt;{variables}&lt;/code&gt; into a map that's passed to the method. It's twenty lines and handles the common cases; it doesn't do wildcards or query strings, and I haven't needed them yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two transports, one handler
&lt;/h2&gt;

&lt;p&gt;HTTP is the natural fit for a service already running in your infrastructure. Claude Desktop, though, launches MCP servers as child processes and speaks newline-delimited JSON-RPC over stdin/stdout. &lt;code&gt;McpStdioRunner&lt;/code&gt; is a read-line loop around the same request handler, with two details that bit me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Logs must go to stderr.&lt;/strong&gt; Anything written to stdout that isn't a JSON-RPC message corrupts the stream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notifications get no response.&lt;/strong&gt; A message without an &lt;code&gt;id&lt;/code&gt; must not produce a reply, or strict clients drop the connection.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;This is a v0.1, and three things are already on my list:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Proxied beans hide annotations.&lt;/strong&gt; The registry reads &lt;code&gt;bean.getClass().getMethods()&lt;/code&gt;. If the bean is wrapped in a CGLIB proxy — say because it has &lt;code&gt;@Transactional&lt;/code&gt; — the proxy's overriding methods don't carry &lt;code&gt;@McpTool&lt;/code&gt;, and the tool silently disappears. The fix is to resolve the target class with &lt;code&gt;AopUtils.getTargetClass()&lt;/code&gt; and use &lt;code&gt;AnnotationUtils.findAnnotation&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing arguments become &lt;code&gt;null&lt;/code&gt;.&lt;/strong&gt; That's fine for &lt;code&gt;String&lt;/code&gt;, but a missing value for an &lt;code&gt;int&lt;/code&gt; parameter fails at invoke time. Validate against the &lt;code&gt;required&lt;/code&gt; list before calling and return a clear &lt;code&gt;-32602&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parameter names need &lt;code&gt;-parameters&lt;/code&gt;.&lt;/strong&gt; Without &lt;code&gt;@McpParam&lt;/code&gt;, names come from reflection, which yields &lt;code&gt;arg0&lt;/code&gt; unless the code is compiled with the &lt;code&gt;-parameters&lt;/code&gt; flag. Either fail fast at startup or require &lt;code&gt;@McpParam&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Keep the protocol core independent of your framework; transports become cheap.&lt;/li&gt;
&lt;li&gt;Spend your effort on descriptions and schemas — that's the interface the model actually reads.&lt;/li&gt;
&lt;li&gt;Return tool failures as data the model can reason about.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The code, tests and sample app are on &lt;a href="https://github.com/ayushwing/java-mcp-server" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. If you're putting internal Java services in front of agents, I'd like to hear how you're handling auth — that's the next big piece.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://ayushwing.com/blog/java-mcp-server" rel="noopener noreferrer"&gt;ayushwing.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>ai</category>
      <category>mcp</category>
    </item>
  </channel>
</rss>
