<?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: Java Only</title>
    <description>The latest articles on DEV Community by Java Only (@thinhotwp1).</description>
    <link>https://dev.to/thinhotwp1</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%2F1443649%2F6390dec5-fe93-432e-9273-5df90994ca6e.JPG</url>
      <title>DEV Community: Java Only</title>
      <link>https://dev.to/thinhotwp1</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/thinhotwp1"/>
    <language>en</language>
    <item>
      <title>How We Split a Legacy Monolith Into Microservices Without a Single Outage</title>
      <dc:creator>Java Only</dc:creator>
      <pubDate>Mon, 20 Jul 2026 15:48:34 +0000</pubDate>
      <link>https://dev.to/thinhotwp1/how-we-split-a-legacy-monolith-into-microservices-without-a-single-outage-3mfg</link>
      <guid>https://dev.to/thinhotwp1/how-we-split-a-legacy-monolith-into-microservices-without-a-single-outage-3mfg</guid>
      <description>&lt;p&gt;&lt;em&gt;8 min read · telecom provisioning platform, 30M+ subscribers&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Most companies avoid migrating their monolith for one reason: they imagine it as a single, terrifying event — months of a feature freeze, a weekend cutover, and a rollback plan that's really just hope. That fear is reasonable. A big-bang rewrite of a live system serving 30M+ subscribers really would be terrifying.&lt;/p&gt;

&lt;p&gt;So we didn't do that. We used a pattern that lets you migrate a monolith one slice at a time, while the system keeps running and the product team keeps shipping features — the same pattern Martin Fowler named &lt;strong&gt;Strangler Fig&lt;/strong&gt;, after the vine that grows around a host tree, gradually taking over, until eventually the original tree is no longer needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the "just rewrite it" instinct is usually wrong
&lt;/h2&gt;

&lt;p&gt;The instinct to rewrite a legacy monolith from scratch is understandable — the old code is scary, undocumented, and nobody wants to touch it. But a full rewrite has a well-known failure pattern: it takes far longer than estimated, the business can't freeze feature development for that long, and by the time the rewrite is "done," the old system has changed underneath it and the rewrite is already out of date.&lt;/p&gt;

&lt;p&gt;The alternative isn't "don't migrate." It's: &lt;strong&gt;migrate in slices small enough that each one is boring&lt;/strong&gt;, and never require the business to stop shipping while you do it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern: a facade, and one slice at a time
&lt;/h2&gt;

&lt;p&gt;Strangler Fig works by putting a routing layer — a facade or API gateway — in front of the monolith. At first, 100% of traffic passes through to the old system untouched. Then, one capability at a time:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Build the new version of that one capability as an independent service.&lt;/li&gt;
&lt;li&gt;Update the facade to route just that capability's traffic to the new service.&lt;/li&gt;
&lt;li&gt;Run both in parallel long enough to trust the new one (see "shadow traffic" below).&lt;/li&gt;
&lt;li&gt;Retire that piece of the old monolith. Repeat for the next capability.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;At every point in this process, the system is fully functional. There's no "half-migrated and broken" state — there's only "some capabilities on the new architecture, some still on the old one," which is a perfectly stable place to be for months if needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking what to peel off first
&lt;/h2&gt;

&lt;p&gt;This is the decision that determines whether the whole migration feels safe or risky: &lt;strong&gt;start with the slice that's high-value but low-risk&lt;/strong&gt;, not the scariest, most business-critical piece. A good first slice is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reasonably self-contained (doesn't touch five other modules)&lt;/li&gt;
&lt;li&gt;Has a clear, testable boundary (you can tell if the new version is behaving correctly)&lt;/li&gt;
&lt;li&gt;Matters enough that finishing it proves the pattern works — but isn't the piece that takes down the whole business if something goes wrong&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Getting one real slice fully migrated — end to end, safely — does more for organizational confidence than any planning document. After the first slice, the second one is always easier, because now there's a proven playbook instead of a theory.&lt;/p&gt;

&lt;h2&gt;
  
  
  The detail most teams skip: an anti-corruption layer
&lt;/h2&gt;

&lt;p&gt;When the new service needs to talk to parts of the monolith that haven't been migrated yet, it's tempting to just reach into the old database directly. Don't. That recreates the exact coupling you're trying to get away from, and now the "new" service is just as entangled as the old one.&lt;/p&gt;

