Engineering Atlas › System Design Fundamentals

Migrating legacy systems with the strangler fig

12 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

The facade

Everything starts with an interception point that all traffic passes through: a reverse proxy, API gateway, or a routing layer inside the monolith. At first it sends 100% of traffic to the legacy system. Then, route by route, it sends selected requests to the new implementation. Routing can be by path (/invoices/), by tenant, by user cohort, or by percentage, which allows canary releases of each slice. The name is Martin Fowler's, after the strangler figs of Queensland that grow around a host tree until they replace it; the 2024 version of his article makes the point that the facade and sync code are *transitional architecture, built to be thrown away, and still worth it for the earlier value and lower risk (martinfowler.com). Managed facades come and go: AWS Migration Hub Refactor Spaces, which set up API Gateway as a strangler proxy, closed to new customers on 7 November 2025 (AWS Prescriptive Guidance), so keep the routing rules in config you own.

Choosing slices

Good first slices have clear boundaries, few writes into shared tables, and visible value: a read-only catalogue page, a new reporting feature, a self-contained workflow. Bad first slices sit at the centre of the data model (core order processing) or have many hidden callers. Event storming or a dependency map of the monolith helps find seams.

Data during the transition

This is where most strangler projects get stuck. Options, roughly from easiest to hardest:

Avoid dual writes from application code into both databases; they diverge on partial failure.

Calls also cross the boundary in both directions. Unmigrated code in the monolith still calls features that have moved out, and new services still call features that have not. Put an anti-corruption layer at each crossing: inside the monolith, a small adapter (say UserServiceFacade) keeps the old interface and forwards to the new service, so existing callers do not change (AWS Prescriptive Guidance).

Verifying behaviour

Legacy systems encode years of edge cases. Before switching a route, run the new implementation in shadow mode: the facade sends real requests to both, returns the legacy response, and logs differences. Fix discrepancies until the diff rate is near zero, then ramp traffic with a percentage split. Keep the legacy path ready for instant rollback until the slice has run cleanly for a while.

Finishing

The pattern is complete only when the legacy system is decommissioned. Track remaining routes and traffic share as a burn-down. Budget for the long tail explicitly, or the organisation will run two systems forever.

Two finishing details are easy to miss. Rollback stays cheap only while the legacy tables and sync jobs still exist; once you drop them, going back means restoring objects and replaying changes, so drop them as a deliberate last step per domain. And the facade itself is usually removed at the end, with clients pointed straight at the new system, unless you keep it as an adapter for legacy clients (Azure Architecture Center).

Down the stack

Layer 1The contract

The facade promises that every request is served by exactly one primary implementation, chosen deterministically from the current routing rules, and that a user's assignment is sticky for a given rule. Changing a rule is a config change that takes effect within seconds and can be reverted just as fast. Shadowed requests never influence the response the user sees, and mirrored writes never reach production data. For data, each item has exactly one writer at any time.

Layer 2The mechanism

In NGINX, routing is location blocks with proxy_pass to different upstreams, and percentage splits use split_clients, which hashes a stable value such as a user ID with MurmurHash2 and maps hash ranges to percentages, so a user keeps the same variant (NGINX docs). The mirror directive (since 1.13.4) sends background copies of requests and ignores their responses (NGINX docs). In Envoy, a route can use weighted_clusters or a runtime_fraction, and request_mirror_policies sends a copy of each request to a shadow cluster, fire-and-forget, with the Host header suffixed with -shadow so the receiver knows; a runtime_fraction on the policy samples a share of traffic instead of all of it (Envoy docs). A comparison service collects legacy and new responses keyed by request ID, normalises fields that legitimately differ (timestamps, IDs), and records diffs. Data moves with CDC from the legacy database's redo or binary log into the new service's store. For MySQL that means reading the binlog; for Oracle, Debezium's connector uses LogMiner by default and can instead read redo logs through OpenLogReplicator or the commercial XStream API (Debezium docs).

Layer 3The machine

The facade adds a proxy hop of roughly 0.2 to 1 ms inside a data centre, mostly connection handling and parsing. Mirroring 100% of a route doubles the outbound bandwidth and upstream CPU for that route, so shadowing usually samples a percentage. CDC readers parse redo or binlog files, adding sequential read I/O and CPU on the legacy database host, which on an old, saturated machine can be the real constraint. A freshly cut-over service starts with cold caches and empty connection pools, so the first minutes at a new traffic share run slower than steady state; ramps should be gradual.

Why it's built this way

  1. Risk grows with the size of a change, so migration is cut into slices that can be shipped and reverted alone.
  2. Legacy behaviour is incompletely documented, so the new code is checked against real production traffic in shadow.
  3. Two writers for one record diverge on partial failure, so data has one owner and other copies follow via CDC.
  4. Reverting must not require a deploy of either system, so the switch lives in a separate routing layer.

Diagram

flowchart LR
    U["Users"] --> F["Routing facade"]
    F -->|"/invoices (100%)"| N1["New billing service"]
    F -->|"/reports (25% canary)"| N2["New reporting service"]
    F -->|"everything else"| L["Legacy monolith"]
    F -.->|"shadow copy"| N3["New orders service (shadow)"]
    L --> LDB[("Legacy DB")]
    LDB -->|"CDC"| NDB[("New services' stores")]
    N1 --> NDB
    N2 --> NDB

Worked example

An insurer's 15-year-old policy administration system handles quoting, policy changes, claims, and billing. A rewrite estimate came back at 3 years with no releases in between. Instead they put an NGINX-based facade in front of it.

Slice 1 is quote retrieval, read-only, 1,200 requests per second at peak. The new service reads policy data from its own Postgres, populated by CDC from the legacy Oracle database with lag under 2 seconds. In shadow mode for two weeks, the comparator finds a 0.8% mismatch rate, traced to three undocumented rounding rules and a timezone quirk. After fixes the rate is 0.01%, all explained. Traffic ramps 5%, 25%, 50%, 100% over ten days, and p95 latency drops from 900 ms to 120 ms.

Slice 2, policy changes, involves writes. The new service becomes the owner of endorsements, and a reverse sync writes summary rows back to Oracle so the legacy claims module keeps working. Eighteen months in, 85% of traffic runs on new services. The final 15% includes a rarely used reinsurance module with two users. The team decides to wrap it, not rewrite it, and schedule its retirement with a vendor product.

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Big-bang rewriteClean design, no transition complexityLong delay before value, high risk of missed behaviourSmall systems or fully understood behaviour
Strangler figIncremental value, reversible steps, early learningLong transition with two systems and syncLarge, business-critical legacy systems
Refactor in placeNo new infrastructure, keeps one systemConstrained by legacy platform and couplingThe platform is sound but the code is messy
Wrap and retainCheap, minimal riskLegacy stays foreverStable, rarely changing functionality

Pitfalls in production

Leadership lens

Check yourself

  1. Why is shadow traffic so useful when strangling a legacy system?

    Show answer

    It reveals undocumented behaviour by comparing new and legacy responses on real production requests, without exposing users to the new implementation.

  2. Why are application-level dual writes risky during migration?

    Show answer

    A failure between the two writes leaves the stores inconsistent, and there is no transaction spanning both systems, so drift accumulates silently.

  3. When is a slice truly finished?

    Show answer

    When all its traffic goes to the new service, the new service owns the data, and the legacy code and data paths for it have been removed.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →