The Architecture of the Strangler Pattern
The Strangler Fig pattern works by inserting a thin interception layer — a facade, proxy, or gateway — directly in the request path in front of the legacy system, so that individual capabilities can be re-routed to new services one at a time while every un-migrated request silently falls through to the old code, letting both systems run in parallel and the new one grow until the old is dead.
The name comes from the strangler fig, which germinates in the canopy of a host tree, sends roots down around its trunk, and eventually replaces it — the host is never felled, it is gradually superseded. Martin Fowler borrowed it to describe migrating off a monolith without the risk of a big-bang rewrite. The single most important structural fact: the facade is the seam. All traffic enters through it, so the decision of "old or new?" is made in exactly one place, per capability, and can be changed without redeploying either backend.
The facade is a router, not a gatekeeper toll-booth
Calling the facade a "gatekeeper" undersells it. Its real job is to hold a routing table keyed by capability (usually an HTTP route or an RPC method) and, for each request, answer one question: does this capability, for this specific caller, belong to the new service yet? Because the answer is data, not code, you can dial a capability from 0% to 100% of traffic live, and dial it back to 0% just as fast when the new service misbehaves. That reversibility is the whole point.
A worked migration: the orders capability
Say a monolith owns everything and we want to peel out order handling into a new service. We split the capability into orders.read and orders.write because reads are safe to canary first (worst case: a stale list) while writes carry the real risk (a lost or duplicated order). The facade routes by a stable hash of the user ID into a bucket 0..99, then compares that bucket to the rollout percentage. Same user, same bucket, every time — so a user does not flip back and forth between systems on consecutive clicks.
Here are real bucket values (SHA-1 of "orders.read:<uid>", first 8 hex digits, mod 100) and where each user lands as we ramp the read rollout:
| User ID | bucket | read @ 1% | read @ 25% | read @ 100% |
|---|---|---|---|---|
| 8123 | 23 | LEGACY | NEW | NEW |
| 55 | 34 | LEGACY | LEGACY | NEW |
| 40551 | 74 | LEGACY | LEGACY | NEW |
| 90210 | 88 | LEGACY | LEGACY | NEW |
Read this vertically: as the percentage rises, a user only ever moves left-to-right, legacy→new, and never back. A user with bucket 23 joins the canary the moment rollout crosses 23%; bucket 88 waits until nearly the end. Dropping the percentage back to 0% (a rollback) cleanly returns everyone to legacy. The ramp we actually shipped:
| Day | orders.read | orders.write | What we are watching |
|---|---|---|---|
| 0 | 1% | 0% | error rate + p99 latency on the new read path |
| 3 | 25% | 0% | data parity: new reads == legacy reads? |
| 7 | 100% | 5% | write correctness on a tiny slice |
| 14 | 100% | 100% | legacy write path now dark |
| 21 | delete the orders code from the monolith; remove the routes from the facade | ||
The router, done right
The original toy predicate — "use the new service for even user IDs" — looks fine and is quietly broken. This version is what actually ships:
import hashlib
class StranglerFacade:
"""Front door for the monolith. Routes each capability to the new
service by rollout percentage; everything else falls through
to the legacy system unchanged."""
def __init__(self, legacy, new, rollout):
self.legacy = legacy # client for the monolith
self.new = new # client for the new service
self.rollout = rollout # {"orders.read": 100, "orders.write": 5}
def _bucket(self, capability, routing_key):
# Stable 0..99 bucket: same key -> same bucket, forever.
digest = hashlib.sha1(f"{capability}:{routing_key}".encode()).hexdigest()
return int(digest[:8], 16) % 100
def _in_canary(self, capability, routing_key):
pct = self.rollout.get(capability, 0)
if pct <= 0:
return False # capability still fully legacy
if pct >= 100:
return True # fully migrated
return self._bucket(capability, routing_key) < pct
def handle(self, capability, routing_key, request):
if self._in_canary(capability, routing_key):
return self.new.dispatch(capability, request)
return self.legacy.dispatch(capability, request) # fall throughWhy the naive version is wrong. userID % 2 == 0 hard-codes a fixed 50% split you cannot ramp — there is no 1% canary and no way to reach 100%; it is 50-or-nothing. It gives you no rollback lever (the code, not a config value, decides), and because the parity of an ID is arbitrary, a user's reads and writes can land on different systems with no coherent story about which holds their truth. The hash-bucket version fixes all three: any percentage is expressible, the decision is a config value you can flip live, and it is sticky and monotonic — raising the percentage only ever moves users from legacy to new, never the reverse, so nobody's session thrashes mid-migration.
The hard part the routing hides: data consistency
Routing a request is easy. The genuinely difficult problem is that one order record now has two possible writers. While orders.write is at 5%, some orders are created by the new service and some by the monolith, and every reader must see all of them. You have three real options, in rough order of increasing safety and cost:
- Shared database. Both systems read and write the same tables. Consistency is free (there is one copy), but the new service is now coupled to the monolith's schema — you have not really separated them, and you defer the true migration. Fine as a first stage, dangerous as an end state.
- New system owns the data, syncs back via CDC. The new service gets its own store; a change-data-capture stream (e.g. Debezium off the DB log, or an outbox) replicates writes back to the legacy tables so the still-legacy reads stay correct. This is the clean target, but the sync is asynchronous — there is a replication lag window where the two stores disagree.
- Dual-write from the facade. Tempting and usually a trap: writing to both stores in one request has no atomicity, so a crash between the two writes leaves them permanently divergent. Prefer CDC or an outbox over dual-write.
Whichever you pick, the migration order must respect it: migrate reads before writes so a read-side bug is harmless, and never let a capability's writes go to the new store while a large fraction of its reads still hit the old one unless a sync keeps them in step.
Pitfalls
- The facade becomes a permanent bottleneck (and a god object). Every request now hops through it, adding a network round-trip and a single point of failure. Teams keep piling auth, rate-limiting, and business logic into it until it is a second monolith. Keep it dumb: routing and nothing else.
- The last 10% never migrates. The easy capabilities move fast; the gnarly ones — shared stored procedures, a reporting job that reads twelve tables, an undocumented cron — stall. The monolith lingers for years at 5% of traffic but 100% of the maintenance headache. Budget explicitly for finishing, and treat "delete the legacy code" as a shipped feature, not cleanup.
- Rollback that requires a redeploy is not a rollback. If flipping a capability back to legacy means shipping a build, your incident response is 20 minutes when it needs to be 20 seconds. The routing state must be live config (a flag service / config store), not baked into the binary.
- Shared mutable state across the seam. Session data, in-memory caches, and DB sequences that both systems touch cause split-brain: a user authenticated in the monolith hits the new service and appears logged out. Externalize session/state before you split the capability that reads it.
- Distributed transactions spanning both systems. A request that must update legacy and new atomically has no ACID boundary anymore. Either keep such tightly-coupled operations together on one side until both move, or redesign them as a saga.
- Data parity drift goes unmeasured. Without a shadow/compare check (send the read to both, diff the responses, alert on mismatch), you promote a subtly-wrong new service to 100% and only find out from customers.
When to use it — and when not to
Reach for the Strangler Fig when: the system is large and business-critical (you cannot afford a freeze or a risky cutover), it stays in active use during the migration, and its functionality decomposes into capabilities you can peel off one at a time behind a network boundary (HTTP routes, RPC methods). The signal is "we cannot stop the world, and the rewrite will take quarters not weeks."
Trade-offs versus the alternatives:
- vs. Big-bang rewrite. The rewrite is simpler to reason about (no dual-running, no facade, no data-sync window) and can be faster if it succeeds. What it costs is risk: a single cutover with no incremental validation and, classically, a years-long project that ships nothing until the end. Choose big-bang only when the system is small enough to rewrite in weeks and can tolerate a maintenance window; choose strangler when it is too big or too critical to cut over at once.
- vs. Branch by Abstraction. Branch-by-abstraction inserts the seam inside the codebase (an interface with old and new implementations behind it) rather than in front of it over the network. It avoids the extra network hop, the facade's latency, and the data-across-a-boundary problem — but only works when old and new run in the same process/deployable. Choose branch-by-abstraction to swap an in-process component; choose strangler when the new code is a separately deployed service.
- vs. Parallel run (dark launch). A parallel run sends traffic to both, uses the old result, and compares — maximal safety for correctness-critical logic (billing, pricing), at the cost of double the compute and a comparison harness. It is complementary: run it during a strangler canary to verify parity before you shift real traffic.
Choose strangler when incremental, reversible, capability-by-capability migration of a live distributed system is worth the price of an extra hop, a routing layer to operate, and a period of dual-running with a consistency story. Prefer a rewrite when the system is small, and branch-by-abstraction when the swap is in-process.
Takeaways
- The facade is the single seam: one per-capability routing decision, driven by live config so you can ramp to any percentage and roll back in seconds without a deploy.
- Route by a stable hash of the caller, not by a toy predicate — it must be sticky (a user does not thrash between systems) and monotonic (raising the percentage only moves users legacy→new).
- Routing is the easy 20%; the hard 80% is data consistency across two writers — migrate reads before writes, and pick shared-DB / CDC / outbox deliberately rather than dual-writing.
- Plan for the finish. Budget to migrate the ugly last 10% and to delete the dead legacy code, or you inherit two systems forever.
Sources: Martin Fowler, "StranglerFigApplication" (martinfowler.com); Sam Newman, Monolith to Microservices (O'Reilly, 2019) — chapters on the Strangler Fig, Branch by Abstraction, and Parallel Run; Microsoft Azure Architecture Center, "Strangler Fig pattern." Router code and traced bucket values authored and executed for this guide. Re-authored / Deepened for this guide.
🤖 Don't fully get this? Learn it with Claude
Stuck on The Architecture of the Strangler Pattern? 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 **The Architecture of the Strangler Pattern** (System Design) and want to truly understand it. Explain The Architecture of the Strangler Pattern 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 **The Architecture of the Strangler Pattern** 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 **The Architecture of the Strangler Pattern** 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 **The Architecture of the Strangler Pattern** 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.