<?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: Qihu Zhang</title>
    <description>The latest articles on DEV Community by Qihu Zhang (@danzizhangdev).</description>
    <link>https://dev.to/danzizhangdev</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%2F4062117%2Fc27a4e11-8e39-40d5-ad7a-e154d92508e3.png</url>
      <title>DEV Community: Qihu Zhang</title>
      <link>https://dev.to/danzizhangdev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/danzizhangdev"/>
    <language>en</language>
    <item>
      <title>Six services, 6 GB, one command: what it takes to make a microservice stack shippable</title>
      <dc:creator>Qihu Zhang</dc:creator>
      <pubDate>Sun, 30 Aug 2026 04:37:16 +0000</pubDate>
      <link>https://dev.to/danzizhangdev/six-services-6-gb-one-command-what-it-takes-to-make-a-microservice-stack-shippable-20mc</link>
      <guid>https://dev.to/danzizhangdev/six-services-6-gb-one-command-what-it-takes-to-make-a-microservice-stack-shippable-20mc</guid>
      <description>&lt;p&gt;I spent a week turning a working six-service system into something a stranger could run. The code barely changed. Almost all of the work was in the gap between "it runs on my machine" and "it runs on yours, on the first try, without me."&lt;/p&gt;

&lt;p&gt;That gap is where most side projects quietly die, and it is much wider than it looks from the inside.&lt;/p&gt;

&lt;h2&gt;
  
  
  The memory budget is a product decision, not an ops detail
&lt;/h2&gt;

&lt;p&gt;Six JVMs, Kafka, PostgreSQL, Redis, a tracing collector, a server-rendered storefront and two static consoles. Thirteen containers. Someone has to run this on a box they pay for monthly, and the size of that box is the first thing they decide — before they read a line of my code.&lt;/p&gt;

&lt;p&gt;So I capped the Docker VM at 6 GB and measured a cold start, sampling every two seconds:&lt;/p&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;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Peak, all thirteen containers&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.35 GiB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Steady state after the full product walk&lt;/td&gt;
&lt;td&gt;3.26 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Containers killed for memory&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Building the jars (&lt;code&gt;mvn clean package&lt;/code&gt;, peak RSS)&lt;/td&gt;
&lt;td&gt;530 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The peak matters more than the steady state, and it is the number nobody publishes. Steady state is six JVMs that have finished waking up. The peak is six JVMs initialising &lt;em&gt;at once&lt;/em&gt; while Flyway migrates five schemas and Kafka creates its topics — the exact moment a 4 GB box dies. If I had only measured the calm afterwards, I would have been advertising a floor I had never tested.&lt;/p&gt;

&lt;p&gt;Three decisions came out of that budget rather than out of taste:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-Xmx256m&lt;/code&gt; on every service, and Kafka's heap capped explicitly.&lt;/strong&gt; Kafka's default heap will happily take a gigabyte it does not need on a single-broker demo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One PostgreSQL instance, one database per service.&lt;/strong&gt; Five containers of Postgres would have been more purist and would have cost about 400 MB more for nothing. The isolation that matters is schema ownership, not process count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I stopped at six services.&lt;/strong&gt; Splitting further was tempting and would have pushed the floor to 16 GB — which does not make the design better, it makes the product unsellable.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cold start &lt;em&gt;is&lt;/em&gt; the gate
&lt;/h2&gt;

&lt;p&gt;The single highest-value thing I built was not a feature. It is one script that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;builds the jars,&lt;/li&gt;
&lt;li&gt;tears the stack down &lt;strong&gt;including volumes&lt;/strong&gt;,&lt;/li&gt;
&lt;li&gt;brings all thirteen containers up from nothing,&lt;/li&gt;
&lt;li&gt;waits for every health check,&lt;/li&gt;
&lt;li&gt;then walks the whole product path with 32 assertions — a buyer applies to sell, an admin approves them, the shop is created, a cross-shop order splits, stock is reserved, payment moves it, each shop fulfils its own slice, one is refunded, the rest is billed with commission and paid out.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One command, exit code 0 or a specific failed assertion. It runs in about ninety seconds to all-green.&lt;/p&gt;

&lt;p&gt;This turned out to matter more than the tests. Unit and integration tests told me my code was right &lt;em&gt;given&lt;/em&gt; my machine's state. The gate tells me a stranger's first command works. Those are different claims, and only the second one is what shipping means.&lt;/p&gt;

&lt;p&gt;It caught things nothing else did. Deleting three products from a seed file left another service's seed generating stock rows for SKUs that no longer existed — orphan ids that then collided with the next id the platform handed out. Every test passed. The gate failed on a duplicate key, in a step unrelated to the change.&lt;/p&gt;

&lt;p&gt;And near the end I moved the gate one step further out: unzip the actual distributable into a clean directory and run it &lt;strong&gt;from there&lt;/strong&gt;. A working tree that runs proves nothing about the archive you upload — missing files, an over-eager ignore rule, a generated file that never got committed, all of it only surfaces after extraction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Say what it does not do, early and in the same voice
&lt;/h2&gt;

&lt;p&gt;The payment gateway is mocked. There is one interface with &lt;code&gt;charge&lt;/code&gt; and &lt;code&gt;refund&lt;/code&gt;, a mock implementation, and documentation for swapping in a real processor including the part people skip — the webhook is not optional, events arrive twice, and you move the order to paid from the webhook rather than from the browser's return trip.&lt;/p&gt;

&lt;p&gt;I could have buried that. Instead it is in the second paragraph of the product description, next to the memory floor and next to "this will not run on shared hosting." My reasoning is not nobility, it is arithmetic: a buyer who discovers the limit after purchase writes a refund request and a one-star review, and both cost more than the sale.&lt;/p&gt;

&lt;p&gt;The same applies to single currency (amounts are integer cents in one currency throughout — multi-currency is a project, not a setting) and to there being no email at all. Writing those down was uncomfortable for about ten minutes and then it made the documentation better, because each limit has an obvious next question and answering it is most of a good manual.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually consumed the week
&lt;/h2&gt;

&lt;p&gt;Not the services. The list looked like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A demo mode that answers the whole app from inside itself.&lt;/strong&gt; One build flag and the mobile app serves its own catalogue, splits its own orders and runs its own fulfilment clock. Three jobs from one implementation: a reviewer walks the product without deploying anything, the live preview becomes a static file, and screenshots come out of the same journey script the real stack uses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Documentation for the person who does not use a terminal.&lt;/strong&gt; Every command explained, and a section on doing database work through pgAdmin instead. My previous product's review cycle was three rounds of "please cover this for non-technical buyers." Cheaper to write it up front.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The platform I had never actually built for.&lt;/strong&gt; I had shipped four milestones on an iOS simulator. The first Android build crashed instantly: the package rename had left &lt;code&gt;MainActivity&lt;/code&gt; in the old Kotlin folder, so the class in the manifest did not exist. It had been broken for months. iOS has no Kotlin, so nothing ever noticed. The app name on the launcher was still the project slug, and Android has blocked plain HTTP since API 28, which no amount of iOS testing would have told me.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the lesson I would keep if I could only keep one: &lt;strong&gt;a platform you have never actually run is entirely unverified, and the defect density there is high.&lt;/strong&gt; Six real defects in one afternoon, in code that had passed every gate I had.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that generalises
&lt;/h2&gt;

&lt;p&gt;Making something shippable is mostly the work of removing yourself from the instructions. Every step that only works because of what you already know — a port that happens to be free, a JDK that happens to be 21, an emulator that happens to reach your laptop, a file that happens to exist because you made it once by hand — is a step that fails for everyone else.&lt;/p&gt;

&lt;p&gt;The way to find those steps is not to think harder. It is to start from nothing, in a clean directory, and let a script tell you where you were leaning on yourself.&lt;/p&gt;

</description>
      <category>microservices</category>
      <category>docker</category>
      <category>devops</category>
      <category>springboot</category>
    </item>
    <item>
      <title>What may degrade, and what must fail fast</title>
      <dc:creator>Qihu Zhang</dc:creator>
      <pubDate>Sat, 29 Aug 2026 04:09:47 +0000</pubDate>
      <link>https://dev.to/danzizhangdev/what-may-degrade-and-what-must-fail-fast-4l4l</link>
      <guid>https://dev.to/danzizhangdev/what-may-degrade-and-what-must-fail-fast-4l4l</guid>
      <description>&lt;p&gt;Six services sit between a buyer clicking "buy" and a vendor eventually getting paid: &lt;code&gt;auth&lt;/code&gt;, &lt;code&gt;gateway&lt;/code&gt;, &lt;code&gt;catalog&lt;/code&gt;, &lt;code&gt;inventory&lt;/code&gt;, &lt;code&gt;order&lt;/code&gt;, and now &lt;code&gt;settlement&lt;/code&gt;. Any one of them can be slow, restarting, or fully down at any moment — that's the normal condition of a distributed system, not an incident you fix once and move past. The question worth writing down isn't "how do we prevent that." You can't, fully. It's: when it happens, which requests should degrade to a worse-but-safe answer, and which must refuse outright rather than guess?&lt;/p&gt;

&lt;p&gt;The instinct most people reach for first is a single global rule — either "always degrade gracefully" or "always fail fast." Both are wrong the moment you apply them uniformly across services that don't carry the same kind of risk. I found the actual criterion isn't "how important is this service." It's whether the business cost of guessing wrong is &lt;em&gt;reversible&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The criterion, not the checklist
&lt;/h2&gt;