&lt;p&gt;Instead, use an &lt;strong&gt;anti-corruption layer&lt;/strong&gt; — a thin translation layer that lets the new service talk to the monolith through a clean interface, without either side needing to understand the other's internal model. It's a bit more upfront work. It's the difference between a migration that gets easier over time and one that gets more tangled with every slice.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to know it's safe to cut over: shadow traffic
&lt;/h2&gt;

&lt;p&gt;Before fully switching a capability to the new service, run both versions in parallel: real production traffic hits the old path (which still returns the actual response), while the same traffic is also sent to the new service in "shadow" mode — its output is logged and compared, but never returned to the user. If the new service produces the same result as the old one across thousands of real requests, you've earned the confidence to cut over for real. If it doesn't, you find out with zero customer impact.&lt;/p&gt;

&lt;p&gt;This is the single biggest reason this approach avoids outages: you're never trusting a new implementation on faith. You're trusting it because you already watched it agree with the old one thousands of times.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The cutover itself should be the least interesting part of the entire migration — because by the time you do it, you already know what's going to happen.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Sequencing: what actually happened, wave by wave
&lt;/h2&gt;

&lt;p&gt;In practice, this played out as a small number of clearly-scoped waves rather than one continuous effort:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wave 0 — foundations.&lt;/strong&gt; Facade/gateway in place, observability wired up so we could actually see what "correct" looked like before changing anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wave 1 — first slice.&lt;/strong&gt; The self-contained, provable capability. Shadow traffic, then cutover. This is where the team builds real confidence, not theoretical confidence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wave 2+ — the rest, in priority order.&lt;/strong&gt; Each subsequent slice reuses the same playbook: build, shadow, compare, cut over, retire the old path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Final step — decommission.&lt;/strong&gt; Once every capability has moved, the facade has nothing left to route to the old monolith, and it's retired for good.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nowhere in that sequence is there a step called "freeze feature development while we rewrite everything." The product team kept shipping the entire time, because from their point of view, nothing was frozen — just steadily, quietly getting replaced underneath them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone starting this today
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your first slice is a confidence-building exercise, not just a technical one.&lt;/strong&gt; Pick one that can actually succeed cleanly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never let a new service reach directly into the old system's data.&lt;/strong&gt; The anti-corruption layer is what keeps the migration from re-creating the same mess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shadow traffic isn't optional if you want zero-downtime confidence.&lt;/strong&gt; Watching the new path agree with the old one, on real traffic, is what makes cutover boring instead of scary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A migration that requires freezing the product roadmap is a sign the slices are too big.&lt;/strong&gt; Break it down further.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The business case for this isn't just "safer engineering." It's that your team never has to choose between modernizing the system and shipping the roadmap — because with this pattern, you were never actually choosing between the two.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Full post with more detail: [&lt;a href="https://thinh-cloud.netlify.app/blog/strangler-fig-migration" rel="noopener noreferrer"&gt;https://thinh-cloud.netlify.app/blog/strangler-fig-migration&lt;/a&gt;]&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Book a 20-min call: &lt;a href="https://cal.com/thinh-le-jvm/20mins" rel="noopener noreferrer"&gt;https://cal.com/thinh-le-jvm/20mins&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>microservices</category>
      <category>systemdesign</category>
      <category>techleadership</category>
      <category>architecture</category>
    </item>
    <item>
      <title>We fixed a hidden bug in a system processing 10M+ messages a day.</title>
      <dc:creator>Java Only</dc:creator>
      <pubDate>Sun, 19 Jul 2026 16:03:31 +0000</pubDate>
      <link>https://dev.to/thinhotwp1/we-fixed-a-hidden-bug-in-a-system-processing-10m-messages-a-day-445</link>
      <guid>https://dev.to/thinhotwp1/we-fixed-a-hidden-bug-in-a-system-processing-10m-messages-a-day-445</guid>
      <description>&lt;p&gt;&lt;em&gt;9 min read · warehouse execution system, global retail &amp;amp; manufacturing&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;A few years ago I worked on a warehouse execution system (WES) — the software that tells a warehouse what to pick, pack, and ship next, in real time, for retail and manufacturing clients moving somewhere between 5 and 10 million requests a day. Orders come in, inventory gets allocated, pick tasks get generated, shipments get confirmed — and every one of those steps talks to the others through messages.&lt;/p&gt;

&lt;p&gt;The system ran on ActiveMQ. It had worked for years. But at that volume, two problems had stopped being rare edge cases and started being a weekly occurrence: &lt;strong&gt;duplicate order processing&lt;/strong&gt;, and &lt;strong&gt;failures nobody noticed until a customer did&lt;/strong&gt;. This post is about what we actually changed — not just "we moved to NATS," but the specific patterns that made the new system trustworthy instead of just newer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem wasn't really the broker
&lt;/h2&gt;

&lt;p&gt;It's tempting to blame ActiveMQ itself. That's not quite fair. The real problem was what had been built &lt;em&gt;around&lt;/em&gt; it over the years: a direct, synchronous-feeling coupling between "write to the database" and "publish a message," with no shared transaction between the two. At low volume, that gap almost never showed up. At 5–10M messages a day, it showed up constantly, in two specific ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dual-write inconsistency.&lt;/strong&gt; A service would commit an order update to the database, then publish an event. If the process crashed between those two steps — or the publish failed silently — the database and the message stream disagreed about what had happened. Sometimes an event fired twice for the same order. Sometimes it didn't fire at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No systematic way to detect "this already happened."&lt;/strong&gt; Consumers processed whatever arrived. If a message got redelivered — which happens by design in most messaging systems, because "at-least-once" is the realistic guarantee, not "exactly-once" — the consumer had no way to know it was seeing the same order for the second time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Put together, that's how you get an order picked twice, or a shipment confirmation that silently vanishes. Nobody had done anything "wrong" — the system had just been built for a lower-stakes version of itself, and it hadn't been revisited as the stakes went up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why NATS, specifically
&lt;/h3&gt;

&lt;p&gt;NATS JetStream wasn't chosen because ActiveMQ is "bad" — it was chosen because the operational model fit this specific problem better: lighter-weight persistence, simpler clustering, and a consumer model that made explicit acknowledgment and replay straightforward to reason about. That mattered less than what we built &lt;em&gt;on top of&lt;/em&gt; it — but it's worth saying plainly: the broker swap was the easy 20% of this project. The patterns below were the other 80%.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 1 — Outbox, so the database and the message stream can't disagree
&lt;/h2&gt;

&lt;p&gt;The fix for dual-write inconsistency is the Outbox pattern: instead of writing to the database &lt;em&gt;and then&lt;/em&gt; publishing a message as two separate operations, you write the business change and a row representing "an event needs to be published" in the &lt;em&gt;same database transaction&lt;/em&gt;. A separate relay process reads unpublished rows and publishes them, then marks them published.&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;outbox_event&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;            &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;aggregate_id&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;64&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;event_type&lt;/span&gt;    &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;payload&lt;/span&gt;       &lt;span class="n"&gt;JSONB&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;    &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;published_at&lt;/span&gt;  &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This sounds almost too simple to matter. It matters a lot. It moves the "did this actually happen" question from "check two systems and hope they agree" to "check one row in one transaction." That single change removed an entire category of incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 2 — Idempotent receivers, because redelivery is normal, not a bug
&lt;/h2&gt;

&lt;p&gt;Once you accept that messages can and will be delivered more than once — which is the honest default for almost any distributed messaging system — the consumer has to be able to answer "have I already handled this?" before doing anything with side effects.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderEvent&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;)&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;processedEventRepo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;exists&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getEventId&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="o"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// already handled — safe to ignore&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;applyOrderUpdate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;processedEventRepo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;markProcessed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getEventId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mechanics here are almost boring — a table of processed event IDs, checked before any state change, updated in the same transaction as the change itself. What isn't boring is how much it changes the character of incidents: instead of "why did this order get picked twice," the failure mode becomes "a message was redelivered and correctly ignored," which is invisible to the business and shows up as a log line instead of a support ticket.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 3 — Saga, for the things no single transaction can cover
&lt;/h2&gt;

