LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Strangler Fig for Legacy Migrations: Replacing Systems Without the Big Rewrite

architecturemigrationlegacyrefactoringstrangler-fig

Every software engineer eventually faces a legacy system that needs to be replaced. The reasoning is always roughly the same: the code is twelve years old, written in a language three of the original authors don’t use anymore, running on an OS version security told us about last year, hosting critical business logic in stored procedures nobody dares edit. Everyone agrees it should be replaced. Everyone is scared to propose the project, because everyone has seen — or been part of — a multi-year rewrite that ended in a bonfire of regret.

The strangler fig pattern is the most reliable alternative. It’s named after a tropical tree that grows around a host tree, gradually taking over its role until the host is hollowed out and the strangler stands alone. Applied to software, it means building the replacement around the existing system piece by piece, routing traffic to the new implementation as each piece is ready, until the legacy has been quietly retired without anyone noticing the moment it happened.

This post is the practical playbook. It covers the routing strategies, dual-write traps, data migration patterns, how to measure progress, and the organizational choices that make the difference between a strangler that works and one that stalls at 40% and becomes its own legacy system.

Why the big rewrite fails

Big rewrites fail for predictable reasons:

  1. The “replace everything” scope freezes the business. For the duration of the rewrite — typically underestimated by 2–3× — feature development in the old system slows or stops, because any change now has to be built twice. The business pushes back.

  2. Equivalent functionality is much larger than it looks. The legacy system encodes years of edge cases, bug fixes, and implicit business rules that nobody has written down. The rewrite team doesn’t know about them until they ship and customers complain.

  3. No intermediate value. A rewrite delivers value only on the day of cutover. Before that, all cost, no benefit. Months of runway spent producing a system that isn’t running.

  4. The switchover is terrifying. One big flip means one big risk. Rollback is typically not feasible at that scale. Bugs found after cutover are discovered in production, on real users.

  5. The rewrite team learns about the domain at the same rate as the original team did. The original built their understanding over years of iteration; the rewrite team has weeks.

Joel Spolsky’s 2000 essay “Things You Should Never Do, Part I” famously indicted Netscape’s from-scratch rewrite. The pattern repeats. The strangler fig is the structured alternative.

The core idea

A strangler migration follows this shape:

  1. Put a facade in front of the legacy system. This could be an API gateway, a reverse proxy, a shared routing layer, or code in the legacy application itself. Every client request passes through the facade.
  2. Build new functionality behind the facade in the new system. The first new feature doesn’t touch the old codebase at all.
  3. Route new feature requests to the new system; route everything else to the legacy.
  4. Migrate existing functionality one piece at a time. For each piece: reimplement in the new system, shadow traffic for correctness, flip traffic when confident.
  5. Retire the legacy once no traffic flows to it.

At every point during the migration, both systems are running, serving live traffic. The business continues to get new features. Each migrated piece reduces the legacy’s footprint. The end of the migration is a non-event.

The facade: where routing happens

The facade is the most important piece. It’s the single place where you decide “does this request go to legacy or new?” Design choices matter.

API gateway (Kong, Envoy, AWS API Gateway, Nginx). Clean for HTTP-based systems. Route by path, method, header, weight. Industry standard; every organization eventually has one. Appropriate when the legacy exposes HTTP APIs.

Application-level facade. A thin service that accepts all requests, decides what to do with each, and dispatches. Gives you unlimited flexibility (transform requests, combine responses, enforce backwards compatibility). Heavier lift than a gateway; earns it when routing logic has business dimensions.

In-process facade. Modify the legacy application itself to check “should this request go to the new system?” Code that was previously in the legacy handler becomes a conditional redirect. Useful when the legacy can’t easily be bypassed (tight coupling with database, session state, etc.).

Database-level split. CDC from legacy tables; new services consume the stream and serve reads from the new system. Rare as a primary strategy but useful for read-heavy migrations where the legacy writes can continue while reads move.

For most migrations, an API gateway or reverse proxy is the right call. Low friction, easy rollback, supported by existing tooling.

Routing strategies

Inside the facade, how do you decide where traffic goes?

By endpoint. The simplest split: “anything under /v2/ goes to the new system.” Clean for migrations where whole endpoints are being replaced. The consumer (client team) has to know about the new endpoints.