&lt;p&gt;A service whose worst-case wrong guess is "the buyer sees something slightly stale" may degrade — the guess self-corrects the moment the dependency comes back, and nothing irreversible happened while it was wrong. A service whose worst-case wrong guess is "we sold something we don't have" or "money moved that shouldn't have" must fail fast, because there is no &lt;code&gt;UPDATE&lt;/code&gt; statement that un-ships a package or un-spends a vendor's payout. That's the whole rule. Everything below is just applying it, service by service, to real code that's actually running.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;catalog&lt;/code&gt; down → degrade
&lt;/h3&gt;

&lt;p&gt;The storefront can serve a cached snapshot of what it last saw, or an empty result for an uncached query, and let the buyer retry. Worst case: a product looks unavailable that's actually fine, or a price is a few seconds stale. Both are cosmetic and self-correct the instant catalog comes back. Nothing about "can't browse right now" commits the business to anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;inventory&lt;/code&gt; down → fail fast
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;order.create()&lt;/code&gt;'s call to inventory's reserve endpoint is synchronous, and it has no fallback path — deliberately. If the fallback were "let the order through and reconcile inventory later," the failure mode is overselling the last unit of a scarce SKU to five buyers at once, discovered only when four of them can't be shipped what they paid for. Checkout returning a 503 and telling the buyer to retry is strictly better than checkout succeeding and lying. This is the same conditional-update invariant I wrote about in the inventory-reservation piece — &lt;code&gt;WHERE available &amp;gt;= ?&lt;/code&gt; either commits or it doesn't, and there's no version of "degrade gracefully" that doesn't mean "sell something you can't deliver."&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;settlement&lt;/code&gt; down → degrade to zero visible effect
&lt;/h3&gt;

&lt;p&gt;This is the newest row in the table, and the one that only exists because M3 added a sixth service. &lt;code&gt;order&lt;/code&gt; never calls settlement synchronously — the only coupling is &lt;code&gt;order&lt;/code&gt; publishing &lt;code&gt;order-paid&lt;/code&gt;, &lt;code&gt;sub-order-delivered&lt;/code&gt;, and &lt;code&gt;sub-order-refunded&lt;/code&gt; to Kafka and moving on. Settlement being down means those events queue up unconsumed. Checkout, payment, and fulfillment are entirely unaffected, because nothing on the buyer path is waiting for settlement to acknowledge anything. The cost lands entirely on vendors getting paid later than usual — bounded and recoverable, because Kafka retains the backlog and settlement's own idempotent consumer means catching up on a day of queued events produces the same end state as processing them on time. I didn't just argue this — the M3 gate exercises it directly: place an order, split it, pay, mark it delivered, and &lt;em&gt;only afterward&lt;/em&gt; bring the billing sweep around to consume the resulting events. Nothing in the buyer-facing steps has ever depended on settlement being reachable, by construction, not by the gate happening to get lucky with timing.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;auth&lt;/code&gt; down → degrade for issued tokens, fail fast for new ones
&lt;/h3&gt;

&lt;p&gt;The gateway validates JWTs locally against a cached JWKS — a request carrying a still-valid signed token is authorized without calling &lt;code&gt;auth&lt;/code&gt; at all, so a buyer mid-session keeps shopping through an &lt;code&gt;auth&lt;/code&gt; outage with zero visible effect. A brand-new login does have to reach &lt;code&gt;auth&lt;/code&gt;, and that has to fail rather than accept a token it can't verify. Skipping that check isn't a degradation choice, it's a decision to stop checking signatures — a security regression wearing a resilience costume.&lt;/p&gt;

&lt;h2&gt;
  
  
  The table, for anyone paging through an incident
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dependency down&lt;/th&gt;
&lt;th&gt;Buyer-facing behavior&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;catalog&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cached/stale/empty results&lt;/td&gt;
&lt;td&gt;Wrong guess is cosmetic and reversible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;inventory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Checkout fails fast (503)&lt;/td&gt;
&lt;td&gt;Wrong guess is overselling — irreversible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;settlement&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No visible effect at all&lt;/td&gt;
&lt;td&gt;Not on the buyer path; backlog drains later&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;auth&lt;/code&gt; (existing token)&lt;/td&gt;
&lt;td&gt;Unaffected (local JWT verify)&lt;/td&gt;
&lt;td&gt;No call needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;auth&lt;/code&gt; (new login/JWKS miss)&lt;/td&gt;
&lt;td&gt;Fails fast&lt;/td&gt;
&lt;td&gt;Can't verify what we can't check&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The value of writing this down isn't the table itself — it's that "which side of this table a service belongs on" is a property of &lt;em&gt;what the wrong guess costs&lt;/em&gt;, not of how central the service feels in an architecture diagram. Add a seventh service later, and the question to ask is the same one this table already answers: can a wrong-guess response be undone by a later correction, or does it commit the business to something — stock shipped, money moved — that can't be clawed back cleanly? The answer decides the failure mode before a single line of fallback code gets written, not after an incident reveals it was wrong.&lt;/p&gt;

&lt;p&gt;It's also worth naming what this framework is &lt;em&gt;not&lt;/em&gt; saying. It's not "settlement doesn't matter" — money not reaching vendors on time matters a great deal to the vendors waiting on it. It's that the &lt;em&gt;recovery&lt;/em&gt; is cheap and bounded (drain the backlog; reconciliation catches anything that still doesn't add up), so it belongs in the "degrade" column even though the thing it's protecting is high-stakes. Reversibility, not stakes, is the axis this sorts on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a circuit breaker fits, and where it doesn't
&lt;/h2&gt;

&lt;p&gt;Resilience4j sits at the gateway, and it's tempting to treat "add a circuit breaker" as a solved, mechanical step once you've written a resilience ADR. It isn't, because a circuit breaker's failure-rate threshold and open-state duration are business judgments wearing config-file syntax, not defaults you tune once and forget. Set the threshold too sensitive and you trip the breaker on an inventory service having one slow GC pause, converting a 20ms blip into checkout being unavailable for the whole open-state window — you've just built your own worse incident on top of a fake one. Set it too lax and the breaker never opens before a genuinely struggling dependency has already caused the actual harm you built the thing to prevent.&lt;/p&gt;

&lt;p&gt;The number that should set that threshold isn't a Resilience4j tutorial's example config — it's the answer this ADR already worked out for each dependency. A breaker in front of &lt;code&gt;inventory&lt;/code&gt; should trip fast and stay open long enough that a struggling instance gets a real chance to recover, because every request that gets through to a failing inventory call is a request that either fails fast (correct) or, worse, times out slowly while a buyer stares at a spinner. A breaker in front of &lt;code&gt;catalog&lt;/code&gt;, by contrast, can afford to be far more patient, because a slightly-too-eager open state there just means a few extra buyers see a stale result they'd have seen anyway. Same library, same default config shape, opposite tuning — because the two dependencies sit on opposite sides of the reversibility line this whole piece is about. The circuit breaker doesn't replace the judgment call. It just automates executing whichever judgment call you already made.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is part of a series on building a multi-vendor commerce platform. The open-source half, &lt;a href="https://github.com/danzizhangdev/stallora-cloud-starter" rel="noopener noreferrer"&gt;stallora-cloud-starter&lt;/a&gt;, carries the gateway and auth services this piece describes. Next up: shipping a six-service stack that buyers can actually run on a 6 GB VPS.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>microservices</category>
      <category>java</category>
      <category>architecture</category>
      <category>resilience</category>
    </item>
    <item>
      <title>Never split the money on payment success</title>
      <dc:creator>Qihu Zhang</dc:creator>
      <pubDate>Wed, 19 Aug 2026 06:18:45 +0000</pubDate>
      <link>https://dev.to/danzizhangdev/never-split-the-money-on-payment-success-32ob</link>
      <guid>https://dev.to/danzizhangdev/never-split-the-money-on-payment-success-32ob</guid>
      <description>&lt;p&gt;Here's a line of code that looks completely reasonable: the moment a buyer's card is charged, credit the vendor's share to their payout balance. It's the obvious anchor — &lt;code&gt;paid_at&lt;/code&gt; is already a timestamp sitting right there on the order, the money genuinely did just arrive, and "bill the vendor when we got paid" reads like the simplest possible rule.&lt;/p&gt;

&lt;p&gt;It is also, in a marketplace with real buyers and real returns, the most expensive line of code you can write, because it commits you to a promise you can't always keep: that a refund is just a database correction. It isn't. I built the settlement service for a multi-vendor platform around the opposite anchor — bill a vendor only after the order is delivered &lt;em&gt;and&lt;/em&gt; the return window has closed — and the reason isn't academic purity about escrow. It's that the alternative turns every post-payment refund into a collections problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "bill on payment" actually commits you to
&lt;/h2&gt;

&lt;p&gt;Walk through what a refund has to do under a &lt;code&gt;paid_at&lt;/code&gt; anchor. The vendor's cut was billed the instant the charge cleared. In this system, "billed" is what makes a bill eligible to be locked into a payout and paid out — so by the time a buyer returns the item three days later, that money may already have left the platform and landed in the vendor's bank account. The refund can't just flip a status column anymore. Now you're chasing the vendor for money back, netting it against whatever they're owed next cycle, or eating the loss yourself. None of those are database operations. They're accounts-receivable operations, and they involve a second party who didn't do anything wrong and may not appreciate a clawback request.&lt;/p&gt;

&lt;p&gt;The fix people reach for is "well, don't pay out until some grace period after billing." But that's just re-deriving a return-window anchor through the back door, badly — now you have two clocks (billing and payout-eligibility) that both have to track the same real-world fact (has the buyer's refund window closed), and they can drift out of sync. Easier to have one clock.&lt;/p&gt;

&lt;h2&gt;
  
  
  The anchor: delivered, plus the return window
&lt;/h2&gt;