&lt;p&gt;Order processing touched inventory, picking, and shipment — three services, three databases, no single transaction spanning all of them. That's exactly the situation the Saga pattern exists for: model the multi-step process as a sequence of local transactions, each with a defined compensating action if a later step fails.&lt;/p&gt;

&lt;p&gt;Concretely: if inventory allocation succeeds but pick-task generation fails, the saga triggers a compensating "release allocation" step rather than leaving the system in a half-committed state. None of this is exotic — it's the standard answer to distributed consistency without two-phase commit — but it only works if step 1 (idempotency) and step 2 (Outbox) are already solid. Saga compensations that fire against a system that can't tell "processed" from "not yet processed" just create a second category of duplicate work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 4 — Retries with backoff, and a dead-letter queue with an actual runbook
&lt;/h2&gt;

&lt;p&gt;The last piece was making failure explicit and bounded, instead of infinite or silent.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;consumer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;durable_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;order-service&lt;/span&gt;
  &lt;span class="na"&gt;ack_policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;explicit&lt;/span&gt;
  &lt;span class="na"&gt;max_deliver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
  &lt;span class="na"&gt;backoff&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1s"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5s"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;30s"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2m"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10m"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;dead_letter_subject&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders.dlq"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A message gets a bounded number of attempts with increasing backoff. If it still fails, it goes to a dead-letter subject — and this is the part that's easy to skip and expensive to skip: a dead-letter queue that nobody monitors is just a slower, quieter way of losing messages. We paired the DLQ with an actual on-call runbook: what triage looks like, who gets paged, and what "safe to replay" versus "needs a human" looks like for each event type.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before → After, in short:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Before:&lt;/strong&gt; App writes to DB, then separately publishes to ActiveMQ (two uncoordinated steps) → consumer processes whatever arrives, with no way to detect a repeat → failures are silent until someone downstream notices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After:&lt;/strong&gt; App writes to DB and an Outbox row in one transaction → a relay publishes to NATS JetStream → an idempotent consumer checks a processed-events table before writing → failures beyond the retry budget go to a dead-letter subject with an actual runbook attached.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;(There's a diagram of this in the full post on my site — link at the bottom.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrating without a big-bang cutover