By path transparent rewriting. Facade owns the URL space; legacy and new systems are internal implementation detail. Clients keep calling /orders; the facade routes to new or legacy based on internal policy. Best UX; hides the migration from clients.

By user / tenant. “Users with IDs in bucket X go to the new system.” Pairs with feature flagging. Enables canary rollouts — 1% of users, then 10%, then 100%.

By request content. “Orders with total > $10,000 still go to the legacy system for now.” Useful when the new system is incomplete. Gets tangled fast; treat as temporary.

By shadow + compare. Send traffic to both systems. Serve the legacy’s response; compare the new system’s response asynchronously; log mismatches. Essential for building confidence before cutover.

Time-based / weighted. “Send 5% of reads to the new system” — useful for early validation before shadow is set up.

Most non-trivial migrations use several of these simultaneously: shadow for correctness building, canary-by-user for rollout, and path-based for complete cutover of endpoints the new system owns.

Shadow traffic and diff testing

The pattern that prevents most regressions: shadow. For a given endpoint, send every real request to the legacy (which serves the response) and asynchronously send the same request to the new system. Compare the responses. Log differences.

Client → Facade → Legacy (response returned to client)
                ↓
                → Shadow queue → New system → Diff service → Log/alert

Properties:

  • The user never sees the new system’s response. Zero risk of incorrect behavior reaching production.
  • You get real traffic, real patterns, real edge cases. Synthetic tests miss the weird.
  • Diff logs become your work queue. Every diff is either a new-system bug or a legacy quirk you need to decide whether to preserve.

GitHub’s scientist library (and ports to other languages) does this well: run the experiment alongside the control, compare, log deltas. For larger-scale shadow, send requests via the facade into a durable queue, process asynchronously, persist diffs to a searchable store.

Cutover becomes a low-risk flip once shadow has shown equivalent behavior for weeks. Without shadow, cutover is a gamble.

Data migration: the hardest part

Routing traffic is the easy half. Data migration is the part that makes strangler migrations hard, because both systems need consistent data for the migration period.

Three main approaches:

1. Shared database

Both old and new systems read and write the same database. Simplest possible approach.

Pros:

  • No data sync. Both systems see the same state immediately.
  • Migration is purely a code/compute migration; data stays put.
  • Cutover is a code change, not a data change.

Cons:

  • New system inherits legacy schema. One of the most common reasons to migrate is because the schema is bad; shared-DB strategies perpetuate it.
  • Schema changes are coupled. Any change must work for both.
  • Database becomes a tight coupling point that’s hard to remove later.

Best when the existing schema is actually fine and the migration is about the application layer. Not great when the data model is a primary reason for the migration.

2. Parallel databases with sync

New system has its own database. A sync layer keeps them in step.

Sub-patterns:

  • Legacy writes, CDC replicates to new. New system serves reads from its own store. Writes still go to legacy. Works well for read-heavy systems where writes can remain in the legacy for longer.

  • New system is primary, legacy follows. Writes land in the new system; CDC or event stream keeps legacy updated. Used late in migration when the new system is well-proven.

  • Bidirectional sync. Hardest. Writes can happen on either side; a sync layer propagates. Requires careful conflict resolution (last-write-wins, application-level rules). Treat as a last resort.

CDC (Debezium + Kafka, or similar) is the typical tool. For each change on the source, emit an event; apply to the target. Delays are typically milliseconds; acceptable for most use cases.

3. Dual writes (the pitfall)

“Both systems write simultaneously” is the naive approach, and it’s almost always wrong.

1
2
3
4
# Don't do this
def update_order(order):
    legacy_db.update(order)
    new_db.update(order)

Failure modes:

  • Legacy succeeds, new fails → divergent state.
  • New succeeds, legacy fails → divergent state.
  • Network hiccup between the two → divergent state.

The outbox pattern or CDC-based sync is strictly better. The only time dual-write is acceptable is when both writes are within a single local transaction (e.g., same database, different tables). Otherwise, use a single source of truth and let the other follow via a reliable stream.

The contraction phase

Migrating a slice of functionality to the new system means shrinking the legacy by a proportional amount. In practice, many teams only do half of this: they build the new thing but don’t remove the old.

