Key Insights and Implications
A Strangler Fig migration works by putting a routing proxy in front of the live monolith so that each incoming request can be sent, independently, to either the old code path or a new service — letting you retire the legacy system one capability at a time while it keeps serving traffic. That routing layer is the easy part. The insight that separates a migration that finishes from one that stalls at "80% done" for two years is this: the old and new code paths still operate on the same business data, and for the entire coexistence window you must keep that data consistent across a seam that now spans two systems. A Strangler migration is a data problem wearing a routing costume.
How the proxy actually decides
The router evaluates a rule per request — a canary percentage (route 25% of checkout traffic to the new service), a feature flag, a user-segment cohort, or a URL/header match — and forwards accordingly. This gives you a dial you can turn from 0% to 100% and, critically, back to 0%. But note what the dial does not do: flipping the proxy back to the monolith does not un-write the orders the new service already persisted. Routing is reversible; data is not. Everything hard about Strangler lives on the write path.
Worked example: strangling the Checkout capability
We extract CheckoutService from a live e-commerce monolith. The monolith writes to legacydb; the new service owns checkoutdb. During the window, the monolith's still-live "Order History" page reads legacydb, so any order the new service creates must appear there too. The tempting first cut is to have the new service write to both databases. Trace what happens to order #4021 (total ₹2,400) when the second write fails:
| Step | Operation | checkoutdb | legacydb |
|---|---|---|---|
| 1 | CheckoutService: INSERT order 4021; COMMIT | 4021 PLACED | — |
| 2 | Open connection to legacydb | 4021 PLACED | — |
| 3 | legacydb failover / network blip → INSERT times out | 4021 PLACED | — (lost) |
| 4 | Customer sees "Order placed" (served by new service) | 4021 PLACED | — |
| 5 | Support opens Order History (served by monolith) | 4021 PLACED | no such order |
The two systems now disagree permanently. The customer was charged; the monolith has no record; reconciliation is a manual ticket.
Why the naive version is wrong
Two independent commits to two datastores are not one transaction. There is no point at which "both or neither" is guaranteed — a crash, timeout, or deploy between the commits leaves the systems diverged with no automatic recovery. You cannot buy atomicity across two databases without a distributed transaction (2PC), and 2PC trades away exactly the availability the Strangler pattern exists to protect.
The fix: single writer + transactional outbox
Make checkoutdb the sole writer for the checkout capability. The order row and an outbox row are written in one local transaction (same database, real atomicity). A separate relay publishes the outbox event and a legacy-side consumer applies it idempotently. Propagation is asynchronous and at-least-once, so duplicates are expected and made harmless with an upsert.
placeOrder(order): # WRONG — dual write, no atomicity
checkoutDb.exec("INSERT INTO orders(id,total,status)
VALUES (4021, 2400, 'PLACED')") # commits
legacyDb.exec("INSERT INTO orders ... VALUES (4021, ...)") # may fail
placeOrder(order): # RIGHT — outbox in the same tx
tx = checkoutDb.begin()
tx.exec("INSERT INTO orders(id,total,status) VALUES (4021, 2400, 'PLACED')")
tx.exec("INSERT INTO outbox(event_type, payload)
VALUES ('OrderPlaced', :orderJson)")
tx.commit() # order + event committed atomically, or neither
# relay (separate process): poll unsent outbox rows,
# publish to Kafka, mark sent -- at-least-once
# legacy consumer:
# INSERT INTO orders(...) VALUES (4021, ...)
# ON CONFLICT (id) DO NOTHING -- idempotent, duplicate-safeNow re-run the failure: if the relay or consumer is down, the outbox row simply waits and is retried; a duplicate delivery hits ON CONFLICT DO NOTHING and is a no-op. The systems converge instead of diverging. This is the same mechanism as the guide's Transactional Outbox and Event-Driven Architecture pages — the Strangler window is where you first feel why you need it.
Pitfalls
- Dual-write with no atomicity — the failure traced above. If you catch yourself writing to two stores in one method, stop and add an outbox.
- Broken read-your-writes across the seam — a user updates their profile via the new service, then loads a page still served by the monolith before the sync lands (say, 400 ms of replication lag) and sees the old value. Route a given session's reads and writes to the same side during the window (sticky routing), or accept and surface eventual consistency.
- Shared database as a shortcut — pointing both systems at the same tables removes the sync problem but re-couples the schemas: now you cannot change a column without a coordinated deploy of the monolith. You added a network hop and called it a microservice. Sometimes acceptable as a transitional step; never as the destination.
- The migration that never ends — teams strangle the easy 80% and leave the tangled, high-coupling 20% running forever. Two systems in parallel is the most expensive state (double infra, double on-call, sync bugs). Budget the last mile explicitly and set a decommission date.
- Rollback loses data — flipping the proxy back to the monolith orphans everything the new store wrote during the canary. A real rollback plan needs bidirectional sync (or a replay), not just a routing flag.
When to use it — and when not
Reach for Strangler Fig when the legacy system is large enough that a rewrite would take many months, it must stay live throughout (it's revenue-critical, you can't take a maintenance window), and it has identifiable seams — bounded capabilities like "checkout" or "profile" whose data you can carve out. Concrete signals: "the monolith is 500k LOC," "we ship features weekly and can't freeze," "one bad cutover would page the whole company."
Trade-offs versus the alternatives:
- vs. Big-bang rewrite — Big-bang gives you a clean slate with no coexistence tax: no proxy, no dual-run infra, no CDC/outbox, no read-your-writes seam. You pay for it with all-or-nothing launch risk and a feature freeze during the rewrite. Choose big-bang when the system is small enough to rebuild and cut over inside a single window; choose Strangler when it isn't.
- vs. Branch-by-abstraction — If you're modularizing a monolith you intend to keep as one deployable, introduce an in-process abstraction and swap implementations behind it. No proxy, no second datastore, no eventual consistency. But it does nothing for you if the goal is to split deployment and data across services. Prefer branch-by-abstraction when the boundary is a code seam, not a service seam.
The sub-decision inside Strangler — shared database (fast to build, but schema coupling defeats the point) versus single-writer + CDC/outbox (true decoupling, but you buy eventual consistency and pipeline infra). Default to single-writer + outbox for any capability you actually intend to own; use a shared DB only as a short, dated transitional stage.
Takeaways
- The routing proxy is trivial; a Strangler migration lives or dies on data coexistence. Design the write path first.
- Never dual-write to two stores without atomicity — pick one writer per capability and propagate with an outbox/CDC (at-least-once + idempotent apply).
- Keep a session's reads and writes on the same side of the seam to preserve read-your-writes during the window.
- The parallel-running state is the most expensive one that exists — set a decommission date and drive the last 20% to done.
Re-authored and deepened for this guide. Sources: Martin Fowler, "StranglerFigApplication" (martinfowler.com, 2004); Sam Newman, Monolith to Microservices (O'Reilly, 2019), esp. the chapters on incremental decomposition and splitting the database; Chris Richardson, Microservices Patterns and microservices.io (Strangler, Transactional Outbox, and Change Data Capture patterns).
🤖 Don't fully get this? Learn it with Claude
Stuck on Key Insights and Implications? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Key Insights and Implications** (System Design) and want to truly understand it. Explain Key Insights and Implications from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Key Insights and Implications** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Key Insights and Implications** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Key Insights and Implications** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.