&lt;p&gt;So the actual anchor is &lt;code&gt;delivered_at + RETURN_WINDOW&lt;/code&gt;. A sub-order becomes billable only once it's been marked delivered &lt;em&gt;and&lt;/em&gt; the configured return window has elapsed with nothing excluding it. Here's the query that decides what's billable, straight from the running service:&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sub_order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vendor_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vendor_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gross_cents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pending_settlement&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;bill_id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;excluded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;FALSE&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;delivered_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;delivered_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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="k"&gt;CAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;)&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;id&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three conditions have to hold before a row is even a candidate: nothing has billed it yet (&lt;code&gt;bill_id IS NULL&lt;/code&gt;), nothing has excluded it (&lt;code&gt;excluded = FALSE&lt;/code&gt;), and the return window has actually passed since delivery. &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; means a second sweeper running concurrently splits the batch instead of fighting over the same rows — the same posture the inventory service uses for its own timeout scanner, because both are the same shape of problem: a scheduled job claiming a batch of rows without a coordinator arbitrating between instances.&lt;/p&gt;

&lt;p&gt;Until all three conditions hold, the platform is just... holding the money. Not processing it, not provisionally crediting it, just holding it. That's what escrow actually means: cash in, held, released to the vendor only once the window has run out with no refund. It isn't a side effect of the data model. It's the point of the data model.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a refund actually does under this anchor
&lt;/h2&gt;

&lt;p&gt;Here's the part that makes the whole design earn its keep. A refund inside the window doesn't reach into a payout and try to reverse it. It flips one boolean on a row that was never going to be billed anyway:&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="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;markExcluded&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;subOrderId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// bill_id IS NULL guard: a refund event racing past an already-generated bill must not&lt;/span&gt;
    &lt;span class="c1"&gt;// silently exclude it — that case is reconciliation's to flag, not this consumer's to hide.&lt;/span&gt;
    &lt;span class="k"&gt;return&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="s"&gt;"UPDATE pending_settlement SET excluded = TRUE "&lt;/span&gt;
            &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"WHERE sub_order_id = ? AND bill_id IS NULL AND excluded = FALSE"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subOrderId&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;Read the &lt;code&gt;WHERE&lt;/code&gt; clause carefully, because the guard is doing real work, not just being defensive for its own sake. &lt;code&gt;bill_id IS NULL&lt;/code&gt; means: only exclude a sub-order from billing if it hasn't been billed yet. If a refund event somehow arrives &lt;em&gt;after&lt;/em&gt; the sweep already generated a bill for that sub-order — a misconfigured return window, a slow consumer, some edge case nobody designed for — this update quietly does nothing. &lt;code&gt;markExcluded&lt;/code&gt; returns 0 rows affected, and the caller only writes the &lt;code&gt;REFUND_EXCLUDED&lt;/code&gt; ledger entry when the row count says the exclusion actually took effect. The already-generated bill sits there, untouched, and becomes reconciliation's problem to surface — not something this consumer tries to paper over by pretending it can undo a bill that already exists. A refund's job is to prevent a debt from ever being incurred. It is explicitly not the job of stopping a debt that's already been recorded; that's a different, harder problem, and the code refuses to pretend otherwise.&lt;/p&gt;

&lt;p&gt;That's the whole trick, and it's why "refund = exclusion, not a negative bill" is the right way to describe it rather than "refund = accounting adjustment." There's no &lt;code&gt;-$40&lt;/code&gt; line item anywhere. There's a sub-order that simply never crosses into &lt;code&gt;settlement_bill&lt;/code&gt; at all. The &lt;code&gt;BillingSweep&lt;/code&gt; that turns pending rows into bills is the only writer of that table, and it only ever reads rows where &lt;code&gt;excluded = FALSE&lt;/code&gt; — so an excluded row isn't reversed, it's never admitted into the billing story in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost, stated plainly
&lt;/h2&gt;

&lt;p&gt;This isn't free. The vendor gets paid one full return-window later than they would under a &lt;code&gt;paid_at&lt;/code&gt; anchor. For a marketplace, that delay is the actual price of being the party holding the bag during the window a buyer is entitled to change their mind. It's not a performance bug to be optimized away — it's what escrow costs, and pretending otherwise (by billing early and hoping refunds stay rare) just moves the cost from "vendor payout is delayed" to "vendor payout occasionally has to be un-done," which is a strictly worse failure mode because it involves a third party's bank account.&lt;/p&gt;

&lt;p&gt;I did consider anchoring on &lt;code&gt;paid_at&lt;/code&gt; and clawing back via a receivable on refund. It's not a strawman — it's how a lot of systems are built, often because settlement gets bolted onto an order model after the fact and &lt;code&gt;paid_at&lt;/code&gt; is the timestamp that's already there. I rejected it here because the fix has to happen eventually anyway once someone experiences a payout that's already spent when a refund shows up, and building the receivable-clawback machinery now, only to replace it with a return-window anchor once that incident happens, is strictly more work than building the right anchor from the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this bought, in numbers
&lt;/h2&gt;

&lt;p&gt;The commission calculation happens once, at billing time, and gets frozen into the bill row. Change a commission rule after that and history doesn't move — I proved this in a test that mutates a category's rate mid-run and asserts already-generated bills keep their original commission amount. Against the running stack: two sub-orders get delivered, the return window (compressed to two minutes for the demo, seven days by default) passes, and the sweep produces two bills where &lt;code&gt;net_cents + commission_cents == gross_cents&lt;/code&gt; on every row, using at least two distinct commission-rule scopes to prove the vendor/category/default priority actually resolves differently per line. A second order gets refunded inside its window, and the gate asserts three things directly: the refunded sub-order never gets a bill, its stock comes back, and the still-active pending rows are unaffected. Nothing rolled back. Nothing clawed back. There was simply nothing to bill.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is part of a series on building a multi-vendor commerce platform. The open-source half, &lt;a href="https://github.com/danzizhangdev/stallora-cloud-starter" rel="noopener noreferrer"&gt;stallora-cloud-starter&lt;/a&gt;, carries the outbox library the settlement service consumes events through. Next up: which of this platform's five services are allowed to degrade gracefully when they go down, and which one has to refuse outright — and why that split isn't about which service feels the most important.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>microservices</category>
      <category>java</category>
      <category>architecture</category>
      <category>payments</category>
    </item>
    <item>
      <title>The outbox pattern is four sentences in a blog post. Here are three incidents from running it.</title>
      <dc:creator>Qihu Zhang</dc:creator>
      <pubDate>Mon, 17 Aug 2026 05:01:42 +0000</pubDate>
      <link>https://dev.to/danzizhangdev/the-outbox-pattern-is-four-sentences-in-a-blog-post-here-are-three-incidents-from-running-it-4iek</link>
      <guid>https://dev.to/danzizhangdev/the-outbox-pattern-is-four-sentences-in-a-blog-post-here-are-three-incidents-from-running-it-4iek</guid>
      <description>&lt;p&gt;Write the event into the same transaction as the row. Poll the table. Send to Kafka. Mark it sent. That's the whole pattern, and every version of it you'll find online stops right there, because at that level of description it's obviously correct — one commit, so a published fact can never disagree with the row that caused it.&lt;/p&gt;

&lt;p&gt;I built one, ran it for real between two services, and hit three separate ways for "obviously correct" to still go wrong in practice. None of them are exotic. All three are the kind of thing that only shows up once something is actually polling a table and actually talking to a broker instead of living in a diagram.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 1 — the poison message that never gets counted
&lt;/h2&gt;

&lt;p&gt;The relay's job is: claim a batch of unsent rows, send each one, mark it &lt;code&gt;SENT&lt;/code&gt; on a broker ack. The first version of that loop looked reasonable:&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="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;Exception&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;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Outbox relay failed for message {}; row stays NEW and will be retried"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;messageId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// keep ordering; remaining rows retried next pass&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;break&lt;/code&gt; instead of &lt;code&gt;continue&lt;/code&gt; is deliberate and correct on its own — you want per-partition ordering preserved, so if row 3 fails, rows 4 through 20 shouldn't jump ahead of it. The bug is what's missing: nothing counts how many times row 3 has failed. If the reason it's failing is permanent — a broker that's actually down for that topic, a message the producer can't serialize, anything that isn't going to resolve itself — the relay retries the &lt;em&gt;same&lt;/em&gt; row forever, on every poll, and every row behind it queues up behind a jam that will never clear on its own. No counter, no ceiling, no way to notice except staring at a growing &lt;code&gt;NEW&lt;/code&gt; count in the table.&lt;/p&gt;

&lt;p&gt;The fix is the boring one: give it a number to compare against.&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="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;((&lt;/span&gt;&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;row&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;"attempts"&lt;/span&gt;&lt;span class="o"&gt;)).&lt;/span&gt;&lt;span class="na"&gt;intValue&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&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;attempts&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;maxAttempts&lt;/span&gt;&lt;span class="o"&gt;)&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="s"&gt;"UPDATE outbox_message SET status='FAILED', attempts=? WHERE id=?"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&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="n"&gt;log&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="s"&gt;"Outbox message {} parked as FAILED after {} attempts"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;messageId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="o"&gt;;&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="s"&gt;"UPDATE outbox_message SET attempts=? WHERE id=?"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&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="k"&gt;break&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After &lt;code&gt;maxAttempts&lt;/code&gt; (default 10), the row is parked as &lt;code&gt;FAILED&lt;/code&gt; and the loop moves past it — &lt;code&gt;continue&lt;/code&gt;, not &lt;code&gt;break&lt;/code&gt;, specifically for the row that's given up, so the jam it was causing clears while the ones behind it that might genuinely still succeed keep trying. A &lt;code&gt;FAILED&lt;/code&gt; row is something a human can query, alert on, and retry by hand once the root cause is fixed. An infinitely-retried &lt;code&gt;NEW&lt;/code&gt; row that never surfaces anywhere is not.&lt;/p&gt;