&lt;/h2&gt;

&lt;p&gt;None of this shipped as one release. The order that mattered:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Outbox first, on the existing ActiveMQ setup.&lt;/strong&gt; This alone fixed the dual-write problem before touching the broker at all, and proved the pattern under real load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotent receivers next,&lt;/strong&gt; also still on ActiveMQ. This removed the duplicate-processing risk independently of the broker migration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NATS introduced consumer-by-consumer,&lt;/strong&gt; running in parallel with ActiveMQ for a transition window, comparing outputs before cutting each consumer over.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ActiveMQ retired last,&lt;/strong&gt; once every consumer had been running clean on NATS for long enough to trust it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Doing it in that order meant every step was independently safe to roll back, and the riskiest-sounding part of the project — "replace the message broker" — ended up being the calmest step, because the parts that actually caused incidents had already been fixed before the broker changed at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone starting this today
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency isn't optional at this point of the process&lt;/strong&gt; — it's the thing that turns "redelivery" from an incident into a no-op.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix the dual-write problem before you touch the broker.&lt;/strong&gt; Outbox is boring, unglamorous, and it's the highest-leverage change on this list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dead-letter queue without a runbook is a to-do list nobody reads.&lt;/strong&gt; Decide who gets paged and what "replay" means before you need it at 2am.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migrate the parts that reduce risk before the part that looks risky.&lt;/strong&gt; The broker swap was the least stressful part of this project — because by the time it happened, the system could already tell the difference between "new" and "already handled."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your system has a similar shape — high message volume, a broker that's shown its age, or a nagging feeling that "duplicate" incidents keep coming back in slightly different clothes — this is exactly the kind of thing a short audit is good for: not a rewrite, just an honest map of where the Outbox, idempotency, and retry gaps actually are before you touch anything.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Full post with architecture diagram: &lt;a href="https://thinh-cloud.netlify.app/blog/activemq-to-nats-migration" rel="noopener noreferrer"&gt;Link post&lt;/a&gt;&lt;br&gt;
*Book a 20-min call: &lt;a href="https://cal.com/thinh-le-jvm/20mins" rel="noopener noreferrer"&gt;Book a call&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>enterprise</category>
      <category>systemdesign</category>
      <category>backendengineering</category>
      <category>techleadership</category>
    </item>
  </channel>
</rss>