Delete aggressively. Once a piece of legacy is no longer serving traffic, delete it. Remove the code, drop the unused tables, retire the associated monitoring. A migration that leaves the legacy code in “just in case” never finishes — the legacy is never retired, because there’s always “one more thing” still running on it.

Measure what’s left. Keep a real list of the legacy surface area: endpoints, database tables, batch jobs, integrations. The list should shrink each month. If it doesn’t, the migration has stalled.

The migration isn’t done until the legacy is off. Lots of “successful migrations” ran for five years because the last 10% was never migrated. Either commit to finishing or decommissioning that last 10% with a different approach (freeze the legacy, containerize it, stop modifying it).

Strangler in a monolith-to-microservices migration

The classic strangler scenario: a monolithic application being replaced with a set of services. The pattern:

  1. Put the monolith behind a facade.
  2. Identify a bounded context (often via DDD analysis): ordering, inventory, user management.
  3. Build that context as a new service with its own data store.
  4. Route requests for that context to the new service; proxy through if it needs to call back into the monolith for something it doesn’t own yet.
  5. Repeat with the next bounded context.

Common mistakes:

  • Extracting too small a piece. A “microservice” that handles one endpoint and calls back into the monolith for everything else is worse than the monolith. Extract cohesive bounded contexts, not single endpoints.
  • Extracting without clear ownership. If the service has no clear owner, it becomes a shared burden and stalls.
  • Sharing the monolith’s database. The extraction isn’t complete until the new service owns its own data store. Otherwise you’ve created network-coupled code with the same logical coupling.

Sam Newman’s books (Monolith to Microservices, Building Microservices) are the standard reference here. His cautions about “distributed monolith” are exactly right: splitting a system that was tightly coupled in one process into tightly coupled services makes everything worse. Bounded-context analysis comes first.

Anti-corruption layers

When the new system talks to the legacy (which it will, for a long time), the new system should not adopt the legacy’s vocabulary. This is the anti-corruption layer (ACL): a translation boundary between the legacy’s model and the new system’s cleaner model.

Example: the legacy has a customer table where status is stored as a single-letter code (A, I, D, P, …) whose meaning nobody remembers. The new system should model statuses as named enums (Active, Inactive, Deleted, Pending). The ACL translates at the boundary.

Without an ACL, the legacy’s confused models leak into the new code. The new system “simplifies” over time but accidentally keeps the legacy’s weird assumptions. Years later you realize you’ve built the same swamp.

Concretely, the ACL lives in a specific directory (legacy/ or integrations/ in the new codebase), has its own types, and transforms inputs and outputs at the boundary. New code never imports from the ACL’s internals — only from the clean types the ACL produces.

Feature flags: the migration’s best friend

Feature flags let you decouple deployment from rollout. Ship the new code, flag it off; enable for 1% of users, watch telemetry; ramp to 10%, 50%, 100%. If something’s wrong, flip back without a redeploy.

Essential migration uses:

  • Route flags — “use new system” as a per-request flag based on user ID, tenant, geography.
  • Kill switches — one switch per new feature. If it misbehaves, flip off.
  • Validation flags — “run both systems, compare, log” vs “route only to new.”
  • Read/write split flags — “reads from new, writes to legacy” vs “both to new.”

Keep the number of flags manageable. Flags that live forever turn into configuration bugs. Once a migration’s flag has been at 100% for a month without issue, delete the flag path.

LaunchDarkly, Unleash, or a simple in-house feature-flag table — all work. What matters is that flags are first-class in the code and runtime-toggleable.

Measuring progress

A migration without measurable progress becomes a morale sink and eventually a project that gets quietly cancelled. Define metrics up front:

  • % of traffic on the new system — the primary metric. Should climb weekly/monthly.
  • Number of endpoints still on legacy — inventory that should shrink.
  • Lines of legacy code — a rough proxy; shouldn’t be the only measure.
  • Database tables only the legacy touches — ultimate signal. When this hits zero, the legacy is out.
  • Defect rate in new vs legacy — watch this. If new-system defects outpace legacy’s, you’re trading one problem for another.
  • Developer satisfaction on each side — softer metric, real signal. If everyone dreads working on the new system too, something is off.

Publish these. Every migration I’ve seen succeed had a weekly or monthly update to the broader org. Every one that stalled went quiet and then died.