&lt;p&gt;The part worth sitting with: the code that shipped without a counter &lt;em&gt;passed every test that existed at the time&lt;/em&gt;. Tests exercised the happy path and one transient failure that resolved itself. Nothing exercised "this row will never succeed," because writing that test requires first believing a message can be permanently unsendable — which is exactly the assumption that's easy to skip when you're picturing the pattern as four sentences.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 2 — the trace dies at the queue
&lt;/h2&gt;

&lt;p&gt;HTTP calls carry a trace context automatically. &lt;code&gt;order&lt;/code&gt; calls &lt;code&gt;inventory&lt;/code&gt;'s reservation endpoint, and whatever traced the request into &lt;code&gt;order&lt;/code&gt; traces it straight through to &lt;code&gt;inventory&lt;/code&gt; — that's what tracing instrumentation for HTTP clients is &lt;em&gt;for&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;A polled outbox message gets none of that. By the time the relay picks a row off the table, the HTTP request that originally caused the write is long finished, its context long gone. Kafka doesn't know what a trace is. Left alone, every event on the far side of a &lt;code&gt;KafkaListener&lt;/code&gt; starts a trace of its own — inventory deducting a hold shows up in Jaeger as an orphan, with no link back to the checkout that paid for it.&lt;/p&gt;

&lt;p&gt;The fix has to be manual, because there's no automatic HTTP-style propagation to lean on. The outbox row carries the trace id as a column, written at insert time from whatever's currently in MDC:&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="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="s"&gt;"INSERT INTO outbox_message (message_id, topic, message_key, payload, trace_id) VALUES (?,?,?,?,?)"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;messageId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;topic&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;jsonPayload&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;MDC&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;"traceId"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The relay reads that column back and attaches it as a Kafka header alongside the message id used for deduplication:&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="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;traceId&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="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;row&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;"trace_id"&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;traceId&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="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;headers&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="nc"&gt;OutboxHeaders&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TRACE_ID&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;traceId&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getBytes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StandardCharsets&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;UTF_8&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;And the consumer restores it before doing anything else, so every log line and span for the duration of processing that message is tagged with the trace that started at checkout:&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;traceHeader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;lastHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OutboxHeaders&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TRACE_ID&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;traceId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;traceHeader&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="kc"&gt;null&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;String&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;traceHeader&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="nc"&gt;StandardCharsets&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;UTF_8&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;traceId&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="o"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;MDC&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="s"&gt;"traceId"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;traceId&lt;/span&gt;&lt;span class="o"&gt;);&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="c1"&gt;// ... process&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;MDC&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;remove&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"traceId"&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;Three lines of column, three lines of header, three lines of MDC — and the payoff shows up as one line grepped out of two different services' logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;traceId bd1a34b2a4665b240cf284c31db76708 crossed Kafka into the consumer log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same id, &lt;code&gt;order&lt;/code&gt;'s side and &lt;code&gt;inventory&lt;/code&gt;'s side, proving the bridge actually held. Skip this and the trace doesn't error out or warn you — it just quietly stops, and "quietly stops" is the worst failure mode a debugging tool can have, because you don't find out until you're already looking for something and it isn't there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 3 — at-least-once means you will get it twice
&lt;/h2&gt;

&lt;p&gt;Kafka's delivery guarantee is at-least-once, not exactly-once, and that isn't a rare edge case you might hit — it's the normal operating mode. A broker retry, a consumer rebalance, a producer resending after a slow ack: any of them redelivers a message the consumer already handled. The second copy of &lt;code&gt;order-paid&lt;/code&gt; has to be a no-op. If it isn't, "convert the hold into a sale" runs twice and the stock ledger disagrees with reality.&lt;/p&gt;

&lt;p&gt;The dedup row and the side effect it's guarding both have to commit in the &lt;em&gt;same&lt;/em&gt; transaction, or the fix doesn't actually fix anything:&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="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;processOnce&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;messageId&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;consumerGroup&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Runnable&lt;/span&gt; &lt;span class="n"&gt;action&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;Boolean&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TRUE&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;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="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="s"&gt;"INSERT INTO processed_message (message_id, consumer_group) VALUES (?,?) ON CONFLICT DO NOTHING"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;messageId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;consumerGroup&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="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// same transaction: the dedup row and the side effect commit together&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&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;Splitting that into two transactions — insert the dedup row, commit, &lt;em&gt;then&lt;/em&gt; run the side effect — reopens the exact gap this exists to close: a crash between the two either loses the effect (dedup row committed, side effect never ran) or blocks it forever (some other bug makes the side effect always fail, but the dedup row already says "done"). One transaction, both or neither.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;processed_message&lt;/code&gt; is the fast path, and it's not the only layer here — the ledger's own unique constraint on &lt;code&gt;(order_id, sku_id, type)&lt;/code&gt; is a second, independent check at the data level, so even a message that somehow slipped past deduplication can't double-insert a movement. Testing this for real means actually delivering the same message twice and watching nothing move the second time, not asserting it in the abstract:&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="c1"&gt;// deliver the same messageId twice; Awaitility.during(2s) — not a snapshot —&lt;/span&gt;
&lt;span class="c1"&gt;// confirms the ledger row count and available/reserved stay flat across the whole window&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A one-shot assertion right after the second delivery can pass by accident if the second message just hasn't been processed &lt;em&gt;yet&lt;/em&gt;. Watching a window is the difference between "looks idempotent" and "is idempotent."&lt;/p&gt;

&lt;h2&gt;
  
  
  When you'd reach for Debezium instead
&lt;/h2&gt;

&lt;p&gt;Everything above is the polling-outbox version: a scheduled query with &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; instead of a CDC connector tailing the write-ahead log. Debezium never misses a row and adds essentially no latency, and it costs you Kafka Connect — another JVM, another thing that can fall over, another gigabyte you don't get back. On a budget where the whole stack has to cold-start on a 6 GB box, that trade isn't close.&lt;/p&gt;

&lt;p&gt;The honest trigger for switching: sustained throughput in the thousands of events per second, a latency requirement tighter than "within about a second," or an operations team that's already running Kafka Connect for something else, so the marginal cost is close to zero. None of those describe this system today. The schema doesn't care which way you go — &lt;code&gt;outbox_message&lt;/code&gt; looks the same either way, which is the actual point of keeping the contract in the database instead of in whichever tool happens to be reading it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is part of a series on building a multi-vendor commerce platform. The transactional outbox and the idempotent-consumer helper both live in &lt;a href="https://github.com/danzizhangdev/stallora-cloud-starter" rel="noopener noreferrer"&gt;stallora-cloud-starter&lt;/a&gt;, Apache-2.0, and every pitfall above was found by actually running the thing against real Postgres and real Kafka, not by reading the pattern's description one more time.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kafka</category>
      <category>java</category>
      <category>springboot</category>
      <category>debugging</category>
    </item>
    <item>
      <title>The textbook says cross-service inventory needs a coordinator. This checkout has none.</title>
      <dc:creator>Qihu Zhang</dc:creator>
      <pubDate>Sat, 15 Aug 2026 06:34:47 +0000</pubDate>
      <link>https://dev.to/danzizhangdev/the-textbook-says-cross-service-inventory-needs-a-coordinator-this-checkout-has-none-lcm</link>
      <guid>https://dev.to/danzizhangdev/the-textbook-says-cross-service-inventory-needs-a-coordinator-this-checkout-has-none-lcm</guid>
      <description>&lt;p&gt;Ask how to reserve stock across two services and keep it correct, and the textbook answer arrives fast: you need a distributed transaction coordinator. Two-phase commit, or a try/confirm/cancel (TCC) protocol run by something like Seata, sitting between the services and making sure both sides agree.&lt;/p&gt;

&lt;p&gt;I placed an order that splits across two vendors, reserves stock in a separate &lt;code&gt;inventory&lt;/code&gt; service, and survives two buyers racing for the last unit of the same SKU — with no coordinator anywhere in the request path. Not "eventually add one." None, by design. I want to show you why that holds up, because the reasoning generalises past inventory.&lt;/p&gt;

&lt;h2&gt;
  
  
  The invariant is smaller than the transaction
&lt;/h2&gt;

&lt;p&gt;Here is the constraint that actually has to hold: stock must never go negative. That's it. Not "the order and the reservation must be created atomically" — just that one row, &lt;code&gt;available&lt;/code&gt;, cannot drop below zero no matter how many requests hit it at once.&lt;/p&gt;

&lt;p&gt;That is single-row arithmetic, and Postgres already has an answer for single-row arithmetic under concurrency: a conditional update.&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;UPDATE&lt;/span&gt; &lt;span class="n"&gt;stock_item&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;available&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;available&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reserved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reserved&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;sku_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;available&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the row's &lt;code&gt;available&lt;/code&gt; is high enough, the update commits and the hold exists. If it isn't, zero rows are affected, &lt;code&gt;StockService.reserve&lt;/code&gt; throws, and &lt;code&gt;order.create()&lt;/code&gt; never persists anything. There is no tentative state, no half-reserved row waiting for a second message to confirm it. The &lt;code&gt;WHERE&lt;/code&gt; clause &lt;em&gt;is&lt;/em&gt; the entire oversell defence, and it's a property of one row in one service's own database — nothing about it requires a second service to agree on anything, in the same instant or otherwise.&lt;/p&gt;

&lt;p&gt;The habit that leads people to a coordinator is conflating "this spans two services" with "this needs cross-service atomicity." Placing an order spans two services. The part that must never be wrong — the stock count — does not. Once you separate those two questions, most of the case for a coordinator goes away on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try/confirm/cancel survive, the coordinator doesn't
&lt;/h2&gt;

