Design patternArchitecture Patterns
Strangler Fig
Put an interception layer in front of a legacy system and move routes to the replacement one at a time, until the old system has nothing left to serve and can be removed.
- Complexity
- Medium
- Seen in the wild
- Medium
- Also known as
- Strangler Application, Incremental Migration
Intent
Replace a legacy system incrementally instead of in one rewrite. An interception layer - a routing facade - sits in front of the legacy system and forwards each request either to it or to the replacement. Migration moves one route at a time: build the new handler, flip the route, watch it, move on. Like the fig that grows around a host tree until the host is gone, the new system grows around the old until nothing remains to strangle.
Forces
- Rewrite risk vs legacy drag: a big-bang rewrite bets the whole system on one cutover; incremental migration bets one route at a time.
- Two systems vs one truth: during migration both systems serve traffic and may hold data - every shared concept needs an owner and a sync story.
- Completion vs eternal coexistence: without a per-route migration plan and an end state, the strangler becomes permanent infrastructure and the legacy system never dies.
- New freedom vs old constraints: the replacement can redesign freely only where the facade absorbs the difference; leaked legacy shapes become the new system's architecture.
Therefore
Route all traffic through one interception layer, replace handlers one route at a time, sync or migrate the data each route needs, and delete the legacy handler when its last route moves - with the route table as the migration's public progress bar.
The facade is the whole trick: it makes the cutover a routing change instead of a deployment, and it makes rollback a routing change back. Each moved route must also settle its data - either the new system owns the data for that route from the flip, or reads from the old store until it does not (Transactional Outbox carries changes across while both stores live).
Structure
Diagram source (Mermaid)
flowchart TD A[Incoming request] --> B[Routing facade] B -- /orders, /users --> C[New handlers] B -- /reports, /billing --> D[Legacy system] C --> E[(New store)] D --> F[(Legacy store)] B -. migrate one route at a time .-> B
Participants
- Routing facade (
Gateway): intercepts every request and maps routes to handlers. Knows nothing about either system's internals - only the route table. - Legacy handler (
LegacySystem): the old system, untouched behind the facade. Serves exactly the routes still pointed at it. - New handlers (
NewOrders,NewUsers): the replacement's routes, deployed into the same surface as the facade - each owns its data from its flip. - Route table (
routes): the migration state. Public, reviewable, reversible per entry.
Use it when
- A legacy system must be replaced but cannot stop serving traffic - the rewrite happens beside it, not instead of it.
- The system is big enough that a big-bang cutover is untestable, and routes can be migrated independently.
- You can intercept the legacy system's entry points - a fronting proxy, a shared gateway, or the legacy app's own routing table.
- Risk must be reversible per unit of work: each route flip is a small, watchable, revertible change.
Do not use it when
- The system is small enough to rewrite and cut over in one change - the facade is migration scaffolding, and scaffolding for a shed is a shed.
- The legacy system's interface cannot be intercepted (no common entry point, clients baked to internal APIs). Fix interception first or the strangler has nothing to grow around.
- Data is so entangled that no route's data can be owned cleanly - every flip corrupts something. Untangle the data model first; the routing is the easy part.
- There is no migration endpoint: no team owns finishing it. A strangler without an owner becomes the permanent double system everyone maintains.
Application grill
Inspect first: the legacy system's entry points (routes, endpoints, consumers) where interception goes; the routing layer already in front (reverse proxy, load balancer, platform rewrites, gateway); how data is shared while both systems run (one writer or dual writes); the replacement's deploy target per extracted route.
Missing pieces: an interception layer you control: reuse the reverse proxy or platform router already in front - adding a new proxy layer for strangling is rarely justified. Per-route or per-percentage cutover control comes from feature flags or routing configuration; a flag service earns its place when cutovers must be reversible by non-developers. Each extracted route needs the smallest deploy target that serves it (a serverless function).
Ask before you build:
- Scale: what is the cutover unit - per route, per header, per traffic percentage - and does the proxy support splitting?
- Performance: do proxied routes pay an extra hop during the strangling period, and is that acceptable for months?
- Reliability: what is the rollback story per route - one flag, or a redeploy?
- Budget: two systems run in parallel for the duration - is the double-run cost (infrastructure and on-call) planned?
- Implementation: while both systems run, who writes the data of record - one writer with the other reading, or a sync job?
Implementation steps
- Put the facade in front of everything first, forwarding every route to the legacy system, and prove it serves production traffic unchanged. The flip lever must exist before the first flip.
- Inventory routes and rank them: start with one that is valuable, low-traffic, and data-light, so the first migration exercises the machinery on the cheapest possible mistake.
- Build the new handler for that route and decide its data ownership at the flip: either it owns the data from day one, or it reads legacy and you sync with an outbox until it owns them.
- Flip the route in the facade, watch its error and latency metrics against the legacy baseline, and keep the rollback as a one-line revert of the route entry.
- Handle the stragglers deliberately: deep links, API clients, scheduled jobs that hit old paths. Every alias gets its own flip or a redirect with an expiry.
- Track the route table as the migration's state - counts of legacy-served routes trend to zero, and the trend is the plan. When no route remains, delete the legacy system, not the facade.
- Keep the facade thin forever: routing and, at most, request shaping. Business logic in the facade is the legacy system growing back.
Example
A monolith's reporting and billing routes still serve; orders and users have been migrated. The facade's table is the state; flipping a route is one entry. The demo migrates /reports live and shows the legacy handler's route count dropping toward zero.
type Handler = (path: string) => string;
type Entry = { handler: Handler; owner: "legacy" | "new" };
class LegacySystem {
readonly routes = ["/orders", "/users", "/reports", "/billing"];
serve(path: string): string {
return `legacy handles ${path}`;
}
}
const newOrders = (path: string) => `new orders handler: ${path}`;
const newUsers = (path: string) => `new users handler: ${path}`;
// The facade: every request enters here; the table is the migration state.
class Gateway {
private table = new Map<string, Entry>();
constructor(legacy: LegacySystem) {
for (const route of legacy.routes)
this.table.set(route, { handler: (p) => legacy.serve(p), owner: "legacy" });
}
/** One entry flip is one migration step - and its own rollback. */
migrate(route: string, handler: Handler): void {
if (!this.table.has(route)) throw new Error(`unknown route ${route}`);
this.table.set(route, { handler, owner: "new" });
}
handle(path: string): string {
const entry = this.table.get(path);
return entry ? entry.handler(path) : "404";
}
get legacyRoutesLeft(): string[] {
return [...this.table.entries()]
.filter(([, e]) => e.owner === "legacy")
.map(([route]) => route);
}
}
const gateway = new Gateway(new LegacySystem());
gateway.migrate("/orders", newOrders);
gateway.migrate("/users", newUsers);
console.log(gateway.handle("/orders")); // new orders handler: /orders
console.log(gateway.handle("/reports")); // legacy handles /reports
console.log(gateway.legacyRoutesLeft); // ["/reports", "/billing"]
gateway.migrate("/reports", (p) => `new reports handler: ${p}`);
console.log(gateway.handle("/reports")); // new reports handler: /reports
console.log(gateway.legacyRoutesLeft); // ["/billing"]The Go example leans on pointer identity of the method value; in production the route table carries an explicit owner field ("legacy" | "new") instead of identity tricks - the demo compresses that to keep the participants visible.
Trade-offs
Gains
- Risk arrives in route-sized slices, each independently watchable and revertible.
- The legacy system keeps serving while value ships - no freeze, no big-bang cutover date.
- The route table makes migration progress public and honest: count the legacy entries.
Costs
- Two systems and two stores run simultaneously; data ownership per route is a real design task, not a checkbox.
- The facade is permanent infrastructure that must stay thin - it is also the one component that can become the new monolith.
- Stragglers (deep links, API clients, jobs) extend the tail; the last 10% of routes takes as long as the first 80%.
- The migration needs an owner with authority to delete the legacy system, or it never finishes.
Relations
- Facade: the interception layer is a facade in front of two systems - but unlike a simplifying facade it must stay thin, because its job is routing, not comprehension.
- Proxy: the facade's production position; in practice it is a reverse proxy or gateway, and per-route control is the feature that matters.
- Adapter: when a new handler must serve a legacy-shaped request during transition, an adapter at the facade absorbs the interface difference.
- Transactional Outbox: carries changes from the legacy store to the new one (or back) while a migrated route's data settles.
Agent checklist
- The facade serves all production traffic before any route flips.
- Routes migrate smallest-first, and each flip has watched metrics and a one-line rollback.
- Data ownership per route is decided at its flip - owner, or synced-until-owner, never both.
- Stragglers (aliases, clients, jobs) have their own flips or expiring redirects.
- The route table trends to zero legacy entries under a named owner.
- The facade contains no business logic, only routing and request shaping.
See also
- FacadeGive a complex subsystem one small entry point for a common task, so callers stop orchestrating its classes and calling them in the right order.
- ProxyPut a stand-in with the same interface in front of an object to control access to it: create it lazily, check permissions, or reach it remotely.
- AdapterWrap an incompatible class in an object that exposes the interface your code expects, so you can use it without changing either side.
- Transactional OutboxWrite the outgoing message in the same transaction as the business change; a relay publishes and marks it sent - the commit and the message become one fact, not two hopes.
Original writing and examples. Topic coverage follows Microsoft Azure Architecture Center, which explains this topic with its own illustrations.