system-design

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:

  1. Freeze the old system.
  2. Build a new one that does everything the old one does.
  3. Switch over on a big day.

🚨 Big-bang rewrites fail at an astonishing rate, and the reasons are structural, not motivational:

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:

  1. 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.

  2. 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.

  3. 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.


What to extract first

🚨 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:

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:

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:

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


🚨 Interview traps


🎙️ Soundbites


🛠️ 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