&lt;p&gt;The reservation lifecycle really is shaped like TCC. I didn't argue that away — I kept the shape and cut the coordinator out of the middle of it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;TCC step&lt;/th&gt;
&lt;th&gt;What actually runs&lt;/th&gt;
&lt;th&gt;Where the correctness lives&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;try&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;order.create()&lt;/code&gt; calls &lt;code&gt;inventory&lt;/code&gt;'s reserve endpoint synchronously, one HTTP call, no saga framework&lt;/td&gt;
&lt;td&gt;The conditional &lt;code&gt;UPDATE&lt;/code&gt; above, inside &lt;code&gt;inventory&lt;/code&gt;'s own local transaction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;confirm&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;order&lt;/code&gt; writes &lt;code&gt;order-paid&lt;/code&gt; to its outbox in the same transaction as the &lt;code&gt;PAID&lt;/code&gt; state change; &lt;code&gt;inventory&lt;/code&gt; consumes it later and deducts the hold&lt;/td&gt;
&lt;td&gt;Kafka's at-least-once delivery + an idempotent consumer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;cancel&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Same shape, on &lt;code&gt;order-cancelled&lt;/code&gt; — buyer cancel or the timeout sweep&lt;/td&gt;
&lt;td&gt;Same idempotent consumer, same code path&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;try&lt;/code&gt; is synchronous because the caller is still on the line and the answer is needed right now: either the hold exists or the order was never worth creating. &lt;code&gt;confirm&lt;/code&gt; and &lt;code&gt;cancel&lt;/code&gt; are asynchronous because they're genuinely eventual — payment takes real time, might never happen, and nothing about "eventually tell inventory to convert or release the hold" needs to happen at the same instant as anything else. A coordinator's whole value is enforcing that two updates happen in the same instant. Nothing here asks for that.&lt;/p&gt;

&lt;p&gt;What crosses the service boundary isn't a joint decision, it's a sequence: reserve now, then confirm or cancel later, exactly once. Outbox-plus-Kafka already delivers that sequence. Adding a coordinator on top wouldn't remove the "later, eventually" step — it would add a second system whose only job is remembering where in the sequence everyone currently is, duplicating the job Kafka and an idempotent consumer already do.&lt;/p&gt;

&lt;p&gt;If the reservation succeeds but the order fails to persist right after — a duplicate &lt;code&gt;Idempotency-Key&lt;/code&gt; loses a race, or the insert throws for an unrelated reason — that's the one compensation that runs synchronously: &lt;code&gt;order.create()&lt;/code&gt;'s catch block calls &lt;code&gt;inventory&lt;/code&gt;'s release endpoint before returning the error to the caller, because the failure is already known and there's no reason to hand it to an async pipeline for something the request thread can just fix immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unglamorous parts that make it safe
&lt;/h2&gt;

&lt;p&gt;None of the above works without three pieces of plumbing that will never make it onto a conference slide.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Idempotency, four layers deep.&lt;/strong&gt; Kafka promises at-least-once, which means the consumer will eventually see the same message twice, or — worse — see &lt;code&gt;order-cancelled&lt;/code&gt; arrive after it already applied &lt;code&gt;order-paid&lt;/code&gt; for the same order because something upstream misbehaved. Four checks catch that, cheapest first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;processed_message (message_id, consumer_group)&lt;/code&gt; — a redelivered message is recognized
before any business logic runs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;stock_ledger&lt;/code&gt;'s &lt;code&gt;UNIQUE (order_id, sku_id, type)&lt;/code&gt; — a second &lt;code&gt;RESERVE&lt;/code&gt;/&lt;code&gt;RELEASE&lt;/code&gt;/&lt;code&gt;DEDUCT&lt;/code&gt;
row for the same order and SKU can't insert, so even a message that slipped past layer 1 (a crash between marking it processed and committing) is caught at the data level.&lt;/li&gt;
&lt;li&gt;The conditional arithmetic itself, unconditionally guarded against going negative.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terminal-movement mutual exclusion&lt;/strong&gt; — nothing above stops &lt;code&gt;release&lt;/code&gt; from firing on an
order that was already &lt;code&gt;deduct&lt;/code&gt;ed. &lt;code&gt;release&lt;/code&gt; and &lt;code&gt;deduct&lt;/code&gt; each check the ledger for the &lt;em&gt;opposite&lt;/em&gt; terminal movement first and refuse if it's already there. This is the layer that turns "our own producer emitted the wrong event" into a loud no-op instead of a silent double-adjustment of &lt;code&gt;available&lt;/code&gt;. It also happens to be the layer I added a fix round &lt;em&gt;after&lt;/em&gt; the first version shipped — the original code idempotency-checked duplicate deliveries but never checked whether the opposite terminal state had already landed by a different route. It's the kind of gap that four layers of "handle duplicates" doesn't catch, because a bypassed state machine isn't a duplicate.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Timeouts found by scanning, not by waiting.&lt;/strong&gt; Kafka has no delayed-delivery primitive, so "cancel this order if nobody pays within 30 minutes" can't be a message with a fuse sitting in a queue. Instead a scheduled job selects expired &lt;code&gt;PENDING&lt;/code&gt; orders with &lt;code&gt;SELECT ... FOR UPDATE SKIP LOCKED&lt;/code&gt; against a partial index on &lt;code&gt;status = 'PENDING'&lt;/code&gt; — the locking means running the scanner on more than one instance is safe, and the partial index means it never touches rows that were never candidates. The cancel and its outbox write commit together, so there's no window where the sweep decided to cancel but never told anyone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reconciliation as a feature, not an afterthought.&lt;/strong&gt; The four idempotency layers guarantee duplicates and bypasses are &lt;em&gt;caught&lt;/em&gt;. They don't guarantee anyone finds out. A scheduled job diffs &lt;code&gt;stock_ledger&lt;/code&gt; movements against live order state and files a row for a human to look at on any mismatch. Without it, "eventually consistent" is a hope, not a property — a system can be silently a little bit wrong forever if nothing is watching for it.&lt;/p&gt;

&lt;p&gt;One more small, unglamorous fact worth naming: the &lt;code&gt;Idempotency-Key&lt;/code&gt; on &lt;code&gt;POST /orders&lt;/code&gt; is scoped to the buyer, not global. The first version wasn't — a header collision between two different buyers would have returned buyer A's order to buyer B. &lt;code&gt;UNIQUE (buyer_id, idempotency_key)&lt;/code&gt; instead of &lt;code&gt;UNIQUE (idempotency_key)&lt;/code&gt; closed it. Small column, real information leak if it's missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you actually need a coordinator
&lt;/h2&gt;

&lt;p&gt;I don't think "you never need one" is true, and saying so out loud is the point of writing this down as an ADR rather than a blog opinion. A coordinator earns its cost when one business operation requires two or more services to update local state in the same instant, where neither side has a meaningful "happens now, the other happens eventually" split — the canonical case is a ledger transfer that must debit one account and credit another with no window where the money exists in neither or in both.&lt;/p&gt;

&lt;p&gt;Reservation doesn't have that shape. &lt;code&gt;try&lt;/code&gt; fully completes before the order exists at all; &lt;code&gt;confirm&lt;/code&gt;/&lt;code&gt;cancel&lt;/code&gt; have a real business reason to be delayed — waiting for a human to pay. The other trigger for a coordinator is a compensation that can't be expressed in business terms — if "give the stock back" weren't a meaningful operation, Saga-style compensation would have no move to make. Every compensation here — release a hold, cancel an order, retry a payment — is an ordinary write to ordinary data. None of that applies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Numbers
&lt;/h2&gt;

&lt;p&gt;Two buyers race for the last unit of a SKU with &lt;code&gt;available = 1&lt;/code&gt;, fired from two threads released by the same latch so they genuinely overlap rather than queue politely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Tests run: 1  -- ConcurrencyTest
  exactly 1 winner (200), exactly 1 loser (INSUFFICIENT_STOCK)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No lock taken in application code, no coordinator consulted — Postgres's own row-level locking on the conditional &lt;code&gt;UPDATE&lt;/code&gt; decides the winner.&lt;/p&gt;

&lt;p&gt;Against the running stack (&lt;code&gt;verify-m2.sh&lt;/code&gt;, not a unit test): a buyer places one order across two vendors and it becomes one &lt;code&gt;Order&lt;/code&gt; with two &lt;code&gt;SubOrder&lt;/code&gt;s, each line carrying its price and name snapshot. A second buyer tries to over-order a scarce SKU (one in stock) and gets a fast 409 with the stock count untouched. A paid order's hold converts to a deduction the moment the event is consumed — same &lt;code&gt;traceId&lt;/code&gt; visible on both sides of the Kafka hop. An unpaid order, given a 15-second timeout for the demo, gets cancelled by the sweep and its hold comes back. Four sentences, four real outcomes, no coordinator watching any of them.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is part of a series on building a multi-vendor commerce platform. The open-source half, &lt;a href="https://github.com/danzizhangdev/stallora-cloud-starter" rel="noopener noreferrer"&gt;stallora-cloud-starter&lt;/a&gt;, carries the outbox library this reservation flow is built on. Next up: three production incidents that the outbox pattern's four-sentence blog-post version doesn't warn you about.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>microservices</category>
      <category>java</category>
      <category>architecture</category>
      <category>distributed</category>
    </item>
    <item>
      <title>Your product search does not need Elasticsearch</title>
      <dc:creator>Qihu Zhang</dc:creator>
      <pubDate>Thu, 13 Aug 2026 00:55:52 +0000</pubDate>
      <link>https://dev.to/danzizhangdev/your-product-search-does-not-need-elasticsearch-1h1h</link>
      <guid>https://dev.to/danzizhangdev/your-product-search-does-not-need-elasticsearch-1h1h</guid>
      <description>&lt;p&gt;I added multi-word product search to a marketplace this week. The new infrastructure it required: one column.&lt;/p&gt;