Organizational patterns

Technology alone doesn’t complete a strangler migration. Org design matters:

One team owns the migration end-to-end. A team that builds new features alongside legacy-to-new migration has conflicting priorities. Form a dedicated migration team (or give an existing team the mandate) with explicit goals and freedom to defer feature work.

The legacy team is involved. The people who wrote the legacy know the edge cases. They’re either on the migration team or actively consulted. Ignoring them produces regressions.

Leadership commitment is durable, not enthusiastic. Enthusiastic support at kickoff is worthless if pressure to ship new features six months in makes the migration an afterthought. Get commitment to the migration as a multi-quarter effort.

Celebrate each extraction. The last endpoint off the legacy monolith is a real milestone. Make it visible — internal blog post, public announcement if external-facing. These moments sustain the work.

Common strangler anti-patterns

Failure modes I’ve seen repeatedly:

The forever facade. Facade is in place; new system exists; almost nothing has actually migrated. The facade adds a hop of latency and an extra deployment artifact, for no business gain. Either commit to migrating quickly or remove the facade.

The backport pattern. Bugs fixed in the new system have to also be fixed in the legacy, because the legacy is still running. Duplicate work escalates. Mitigation: keep the migration moving fast so the duplicate-fix period is short.

The last mile. 80% migrated in a year; the last 20% takes another three years. The last bits are always the messiest — edge cases, low-volume endpoints, integrations with external systems that can’t easily be changed. Two options: buckle down and actually finish, or formally freeze the legacy at a stable configuration and treat it as “not changing, here forever, don’t touch.”

Scope creep via modernization. “While we’re rewriting this, let’s also switch languages / frameworks / databases / cloud providers.” Each additional change multiplies risk. Strangle one thing at a time.

The parallel universe. New system diverges from legacy semantically — not just in code but in behavior. Cutover breaks customers because the new system does the same thing slightly differently. Shadow + diff testing catches this; skipping that step doesn’t.

What the happy end looks like

A successful strangler migration ends quietly:

  • The last traffic flows off the legacy on a random Tuesday.
  • A celebratory post in the team channel.
  • The legacy deployment is kept running for a month “just in case,” then decommissioned.
  • The legacy repository is archived.
  • Six months later, nobody remembers exactly when it was retired.

Contrast with the big-rewrite ending: a cutover weekend, war rooms, rollbacks, angry customers, blog posts titled “What We Learned.”

The strangler’s superpower is that it turns the migration into ordinary work. Each increment is small, measurable, and reversible. Risk is distributed across months rather than concentrated into a weekend. Teams learn the new system while still delivering value. The business sees continuous improvement rather than a multi-year “quiet period.”

A pragmatic playbook

If you’re starting a strangler migration:

  1. Document the legacy’s surface area. All endpoints, tables, jobs, integrations. You can’t migrate what you don’t know about.
  2. Define the target architecture. Not in infinite detail, but enough that “is this piece done?” has a clear answer.
  3. Stand up the facade. Route 100% to legacy initially; verify no behavior changes.
  4. Pick the first extraction carefully. Something moderately important, well-understood, with clear boundaries. Not the scariest piece; not the most trivial.
  5. Build, shadow, validate, flip. This is the loop for every extraction. Don’t skip shadow; don’t skip validation.
  6. Track progress visibly. Weekly or monthly updates. Percent of traffic, endpoints remaining, data fully-owned.
  7. Delete aggressively. Each completed extraction ends with removal of the legacy code.
  8. Be ruthless about scope. “Just this one thing while we’re here” is how strangler migrations turn into big-rewrites-in-disguise.
  9. Invest in the ACL. Don’t let the legacy’s model pollute the new system.
  10. Plan for the last mile. Decide early how you’ll handle the final 10%. A frozen legacy, a hard deadline, or a commitment to finish — but don’t drift.

The strangler fig is not glamorous. It’s slower than a rewrite in the best case, and in the best case, the rewrite usually fails anyway. For most legacy migrations in most organizations, strangler is the boring, reliable, actually-finishing approach. The fact that it’s worked for twenty years across industries is the best argument for it.

Pick your legacy carefully, build your facade, and start the long steady work. Years from now, you’ll look up and realize the old system is gone. That’s the pattern doing what it’s supposed to do.

Comments