Strangler Fig
Replacing a legacy system by rewriting it has a poor record. The Strangler Fig replaces it incrementally behind a facade — and the reason it works is organisational as much as technical.
Martin Fowler named this after the strangler figs he saw in Queensland: the fig seeds in a host tree's branches, sends roots down around the trunk, and gradually takes over until the host rots away and a hollow, self-supporting fig is left standing. The metaphor is precise in a way that matters — the fig is load-bearing before the host is gone, and at no point is there a gap where neither tree is holding anything up.
That is the whole pattern. Not "build the replacement, then switch", which is the big-bang rewrite wearing better vocabulary, but "put a facade in front, move one slice behind it, repeat, and be in production the entire time".
The problem
You have a system that works and nobody wants to touch. It carries fifteen years of business rules, some of them undocumented and a few of them load-bearing accidents. It is slow to change, expensive to run, and staffed by a shrinking group of people who understand it.
The obvious plan is to rewrite it. The obvious plan fails, and it fails in a shape so consistent it is worth naming:
- The estimate is made against the visible requirements. The invisible ones — the tax edge case for one jurisdiction, the batch job the finance team depends on, the CSV export with the byte-order mark that a partner's importer requires — surface one at a time, throughout.
- The old system does not stop moving. It must keep receiving fixes and compliance changes while the new one is built, so the target is not fixed. Every change is now made twice, or the new system falls behind and the gap grows.
- Nothing ships for a long time. No feedback, no production learning, no revenue from the work. The business sees cost and no value for quarters at a time.
- The switch is one enormous event. All the risk of eighteen months arrives on one weekend, with a rollback plan that involves restoring a database backup and losing everything since.
- It gets cancelled. Usually at 70% complete, usually after a leadership change, and now you have two systems to maintain instead of one.
The root problem is that a rewrite couples the value of the work to its completion. Nothing pays back until everything is done, so the risk compounds instead of amortising, and the project's political survival depends on nobody losing patience for longer than anyone can reasonably promise.
The Strangler Fig breaks that coupling. Each slice delivers value when it is done. The migration can be paused, reprioritised, or abandoned midway and you still keep everything already moved.
When to use it
- The legacy system is business-critical and must not stop. This is the base case. If you can afford downtime and a clean cutover, Blue-Green is simpler and you should use that instead.
- The system decomposes along seams. Routes, bounded contexts, batch jobs, screens. You need something you can move one of. A genuinely monolithic ball of mud with no separable slice will not yield to this pattern until you have found or created a seam — which is often the real first project.
- Behaviour is poorly understood. Counter-intuitively this is an argument for incremental migration, not against it. Running old and new side by side lets you compare their outputs on real traffic, which is the only reliable way to discover what the old system actually does. A rewrite has to guess.
- The migration will outlast the current plan. Twelve to thirty-six months means leadership will change, priorities will shift, and budget will be re-examined. A pattern that delivers continuously survives that; a pattern that delivers at the end does not.
- You can put a facade in front. An API gateway, a reverse proxy, a load balancer with path rules, or an adapter inside the monolith itself. Without an interception point there is nowhere to stand.
When NOT to use it
- The system is small. Under a few weeks of work, the facade, the dual-running, the parity checking and the coexistence complexity cost more than the rewrite. Rewrite it.
- You cannot intercept the traffic. A desktop client hard-coded to a specific endpoint, or an integration you do not control, removes the seam the pattern depends on.
- The old system is genuinely being retired, not replaced. If the answer is "we are switching to a SaaS product and importing the data", that is a data migration and a cutover, not a strangulation.
- Nobody will fund the last 10%. This is the pattern's characteristic failure and it deserves to be stated as bluntly as the rewrite's: once the painful 80% is migrated, the remaining slices are the awkward ones with no business sponsor, and the organisation quietly stops. You are left running both systems forever, paying for the facade, the legacy licences and the cognitive load of two mental models — which is worse than either endpoint. Do not start without a named owner for the decommission, and treat the deletion of the old system as a deliverable with a date, not as a natural consequence.
- The seams are unstable. If slices constantly need data the other side still owns, you get a distributed monolith with a proxy in front: all the coupling, plus network calls and two deployment pipelines.
Architecture
Three phases, and the middle one is where the entire cost lives.
flowchart TD
subgraph P1[Phase 1 — facade, no behaviour change]
A1[Clients] --> F1[Facade]
F1 --> L1[(Legacy)]
end
subgraph P2[Phase 2 — coexistence, months to years]
A2[Clients] --> F2[Facade]
F2 -->|/orders/*| N2[New service]
F2 -->|everything else| L2[(Legacy)]
N2 <-.->|shared or synced data| L2
end
subgraph P3[Phase 3 — decommission]
A3[Clients] --> F3[Facade]
F3 --> N3[New services]
end
Phase 1 is deliberately a no-op, and skipping it is a common mistake. Insert the facade and route everything to the legacy system, changing no behaviour at all. This proves the interception point works, exercises its performance and failure modes, and gives you a rollback of one routing rule before anything is at stake. If the facade is going to break, it should break while it is doing nothing interesting.
Phase 2 is the pattern. Move one slice. Route its traffic to the new implementation. Verify. Move the next. The order is a real decision: start with something with clear boundaries and low blast radius to build the machinery and the team's confidence, not with the highest-value slice. The second or third slice is where you take on something that matters.
Phase 3 is deletion, and it is the phase that gets skipped.
sequenceDiagram
participant C as Client
participant F as Facade
participant N as New service
participant L as Legacy
C->>F: GET /orders/42
F->>N: route (new owns orders)
F->>L: shadow copy (compare only)
N-->>F: 200 {...}
L-->>F: 200 {...}
Note over F: diff logged, legacy response discarded
F-->>C: new response
That shadow comparison is the highest-value technique in this pattern and the most frequently omitted. Send real production traffic to both, serve the legacy response, and diff the two. You discover what the old system actually does — including the parts nobody documented — against real inputs, with zero customer risk, before you trust the new path. Then flip to serving the new response, ideally as a canary on a small traffic slice first.
Data is the hard part, and the facade does nothing for it. Routing a read is easy; deciding who owns the orders table is not. Three options, in rough order of preference: the new service owns the data and the legacy reads through an API or a view; both read one shared database during coexistence, accepting the coupling as explicitly temporary; or you synchronise bidirectionally, which is where migrations go to die. Bidirectional sync means conflict resolution, ordering, and two systems that can each be authoritative — genuinely harder than the migration it is supporting. Avoid it if there is any way to avoid it, and if there is not, budget for it as its own project.
Code walkthrough
The facade's routing rule should be data, not branching logic, because the whole value proposition is changing it safely and often.
export type Destination = 'legacy' | 'modern';
export interface SliceRule {
readonly slice: string;
readonly matches: (request: IncomingRequest) => boolean;
readonly destination: Destination;
readonly shadow: boolean;
}
export function destinationFor(
request: IncomingRequest,
rules: readonly SliceRule[],
): SliceRule | null {
return rules.find((rule) => rule.matches(request)) ?? null;
}
Rules are ordered and the first match wins, so a narrow rule can be placed above a broad one to carve a single endpoint out of a slice that is otherwise still legacy — which is exactly how a migration proceeds in practice.
const rules: SliceRule[] = [
{ slice: 'orders-read', matches: (r) => r.method === 'GET' && r.path.startsWith('/orders'), destination: 'modern', shadow: false },
{ slice: 'orders-write', matches: (r) => r.path.startsWith('/orders'), destination: 'legacy', shadow: true },
{ slice: 'everything', matches: () => true, destination: 'legacy', shadow: false },
];
Reads have moved; writes are still legacy but shadowing to the new service so its behaviour can be compared before it takes traffic. That asymmetry — reads first, writes later — is the standard ordering, because a wrong read is a bug and a wrong write is a data-corruption incident.
The proxy itself stays small:
export async function handle(request: IncomingRequest, deps: FacadeDeps): Promise<Response> {
const rule = destinationFor(request, deps.rules);
if (!rule) return deps.legacy.send(request);
const primary = rule.destination === 'modern' ? deps.modern : deps.legacy;
const response = await primary.send(request);
if (rule.shadow) {
const other = rule.destination === 'modern' ? deps.legacy : deps.modern;
void deps
.compare(request, response, other)
.catch((error) => deps.log.warn('shadow comparison failed', { slice: rule.slice, error }));
}
return response;
}
Three properties this small function has to hold:
The shadow call is fire-and-forget. void plus a caught rejection. If the shadow path can add latency to the response or fail the request, you have made the migration a reliability risk — and the first outage caused by comparison machinery ends the appetite for the whole approach.
The primary is chosen once and used for the response. The comparison never influences what the customer receives. It writes to a log or a metric, and a human decides what to do about the diffs.
An unmatched request goes to legacy. The default must be the system that currently works. A facade whose default is the new implementation routes every un-migrated path into a service that does not implement it, and does so in production.
For the comparison, log a structural diff, not an equality boolean. "Mismatch on /orders/42" is not actionable; "field taxTotal differed: legacy 12.40, modern 12.39, on 3.2% of requests, all in jurisdiction DE" is a bug report. Normalise the fields that legitimately differ — timestamps, generated ids, key ordering — before diffing, or the signal drowns in noise on day one and the team stops reading it.
Performance characteristics
The facade adds one proxy hop: single-digit milliseconds within a region, and dominated entirely by geography if it is not colocated with what it fronts. This is almost never the concern that matters.
What does matter is that the facade becomes a single point of failure for the entire system on the day you introduce it, which is why Phase 1 exists. It needs the availability of what it fronts plus a margin, its own health checks, and its own capacity plan.
Coexistence has a real running cost that migration plans routinely omit: two systems deployed, two on-call rotations, two sets of dependencies to patch, and engineers holding two mental models. Shadow traffic doubles the load on the shadowed slice — plan capacity for it, and turn it off once a slice's diffs have been clean for long enough to trust.
The metric that predicts success is not code migrated but traffic migrated, per slice, over time. A migration where that curve flattens for a quarter has stalled, whatever the burndown chart says, and stalling is the normal way this pattern fails. Track the decommission explicitly too — a slice is not done when the new service serves it, it is done when the legacy code that used to serve it has been deleted. Code that still exists will still be called, eventually, by someone who did not know.
Live demo
A working facade — routing rules, shadow comparison and a diff report between two implementations of the same endpoint — is on the roadmap here. The routing model above is the load-bearing part: rules as ordered data rather than conditionals, so that moving a slice is a configuration change with a one-line rollback.
Related patterns
API Gateway is the usual facade, and the pattern is essentially unavailable without one. The gateway's routing table is the migration's state, which is worth appreciating: the list of which slices point where is the most accurate progress report the project will ever produce, and it is generated rather than reported.
Canary Release is how each slice should actually flip. Moving a slice from legacy to modern for 100% of traffic in one step reintroduces, at slice scale, exactly the big-bang risk the pattern exists to avoid. Route 1% of the slice's traffic, watch the error rate and the diffs, then widen. The two patterns compose so naturally that a strangler migration without canarying is usually a sign the deployment machinery is not ready for the migration.
Blue-Green is the alternative when you can cut over. It runs two complete environments and switches between them wholesale — simpler, faster, and entirely appropriate when the new system is complete and verified. The strangler fig is what you use when "complete and verified" is years away and the business needs value before then. Choosing blue-green for a system you do not fully understand is choosing to discover the gaps in production, all at once.
CQRS frequently emerges from a strangler migration rather than preceding it, which is why it is listed as a downstream step. Reads move first because they are safe; once reads are served by a new model and writes are still legacy, you have arrived at a read/write split by circumstance. Recognising it as CQRS at that point — and deciding deliberately whether to keep it — is better than discovering years later that the accidental architecture was never chosen.
Related patterns
- Composes with: API Gateway
- Composes with: Canary Release
- Alternative to: Blue-Green Deployment
- Prerequisite of: CQRS