&lt;p&gt;Not a container. Not a sync pipeline. A generated column and an index, in the migration that already existed. Search queries now go to the same database that owns the products, inside the same transaction domain, and a product is findable the instant its edit commits.&lt;/p&gt;

&lt;p&gt;I want to lay out what that actually buys you, what it costs you, and — the part I think gets skipped — why the consistency story is the strongest argument, not the resource savings everyone leads with.&lt;/p&gt;

&lt;h2&gt;
  
  
  The default answer, and what it really costs
&lt;/h2&gt;

&lt;p&gt;Ask how to add product search and the answer arrives before the question finishes: Elasticsearch. Or Meilisearch, or Typesense, if the person is being budget-conscious. All three are good at what they do. All three also mean the same three things:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Another always-on process.&lt;/strong&gt; My whole stack has a hard ceiling: it has to cold-start on a 6 GB VPS, because that is what someone running this will actually rent. Six JVMs at &lt;code&gt;-Xmx256m&lt;/code&gt;, Postgres, Redis, Kafka and tracing already claim most of it. A search engine's few hundred megabytes is not a rounding error at that scale — it is the difference between "runs on the box you have" and "buy a bigger box."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Another sync pipeline.&lt;/strong&gt; Products live in Postgres. The engine needs its own copy. Something has to move rows across: a change-data-capture stream, an outbox consumer, a cron reindex, application-level dual writes. Whatever you pick, you have written a distributed system whose only job is to make two stores agree.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Another way to be wrong.&lt;/strong&gt; That pipeline can lag, die quietly, or apply events out of order. Now "the product page shows the new price but search shows the old one" is a bug class you own forever. Ask anyone who has run a search cluster in production what fraction of incidents were the engine itself versus the thing feeding it.&lt;/p&gt;

&lt;p&gt;None of that is an argument against search engines. It is an argument for knowing what you are buying before you buy it — and for checking whether the database you already run can do the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Postgres gives you already
&lt;/h2&gt;

&lt;p&gt;Three pieces, all built in.&lt;/p&gt;

&lt;p&gt;A generated &lt;code&gt;tsvector&lt;/code&gt; column, maintained by the database rather than by your code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;          &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;slug&lt;/span&gt;        &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;160&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;        &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;240&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;-- ...&lt;/span&gt;
    &lt;span class="n"&gt;search_vec&lt;/span&gt;  &lt;span class="n"&gt;tsvector&lt;/span&gt; &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
                  &lt;span class="n"&gt;to_tsvector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="n"&gt;STORED&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_product_search&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;GIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;search_vec&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;GENERATED ALWAYS AS ... STORED&lt;/code&gt; is the important half. There is no trigger to write, no &lt;code&gt;UPDATE ... SET search_vec = ...&lt;/code&gt; to remember in every write path, no chance of an application forgetting. Insert or update a row and the search vector is already correct before the statement returns. It is not eventually consistent with the row; it is part of the row.&lt;/p&gt;

&lt;p&gt;The GIN index makes the lookup fast. And then the query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;p&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price_cents&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACTIVE'&lt;/span&gt;
   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;search_vec&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;websearch_to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;websearch_to_tsquery&lt;/code&gt; is the function to know. Its predecessor, &lt;code&gt;to_tsquery&lt;/code&gt;, demands real tsquery syntax — &lt;code&gt;wireless &amp;amp; noise &amp;amp; cancelling&lt;/code&gt; — which means you get to write a parser that turns human input into operators and escapes everything that would otherwise be a syntax error. &lt;code&gt;websearch_to_tsquery&lt;/code&gt; accepts what a search box actually receives: &lt;code&gt;wireless noise cancelling&lt;/code&gt;, quoted phrases, &lt;code&gt;-excluded&lt;/code&gt; terms. Bad input yields no results, not an exception.&lt;/p&gt;

&lt;p&gt;So a shopper types three words into a form, the string goes to the API as a parameter, and the parameter reaches &lt;code&gt;websearch_to_tsquery&lt;/code&gt;. There is no sanitising step in the middle, and no injection surface, because it never stops being a bound parameter.&lt;/p&gt;

&lt;p&gt;English stemming comes along for free: "cancelling" matches "cancel", "headphones" matches "headphone". The analyzer is a per-column choice, so a second language is a second expression, not a second system.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you give up
&lt;/h2&gt;

&lt;p&gt;Being honest about this matters more than the pitch, because the failure mode of "just use Postgres" advice is someone adopting it for a problem it does not fit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No typo tolerance.&lt;/strong&gt; "hedphones" returns nothing. Postgres has &lt;code&gt;pg_trgm&lt;/code&gt; for fuzzy matching and you can bolt similarity search on as a fallback, but it is not the same thing as a real engine's fuzziness, and combining the two into one ranked result set gets fiddly quickly. If "did you mean" is a product requirement, this is the wrong tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No faceting.&lt;/strong&gt; Counts per category, per brand, per price bucket, all computed alongside the result set — engines do this natively. In SQL each facet is another aggregate over the filtered set, which is fine for a handful of dimensions and unpleasant beyond that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Relevance tuning stops at &lt;code&gt;ts_rank&lt;/code&gt;.&lt;/strong&gt; You can weight fields and boost by recency. You cannot ship the kind of tuned scoring, synonym dictionaries and per-market rules that a mature search team lives in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scale has a ceiling.&lt;/strong&gt; For a catalog in the thousands, GIN lookups are not the slow part of your page. In the hundreds of thousands, with heavy filtering and real relevance needs, you will feel it.&lt;/p&gt;

&lt;p&gt;My catalog is small, the requirement is "find products by words in their name or description", and every one of those limits is one I can state out loud without wincing. That is the actual test.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part nobody mentions: consistency
&lt;/h2&gt;

&lt;p&gt;Here is the argument I find most convincing, and it is not about memory.&lt;/p&gt;

&lt;p&gt;With a search engine, your search index is a second copy of your data with its own clock. The write path becomes: commit to Postgres, then somehow get it to the engine. Even done well — outbox table, event stream, idempotent consumer — there is a window where the two disagree. You do not get to eliminate the window; you get to make it small and observable.&lt;/p&gt;

&lt;p&gt;With a generated column, there is no second copy and no window. The vector is computed by the same statement that writes the row, under the same transaction. Roll the transaction back and the search state rolls back with it. Restore from a backup and the index comes back consistent, because it &lt;em&gt;is&lt;/em&gt; the data. There is no reindex job, no "search is behind again" dashboard, no bootstrap procedure for a new environment.&lt;/p&gt;

&lt;p&gt;That property is worth real money in operational calm, and it is invisible in a feature comparison table. Feature tables compare fuzziness and faceting. They do not have a row for "cannot drift."&lt;/p&gt;

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

&lt;p&gt;I wrote the decision down as an ADR, and the part I care most about is the last section: what would make me change my mind. Typo-tolerant search as a stated product requirement. Faceted navigation. A catalog two orders of magnitude larger with real relevance work. Multi-language analyzers beyond what per-column configuration handles.&lt;/p&gt;

&lt;p&gt;Notice that none of those are "we grew." They are all "the requirement changed." And when one does, the blast radius is small on purpose: search is behind one endpoint, &lt;code&gt;GET /catalog/products?q=&lt;/code&gt;. Swapping the executor behind it does not touch the storefront, the mobile client, or the admin console. The contract does not know what runs underneath — which is the same reason it was cheap to start here.&lt;/p&gt;

&lt;p&gt;The pattern generalises past search. Every "you'll need X for this" reflex is worth one question: &lt;em&gt;what does the thing I already run do here, and what exactly would I be buying?&lt;/em&gt; Sometimes the answer is that you genuinely need X. Often the answer is a column.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is part of a series on building a multi-vendor commerce platform — Spring Boot services, an event-driven consistency story, and a hard rule that the whole thing cold-starts on a 6 GB box. The shared infrastructure lives in &lt;a href="https://github.com/danzizhangdev/stallora-cloud-starter" rel="noopener noreferrer"&gt;stallora-cloud-starter&lt;/a&gt; under Apache-2.0. Next up: splitting one shopper's order across several vendors without a distributed transaction coordinator.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>search</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Publishing a Spring Cloud starter to Maven Central in 2026</title>
      <dc:creator>Qihu Zhang</dc:creator>
      <pubDate>Tue, 11 Aug 2026 03:28:00 +0000</pubDate>
      <link>https://dev.to/danzizhangdev/publishing-a-spring-cloud-starter-to-maven-central-in-2026-1fp6</link>
      <guid>https://dev.to/danzizhangdev/publishing-a-spring-cloud-starter-to-maven-central-in-2026-1fp6</guid>
      <description>&lt;p&gt;Half the guides still point at a JIRA board that no longer exists.&lt;/p&gt;

&lt;p&gt;If you search for how to publish to Maven Central, you will find detailed walkthroughs telling you to open a ticket on &lt;code&gt;issues.sonatype.org&lt;/code&gt;, wait for a human to approve your group id, then deploy to OSSRH and "close" a staging repository. That flow is gone. Since the Central Portal took over new registrations, the ticket step does not exist, the endpoint is different, and the plugin is different. The old posts are not wrong so much as archaeological.&lt;/p&gt;

