The Strangler Fig: Migrating Legacy Systems
How you replace a system that’s too important to turn off and too big to rewrite: gradually, one
piece at a time, with the ability to stop at any point.
Prerequisites: Monolith vs Microservices, Zero-Downtime Migrations
Time to read: ~18 minutes
The problem
You have a ten-year-old system. It runs the business, it makes money, and it’s a mess — outdated
framework, no tests, tribal knowledge, impossible to change safely.
The tempting answer — the big-bang rewrite:
- Freeze the old system.
- Build a new one that does everything the old one does.
- Switch over on a big day.
🚨 Big-bang rewrites fail at an astonishing rate, and the reasons are structural, not
motivational:
- You never fully understand the old system. Ten years of edge cases, bug-compatible behaviour
that customers now depend on, and undocumented business rules live in that code. You will miss
some, and you’ll only find out in production.
- The old system keeps changing while you build the new one, so you’re chasing a moving target
for years.
- It’s all-or-nothing. You get zero value until the very end, so the project is under pressure to
cut corners, and if it’s cancelled at 80% you have nothing.
- The switchover day is terrifying — everything changes at once, and rollback means going back to
the frozen old system that’s now months out of date.
The name comes from the strangler fig, which grows around a host tree, gradually taking over until
the original is gone — without there ever being a moment when the tree isn’t standing.
The pattern
Incrementally replace the old system, one piece at a time, running both in parallel.
flowchart TB
subgraph Phase1["Phase 1 — façade in front"]
C1[Client] --> F1[Façade]
F1 --> L1[Legacy — everything]
end
subgraph Phase2["Phase 2 — extract one piece"]
C2[Client] --> F2[Façade]
F2 -->|most routes| L2[Legacy]
F2 -->|notifications| N2[New service]
end
subgraph Phase3["Phase 3 — most extracted"]
C3[Client] --> F3[Façade]
F3 -->|shrinking| L3[Legacy]
F3 --> N3[Several new services]
end
The three moves:
-
Put a façade in front. A proxy or routing layer intercepts all traffic. Initially it forwards
everything to the legacy system, so nothing changes. 🚨 This is the enabling step — it’s the
seam you’ll route through for the whole migration.
-
Extract one capability at a time. Build a new implementation of one bounded context. Route its
traffic to the new service. Everything else still goes to the legacy system.
-
Repeat until the legacy system is empty — or until the remaining pain isn’t worth extracting.
🚨 Stopping early is a legitimate outcome. A hybrid where 80% is new and 20% of low-change
legacy remains can be a perfectly good end state.
Why it works: value is delivered incrementally, risk is contained to one piece at a time, you can
roll back one extraction without touching the rest, and you can pause or stop whenever the business
needs to.
🚨 This ordering decision determines whether the migration succeeds, and it’s a common interview
follow-up.
Start at the edges, not the core. Extract capabilities that are:
- Low-dependency — few connections to the rest of the system. Notifications, search, reporting,
and file handling are classic first extractions.
- High-value or high-pain — something that’s changing often and painful to change in the legacy
system, so the extraction pays off immediately.
- Well-understood — you know exactly what it does, so you won’t reproduce a mystery.
Do not start with the core domain. The order-processing engine at the heart of an e-commerce
system has the most dependencies, the most edge cases, and the highest risk. Extract the peripheral
things first, learn the migration mechanics on low-risk pieces, and approach the core last — by which
point you understand the system far better and the surrounding pieces are already new.
🎙️ “I’d start with something at the edge — notifications, say — that has few dependencies and is
well understood. That lets us prove the façade and the migration process on something low-risk before
touching the order engine.”
The hard part: data
🚨 This is where strangler fig migrations actually get difficult, and where the incomplete ones
fail.
Routing requests is easy. But the new service needs data, and that data lives in the legacy
database, which the legacy system is still writing to.
Option 1 — the new service shares the legacy database.
⚠️ 🚨 This is not a real migration. You’ve built a
distributed monolith — the new service is coupled to the legacy schema,
can’t evolve independently, and you’ve added a network hop for no isolation benefit. Acceptable as a
temporary step; a trap as an end state.
Option 2 — the new service owns its data, kept in sync.
This is the real migration, and it needs bidirectional consistency during the transition:
- Reads: the new service reads from its own store.
- Writes during transition: either dual-write (write to both), or use
CDC to stream changes from the legacy database
into the new one.
- Legacy still reads the data it owns — so if the new service is the new source of truth, changes
must flow back to the legacy database too, until legacy no longer needs it.
CDC is usually the cleanest mechanism — stream the legacy database’s changes to the new service
with no changes to legacy code, and stream the new service’s changes back until legacy is retired.
→ CDC
🚨 Migrate the data ownership, not just the code. The most common half-migration is a “new
service” that still reads and writes the legacy tables. It looks done and isn’t.
Verifying you didn’t break anything
🚨 The safest strangler migrations run old and new in parallel and compare, and mentioning this
is a strong signal.
Shadow / parallel run (the “branch by abstraction” or “verify” technique):
Request → façade → legacy (serve this response)
→ new (run it too, LOG any difference, but don't serve it)
You run both implementations on real production traffic, serve the legacy response, and log every
disagreement. Each difference is a bug in the new implementation — or a piece of legacy behaviour you
didn’t know existed. Run it until the mismatch rate is zero, then flip to serving the new response.
GitHub’s “Scientist” library popularized this pattern — running the new code path alongside the
old on real traffic and comparing results, safely.
Then cut over gradually: route 1% of traffic to the new service, then 10%, then 50%, watching
error rates. This is canary deployment
applied to a migration. Keep the ability to route back instantly.
Anti-corruption at the boundary
While both systems coexist, the new services must talk to the legacy system, whose data model is
usually a poor fit. 🚨 Put an
anti-corruption layer at the boundary so
the legacy model’s concepts don’t leak into your new code:
class LegacyOrderAdapter:
def get_order(self, id) -> Order: # your clean model
raw = self.legacy.SP_GET_ORDER_REC(id) # their stored procedure
return Order(id=OrderId(raw["ORD_NO"]), ...) # translate at the seam
Without it, raw["ORD_NO"] and raw["STAT_CD"] == "A" spread through your new services, and you’ve
inherited the legacy model you were trying to escape.
When not to use it
⚖️ Strangler fig is the default for large legacy migrations, but not universally:
- The system is small. A 5,000-line app can just be rewritten. The façade overhead isn’t worth it.
- You can afford downtime and the system is simple — a scheduled cutover is simpler.
- The old and new can’t coexist — a fundamental data model change that can’t be synced
incrementally may force a harder cutover.
- The old system is genuinely disposable — no users, no data worth preserving.
But for the common case — a business-critical legacy system that can’t be turned off — it’s the
standard approach, and “we’d do a big-bang rewrite” is close to a wrong answer.
⚖️ Trade-offs
| Decision |
Gain |
Cost |
| Strangler fig |
Incremental value; contained risk; rollback per piece; can stop anytime |
Long timeline; two systems coexist; data sync complexity |
| Big-bang rewrite |
Clean slate; one system at the end |
High failure rate; no value until the end; terrifying cutover |
| Shared legacy DB (temporary) |
Fast to route requests |
Distributed monolith if made permanent |
| New service owns data + CDC |
Real independence |
Bidirectional sync during transition |
| Shadow/parallel run |
Catches bugs on real traffic, safely |
Double the compute; comparison logic to build |
In the real world
- The term and pattern come from Martin Fowler, describing a real migration where the strangler
metaphor — new growth around the old, gradual replacement — matched the strategy exactly.
- GitHub’s “Scientist” library is the reference tool for the verify step: run new and old code on
real traffic, serve the old, compare, and surface differences. They used it to refactor
business-critical permission-checking code with confidence.
- Amazon and many others migrated off monoliths this way rather than by rewriting — a façade, then
extract one service, then the next. The public accounts consistently emphasize that the data
migration, not the code, was the hard and slow part.
- Half-finished strangler migrations are common — a company extracts a few edge services, hits the
hard core-domain data-ownership problem, and stops with a permanent hybrid. Sometimes that’s the
right call; sometimes it’s a stalled project. Either way it argues for extracting the highest-value
pieces first, so the migration is worthwhile even if it stops early.
🚨 Interview traps
- Proposing a big-bang rewrite for a business-critical legacy system. Near-wrong.
- Not putting a façade in first. It’s the seam the whole migration routes through.
- Extracting the core domain first. Start at the edges.
- A “new service” that shares the legacy database. That’s a distributed monolith, not a
migration.
- Migrating code but not data ownership.
- No verification strategy. Shadow/parallel runs catch what you’d otherwise ship.
- No anti-corruption layer, so the legacy model spreads into new code.
🎙️ Soundbites
- “I’d avoid a big-bang rewrite — they fail because you never fully understand the old system and you
get no value until the end. Strangler fig instead: a façade in front, then extract one capability
at a time.”
- “Start at the edges — notifications, search, reporting — not the core order engine. Prove the
migration mechanics on something low-risk and well-understood first.”
- “The hard part is data, not code. Each new service must own its data, kept in sync with the legacy
database via CDC in both directions until legacy no longer needs it. A new service sharing the
legacy schema is a distributed monolith, not a migration.”
- “Before cutting over, I’d run the new implementation in shadow on real traffic — serve the legacy
response, log every difference. Each mismatch is a bug or a piece of legacy behaviour we didn’t
know about.”
- “A hybrid is a fine end state. If 20% of low-change legacy remains after we’ve extracted the
valuable 80%, extracting the rest may not be worth it.”
🛠️ Try it
1. Build the façade seam. Take any small app. Put a reverse proxy in front routing everything to
it. Then extract one endpoint into a new service and route just that endpoint to the new service via
the proxy. Everything else is untouched — that’s the pattern in miniature.
2. Do a shadow run. For one endpoint, call both the old and new implementations, serve the old
response, and log any difference. Deliberately introduce a subtle bug in the new one and watch the
comparison catch it. This is the technique that makes migrations safe, and doing it once makes
that concrete.
3. Migrate the data with CDC. Set up CDC from the legacy database into the new service’s store
(the CDC chapter has a Debezium setup). Confirm
the new service can serve reads from its own copy while legacy keeps writing. Then implement the
reverse flow.
4. Try to extract the wrong thing. Pick the most connected, most central capability in a codebase
and attempt to extract it. Note how many other things it touches. Compare to extracting an
edge capability. The difference is why ordering matters.
Check yourself
1. Why do big-bang rewrites fail so often?
Several structural reasons, not just poor execution. You never fully understand the old system —
years of edge cases, bug-compatible behaviour customers now depend on, and undocumented rules live in
the code, and you discover the gaps only in production. The old system keeps changing while you
build, so you chase a moving target for years. It's all-or-nothing, so you get zero value until the
very end, which creates pressure to cut corners and means a cancellation at 80% leaves you with
nothing. And the switchover is terrifying because everything changes at once, with rollback meaning
reverting to a frozen old system that's now months out of date. The strangler fig avoids all of these
by being incremental.
2. Why do you put a façade in front first, before extracting anything?
Because it creates the seam through which the entire migration is routed. Initially the façade
forwards all traffic to the legacy system, so nothing changes and there's no risk. But once it's in
place, you can redirect individual capabilities to new services one at a time, transparently to
clients — the façade decides, per request, whether it goes to legacy or new. Without this routing
layer, every extraction would require changing clients. The façade is what makes the incremental,
one-piece-at-a-time, roll-back-per-piece property possible; it's the enabling move of the whole
pattern.
3. Why start extraction at the edges rather than the core domain?
Because the core has the most dependencies, the most accumulated edge cases, and the highest business
risk — extracting it first means taking on maximum risk with minimum experience. Edge capabilities
(notifications, search, reporting, file handling) have few dependencies, are usually well understood,
and often change frequently (so the extraction pays off immediately). Starting there lets you prove
the façade, the data-sync mechanism, and the verification process on low-risk pieces, build
confidence and tooling, and shrink the legacy system's surface — so that by the time you reach the
core, you understand the system far better and much of its context has already been rebuilt.
4. Why is data the hard part, and what does "migrating the data" actually mean?
Routing requests is trivial; the difficulty is that the new service needs data that lives in the
legacy database, which the legacy system is still actively writing to. A "new service" that simply
reads and writes the legacy tables isn't a migration — it's a distributed monolith coupled to the
legacy schema, unable to evolve independently. Real migration means the new service **owns its own
data store**, with changes kept synchronized during the transition: reads from its own store, and
writes flowing bidirectionally (via dual-writing or, more cleanly, CDC) between the two databases
until legacy no longer needs the data. Migrating data ownership, not just code, is what makes the
extracted service genuinely independent — and it's where incomplete migrations stall.
5. How do you verify the new implementation matches the old before cutting over?
Run them in parallel on real production traffic — a shadow or "verify" run. The façade sends each
request to both the legacy and new implementations, serves the legacy response to the user, and logs
every difference between the two. Because it's real traffic, it exercises the actual edge cases and
undocumented behaviours that tests would miss, and because you serve the legacy result, users are
never exposed to new-code bugs. Each logged mismatch is either a bug in the new implementation or a
piece of legacy behaviour you didn't know existed. Once the mismatch rate reaches zero, you cut over
gradually — 1%, 10%, 50% of traffic to the new service — watching error rates, with instant rollback
available. GitHub's Scientist library is the canonical tool for this.
Further reading