&lt;p&gt;Here is what the path actually looks like now, including the four places I lost time. I was publishing two small libraries — a conventions module and a transactional-outbox module — out of a Spring Cloud starter I am building in public.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four prerequisites, all of which must be true at once
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. A verified namespace.&lt;/strong&gt; In the Portal you claim a namespace rather than a group id. If you own no domain, &lt;code&gt;io.github.&amp;lt;your-github-username&amp;gt;&lt;/code&gt; is free: the Portal verifies it by checking a GitHub repository you create with a name it dictates. Mine came out as &lt;code&gt;io.github.danzizhangdev&lt;/code&gt;, and the Publishing Settings page shows it with a green &lt;strong&gt;Verified&lt;/strong&gt; badge. That badge is the thing to look for; nothing downstream works without it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A Portal user token.&lt;/strong&gt; Generate it under your account, not your password, and drop it into &lt;code&gt;~/.m2/settings.xml&lt;/code&gt; as a &lt;code&gt;&amp;lt;server&amp;gt;&lt;/code&gt; entry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. A GPG key pair, published to a keyserver.&lt;/strong&gt; Central rejects unsigned artifacts. You need the private half locally and the public half discoverable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Release plugins in the pom&lt;/strong&gt; — sources jar, javadoc jar, GPG signing, and the Portal's own publishing plugin.&lt;/p&gt;

&lt;p&gt;Each is simple. The interesting part is how they fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: &lt;code&gt;${server}&lt;/code&gt; is a placeholder, not a variable
&lt;/h2&gt;

&lt;p&gt;The Portal shows you a ready-made &lt;code&gt;settings.xml&lt;/code&gt; snippet to paste. It looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;server&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;id&amp;gt;&lt;/span&gt;${server}&lt;span class="nt"&gt;&amp;lt;/id&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;username&amp;gt;&lt;/span&gt;...&lt;span class="nt"&gt;&amp;lt;/username&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;password&amp;gt;&lt;/span&gt;...&lt;span class="nt"&gt;&amp;lt;/password&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/server&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;${server}&lt;/code&gt; is not something Maven interpolates. It is a fill-in-the-blank, and the value has to match the &lt;code&gt;publishingServerId&lt;/code&gt; of the publishing plugin — &lt;code&gt;central&lt;/code&gt; by default. Paste it verbatim and your deploy fails to find credentials, with an error that does not point anywhere near your settings file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: "different login methods are different accounts, even with the same email"
&lt;/h2&gt;

&lt;p&gt;That sentence is in Sonatype's own documentation, and it is the most expensive sentence on the page. Sign up with GitHub, come back later and sign in with email, and you are a different principal: your namespace is not there and your token belongs to someone else. The symptom is a 401 or 403 at deploy time, which reads like a credential problem, so you regenerate the token — and it happens again.&lt;/p&gt;

&lt;p&gt;The rule that follows: &lt;strong&gt;always sign in the same way you signed in when you got the verified namespace.&lt;/strong&gt; If the Namespace page ever looks empty, suspect the login method before you suspect the namespace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: &lt;code&gt;gpg --full-generate-key&lt;/code&gt; times out and never says why
&lt;/h2&gt;

&lt;p&gt;I answered every prompt — RSA, 4096, expiry, identity — watched it print "We need to generate a lot of random bytes", and then it sat there until:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gpg: agent_genkey failed: Timeout
Key generation failed: Timeout
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing in that message mentions the actual cause: gpg-agent had no way to ask me for a passphrase. I had &lt;code&gt;gnupg&lt;/code&gt; and a terminal-only &lt;code&gt;pinentry&lt;/code&gt; installed, no &lt;code&gt;~/.gnupg/gpg-agent.conf&lt;/code&gt;, and no &lt;code&gt;GPG_TTY&lt;/code&gt; exported — so the agent could neither open a dialog nor draw a prompt in the terminal, and waited until it gave up.&lt;/p&gt;

&lt;p&gt;Three fixes, and you want all three:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;pinentry-mac
&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.gnupg/gpg-agent.conf &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
pinentry-program /opt/homebrew/bin/pinentry-mac
allow-loopback-pinentry
default-cache-ttl 600
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'export GPG_TTY=$(tty)'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.zshrc     &lt;span class="c"&gt;# GnuPG asks for this explicitly&lt;/span&gt;
gpgconf &lt;span class="nt"&gt;--kill&lt;/span&gt; gpg-agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the tutorials that use &lt;code&gt;%no-protection&lt;/code&gt; (a passphraseless key in a batch file) sail straight past this. That is why so few of them mention it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 4: &lt;code&gt;dirmngr&lt;/code&gt; does not read your proxy environment variables
&lt;/h2&gt;

&lt;p&gt;Uploading the public key failed with something that looks like a network outage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gpg: sending key ... failed: No route to host
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;http_proxy&lt;/code&gt; was set and working — &lt;code&gt;curl&lt;/code&gt; was fine. But keyserver traffic goes through &lt;strong&gt;dirmngr&lt;/strong&gt;, a separate daemon that does not inherit &lt;code&gt;http_proxy&lt;/code&gt;. It needs its own file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.gnupg/dirmngr.conf &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
honor-http-proxy
http-proxy http://127.0.0.1:7897
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;gpgconf &lt;span class="nt"&gt;--kill&lt;/span&gt; dirmngr
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One more thing that looks like a failure and is not: after uploading to &lt;code&gt;keys.openpgp.org&lt;/code&gt;, fetch your key back and &lt;code&gt;gpg --show-keys&lt;/code&gt; prints &lt;em&gt;nothing&lt;/em&gt;. The key is there — that server deliberately strips every user id until you click the link in the verification email it sent. Central only needs the key material, so this does not block a release. Check &lt;code&gt;keyserver.ubuntu.com&lt;/code&gt; alongside it and you will see the same key with its identity attached.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pom side
&lt;/h2&gt;

&lt;p&gt;Two plugin groups, and one attribute that matters more than the rest.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;plugin&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.sonatype.central&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;central-publishing-maven-plugin&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;0.8.0&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;extensions&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/extensions&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;configuration&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;publishingServerId&amp;gt;&lt;/span&gt;central&lt;span class="nt"&gt;&amp;lt;/publishingServerId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;autoPublish&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/autoPublish&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;waitUntil&amp;gt;&lt;/span&gt;published&lt;span class="nt"&gt;&amp;lt;/waitUntil&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/configuration&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/plugin&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;autoPublish&lt;/code&gt; plus &lt;code&gt;waitUntil=published&lt;/code&gt; is what replaces the old "close the staging repository in the web UI" dance: the build blocks until the Portal says the deployment is live, so a green build means a real release.&lt;/p&gt;

&lt;p&gt;Signing, sources and javadoc go in a profile, so day-to-day builds stay fast:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;profile&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;id&amp;gt;&lt;/span&gt;release&lt;span class="nt"&gt;&amp;lt;/id&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;build&amp;gt;&amp;lt;plugins&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;plugin&amp;gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;maven-source-plugin&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;executions&amp;gt;&amp;lt;execution&amp;gt;&amp;lt;goals&amp;gt;&amp;lt;goal&amp;gt;&lt;/span&gt;jar-no-fork&lt;span class="nt"&gt;&amp;lt;/goal&amp;gt;&amp;lt;/goals&amp;gt;&amp;lt;/execution&amp;gt;&amp;lt;/executions&amp;gt;&amp;lt;/plugin&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;plugin&amp;gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;maven-javadoc-plugin&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;configuration&amp;gt;&amp;lt;doclint&amp;gt;&lt;/span&gt;none&lt;span class="nt"&gt;&amp;lt;/doclint&amp;gt;&amp;lt;/configuration&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;executions&amp;gt;&amp;lt;execution&amp;gt;&amp;lt;goals&amp;gt;&amp;lt;goal&amp;gt;&lt;/span&gt;jar&lt;span class="nt"&gt;&amp;lt;/goal&amp;gt;&amp;lt;/goals&amp;gt;&amp;lt;/execution&amp;gt;&amp;lt;/executions&amp;gt;&amp;lt;/plugin&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;plugin&amp;gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;maven-gpg-plugin&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;configuration&amp;gt;&amp;lt;keyname&amp;gt;&lt;/span&gt;${gpg.keyname}&lt;span class="nt"&gt;&amp;lt;/keyname&amp;gt;&amp;lt;/configuration&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;executions&amp;gt;&amp;lt;execution&amp;gt;&amp;lt;phase&amp;gt;&lt;/span&gt;verify&lt;span class="nt"&gt;&amp;lt;/phase&amp;gt;&amp;lt;goals&amp;gt;&amp;lt;goal&amp;gt;&lt;/span&gt;sign&lt;span class="nt"&gt;&amp;lt;/goal&amp;gt;&amp;lt;/goals&amp;gt;&amp;lt;/execution&amp;gt;&amp;lt;/executions&amp;gt;&amp;lt;/plugin&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/plugins&amp;gt;&amp;lt;/build&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/profile&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My repository is a multi-module build where only two modules are meant to be consumed as libraries; the three runnable services are reference implementations. Those get one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;plugin&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.sonatype.central&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;central-publishing-maven-plugin&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;configuration&amp;gt;&amp;lt;skipPublishing&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/skipPublishing&amp;gt;&amp;lt;/configuration&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/plugin&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Publishing the whole reactor by accident is easy, and unpublishing is impossible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify like you mean it
&lt;/h2&gt;

&lt;p&gt;A release is not verified until you have pulled it back from Central on a machine that could not have had it cached. The trap is your own local repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; ~/.m2/repository/io/github/danzizhangdev
mvn dependency:get &lt;span class="nt"&gt;-Dartifact&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;io.github.danzizhangdev:starter-common:0.1.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without the &lt;code&gt;rm&lt;/code&gt;, Maven answers from disk and you have proven nothing.&lt;/p&gt;

&lt;p&gt;There is a second false positive that bit me for a different reason. My network cannot reliably download Spring Boot's dependency tree straight from Central — parallel downloads get their TLS connections cut — so I resolve through a mirror. A mirror silently invalidates the check above: "pulled it from Central" becomes "pulled it from whatever the mirror had". So I keep a second settings file with no mirrors at all, purely for release verification:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mvn &lt;span class="nt"&gt;-s&lt;/span&gt; ~/.m2/settings-nomirror.xml dependency:get &lt;span class="nt"&gt;-Dartifact&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...:0.1.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mirrors affect resolution, never publication — the publishing plugin talks to the Portal API directly. But if you do not separate the two, your verification step is measuring your mirror.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell someone starting today
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Ignore anything that mentions a JIRA ticket; you want the Central Portal documentation.&lt;/li&gt;
&lt;li&gt;Do the GPG work first, with a passphrase and a GUI pinentry, before you touch the pom.&lt;/li&gt;
&lt;li&gt;Keep one login method for the Portal and write down which one it was.&lt;/li&gt;
&lt;li&gt;Treat &lt;code&gt;skipPublishing&lt;/code&gt; and a no-mirror verification profile as part of the release setup,
not as polish.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The starter itself — gateway, JWT auth with a JWKS endpoint, a transactional outbox library, and both Compose and Helm deployments of the same images — is Apache-2.0 here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/danzizhangdev/stallora-cloud-starter" rel="noopener noreferrer"&gt;https://github.com/danzizhangdev/stallora-cloud-starter&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next in this series: what the memory budget actually looks like when you measure it, and why Kafka's default heap is the first thing to pin on a small box.&lt;/p&gt;

</description>
      <category>java</category>
      <category>maven</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I split a commerce backend into 6 services for a shop with zero users. On purpose.</title>
      <dc:creator>Qihu Zhang</dc:creator>
      <pubDate>Sun, 09 Aug 2026 05:09:55 +0000</pubDate>
      <link>https://dev.to/danzizhangdev/i-split-a-commerce-backend-into-6-services-for-a-shop-with-zero-users-on-purpose-5133</link>
      <guid>https://dev.to/danzizhangdev/i-split-a-commerce-backend-into-6-services-for-a-shop-with-zero-users-on-purpose-5133</guid>
      <description>&lt;p&gt;There is a genre of blog post where someone explains that they moved off microservices and everything got better. Those posts are usually right. If you are building a product and your only goal is to ship it, a modular monolith will beat what I am about to describe on almost every measure that matters: build time, deploy time, cognitive load, your evenings.&lt;/p&gt;

&lt;p&gt;I am doing the opposite anyway, and I want to be precise about why — because "learning" is the kind of reason people give when they have not actually thought about the trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four goals, in order
&lt;/h2&gt;

&lt;p&gt;Stallora is a multi-vendor commerce platform: a Next.js storefront, a Flutter app, an admin panel, and a backend split into a gateway plus five services. I build it about ten hours a week, alone. It has four goals, and I ranked them before writing any code:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Learn how distributed systems are actually run in production.&lt;/li&gt;
&lt;li&gt;Write about it in public.&lt;/li&gt;
&lt;li&gt;Leave behind a reusable starter other people can pick up.&lt;/li&gt;
&lt;li&gt;Sell the business half as a code product.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The order is the interesting part. Goal 4 pays money and goal 1 does not, and goal 1 still wins. Any time the two conflict — a shortcut that would ship faster but hide the distributed problem — the shortcut loses. That rule is written into the repository's constraints, because six weeks from now, tired on a Wednesday night, I will want to take the shortcut and call it pragmatism.&lt;/p&gt;

&lt;p&gt;If your ranking is different, most of what follows does not apply to you. That is fine. This is not advice. It is a decision record with its reasoning exposed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you cannot learn in a monolith
&lt;/h2&gt;

&lt;p&gt;Here is the honest core of it. In a monolith there is no network between your modules. Every call either returns or throws, in-process, in your transaction. That is a feature for shipping and a problem for learning, because three things simply do not exist:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Partial failure.&lt;/strong&gt; In a monolith, &lt;code&gt;inventoryService.reserve()&lt;/code&gt; cannot half-happen. In Stallora, &lt;code&gt;order&lt;/code&gt; calls &lt;code&gt;inventory&lt;/code&gt; over HTTP, and that call can time out &lt;em&gt;after&lt;/em&gt; inventory has already committed the reservation. Now two services disagree about reality and nobody threw an exception. Every design decision downstream — synchronous reserve, compensating release, timeout sweep — exists to answer that one sentence. You cannot practise this against a method call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Messages that arrive twice.&lt;/strong&gt; Once state changes travel as events, at-least-once delivery is the default and exactly-once is a marketing term. So consumers must be idempotent: the second copy of &lt;code&gt;OrderPlaced&lt;/code&gt; has to be a no-op, not a second shipment. In-process, this problem is invisible. Over Kafka it is Tuesday.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two writes that must agree.&lt;/strong&gt; Committing a row and publishing an event are two systems. Do them in the wrong order and you either publish a fact that was rolled back, or commit a fact nobody hears about. The fix — write the event into the same database transaction as the row, then relay it — is the transactional outbox, and it only makes sense once the boundary is real.&lt;/p&gt;

&lt;p&gt;Those three constraints produce the parts of this project I actually want on my résumé: inventory reservations that fail fast under concurrency, sagas that compensate instead of a distributed lock, consumers with deduplication tables. In a monolith I would be simulating all of it, and I would know I was simulating it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 6 GB constraint is the best thing in the design
&lt;/h2&gt;

&lt;p&gt;The other reason this is not a toy: someone has to be able to run it. The business half ships to buyers who will deploy it on a cheap VPS, so the whole stack — six JVMs, a broker, a database, a cache, tracing — has to fit in about 4 GB, on a 6 GB machine, and start with one command.&lt;/p&gt;

&lt;p&gt;That single number killed more architecture than any principle did. It is why there is no service registry, no config server, no separate tracing stack of its own:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;th&gt;Instead of&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Discovery&lt;/td&gt;
&lt;td&gt;Kubernetes Service DNS / compose service names&lt;/td&gt;
&lt;td&gt;Eureka&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Configuration&lt;/td&gt;
&lt;td&gt;env vars + ConfigMap/Secret, Spring profiles&lt;/td&gt;
&lt;td&gt;Config Server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service calls&lt;/td&gt;
&lt;td&gt;Spring HTTP Interface (&lt;code&gt;@HttpExchange&lt;/code&gt;) + RestClient&lt;/td&gt;
&lt;td&gt;OpenFeign&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tracing&lt;/td&gt;
&lt;td&gt;Micrometer Tracing + OTLP → Jaeger&lt;/td&gt;
&lt;td&gt;Brave + Zipkin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Messaging&lt;/td&gt;
&lt;td&gt;Kafka (KRaft mode)&lt;/td&gt;
&lt;td&gt;RabbitMQ&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;PostgreSQL (&lt;code&gt;SKIP LOCKED&lt;/code&gt;, JSONB)&lt;/td&gt;
&lt;td&gt;MySQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consistency&lt;/td&gt;
&lt;td&gt;Saga + transactional outbox&lt;/td&gt;
&lt;td&gt;a transaction coordinator&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every row on the right is a JVM, a container, or a dependency I would have to explain to a buyer and pay for in RAM. Every row on the left is something the platform already does. The pleasant surprise is that the application ends up with &lt;em&gt;no&lt;/em&gt; deployment dependencies at all: because discovery is DNS and configuration is environment variables, the same images run under Docker Compose and under Helm on Kubernetes, with nothing but different env values. Compose stays the buyer's path; Helm is the production-shaped path. I did not have to choose.&lt;/p&gt;

&lt;p&gt;A follow-up post will show the measured numbers per container, including what Kafka does to your budget if you forget to cap its heap. (It defaults to a 1 GB heap. On a 6 GB box that is not a detail.)&lt;/p&gt;

&lt;h2&gt;
  
  
  When you should not do this
&lt;/h2&gt;

&lt;p&gt;I would not build this shape on a team with these requirements. If Stallora were a funded product with a deadline, the right call would be a modular monolith: one deployable, clean module boundaries, extract a service only when scaling or team ownership forces it. Most teams that split early pay the operational tax for years and collect none of the benefit, because their bottleneck was never the runtime — it was the deadline.&lt;/p&gt;

&lt;p&gt;The distinction I would draw is this: &lt;strong&gt;optimise for learning surface or for shipping speed, and know which one you picked.&lt;/strong&gt; A repository that optimises for learning surface should say so out loud, in a file, where future-me can be held to it. Mine says so in &lt;code&gt;docs/adr/0001-deliberately-over-decomposed.md&lt;/code&gt;, along with the sentence "This repository optimises for learning surface, not for shipping speed."&lt;/p&gt;

&lt;p&gt;If you read that as an admission of overengineering — yes. That is the word. It is deliberate, bounded by a memory budget, and documented, which is the difference between overengineering and an accident.&lt;/p&gt;

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

&lt;p&gt;The open-source half is &lt;code&gt;stallora-cloud-starter&lt;/code&gt;: gateway, JWT auth with a JWKS endpoint, a transactional outbox library, and both Docker Compose and Helm deployments of the same images. It is Apache-2.0.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/danzizhangdev/stallora-cloud-starter" rel="noopener noreferrer"&gt;https://github.com/danzizhangdev/stallora-cloud-starter&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Next post: the memory budget in detail — measured per container, and the four
infrastructure swaps that paid for themselves.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have run something like this on a small box, I would like to hear what your first OOM was. Mine has not happened yet, which mostly means I have not finished.&lt;/p&gt;

</description>
      <category>springboot</category>
      <category>microservices</category>
      <category>architecture</category>
      <category>java</category>
    </item>
  </channel>
</rss>
