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

- kind: pattern
- category: architecture
- aliases: Strangler Application, Incremental Migration
- canonical: https://aipatterns.dev/design-patterns/strangler-fig
- inspired-by: https://learn.microsoft.com/en-us/azure/architecture/patterns/strangler-fig

## 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](https://aipatterns.dev/design-patterns/transactional-outbox.md) carries changes across while both stores live).

## Structure

```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

1. 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.
2. 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.
3. 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.
4. 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.
5. 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.
6. 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.
7. 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.

```ts title="gateway.ts"
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"]
```

```python title="gateway.py"
from collections.abc import Callable

Handler = Callable[[str], str]

class LegacySystem:
    def __init__(self) -> None:
        self.routes = ["/orders", "/users", "/reports", "/billing"]

    def serve(self, path: str) -> str:
        return f"legacy handles {path}"

def new_orders(path: str) -> str:
    return f"new orders handler: {path}"

def new_users(path: str) -> str:
    return f"new users handler: {path}"

class Gateway:
    """Every request enters here; the table is the migration state."""

    def __init__(self, legacy: LegacySystem) -> None:
        self._legacy = legacy
        self._table: dict[str, Handler] = {
            route: legacy.serve for route in legacy.routes
        }

    def migrate(self, route: str, handler: Handler) -> None:
        """One entry flip is one migration step - and its own rollback."""
        if route not in self._table:
            raise ValueError(f"unknown route {route}")
        self._table[route] = handler

    def handle(self, path: str) -> str:
        handler = self._table.get(path)
        return handler(path) if handler else "404"

    @property
    def legacy_routes_left(self) -> list[str]:
        return [r for r, h in self._table.items() if h == self._legacy.serve]

legacy = LegacySystem()
gateway = Gateway(legacy)

gateway.migrate("/orders", new_orders)
gateway.migrate("/users", new_users)

print(gateway.handle("/orders"))  # new orders handler: /orders
print(gateway.handle("/reports"))  # legacy handles /reports
print(f"legacy routes left: {gateway.legacy_routes_left}")  # ['/reports', '/billing']

gateway.migrate("/reports", lambda p: f"new reports handler: {p}")
print(gateway.handle("/reports"))  # new reports handler: /reports
print(f"legacy routes left: {gateway.legacy_routes_left}")  # ['/billing']
```

```go title="gateway.go"
package main

import "fmt"

type Handler func(path string) string

type LegacySystem struct {
	routes []string
}

func (l *LegacySystem) serve(path string) string { return "legacy handles " + path }

func newOrders(path string) string { return "new orders handler: " + path }
func newUsers(path string) string  { return "new users handler: " + path }

// Gateway: every request enters here; the table is the migration state.
type Gateway struct {
	legacy *LegacySystem
	table  map[string]Handler
}

func NewGateway(legacy *LegacySystem) *Gateway {
	g := &Gateway{legacy: legacy, table: map[string]Handler{}}
	for _, route := range legacy.routes {
		g.table[route] = legacy.serve
	}
	return g
}

// Migrate flips one entry - one migration step, and its own rollback.
func (g *Gateway) Migrate(route string, handler Handler) {
	if _, ok := g.table[route]; !ok {
		panic(fmt.Sprintf("unknown route %s", route))
	}
	g.table[route] = handler
}

func (g *Gateway) Handle(path string) string {
	handler, ok := g.table[path]
	if !ok {
		return "404"
	}
	return handler(path)
}

func (g *Gateway) LegacyRoutesLeft() []string {
	var left []string
	for route, handler := range g.table {
		isLegacy := fmt.Sprintf("%p", handler) == fmt.Sprintf("%p", g.legacy.serve)
		if isLegacy {
			left = append(left, route)
		}
	}
	return left
}

func main() {
	legacy := &LegacySystem{routes: []string{"/orders", "/users", "/reports", "/billing"}}
	gateway := NewGateway(legacy)

	gateway.Migrate("/orders", newOrders)
	gateway.Migrate("/users", newUsers)

	fmt.Println(gateway.Handle("/orders"))  // new orders handler: /orders
	fmt.Println(gateway.Handle("/reports")) // legacy handles /reports

	gateway.Migrate("/reports", func(p string) string { return "new reports handler: " + p })
	fmt.Println(gateway.Handle("/reports"))          // new reports handler: /reports
	fmt.Printf("legacy routes left: %d\n", len(gateway.LegacyRoutesLeft())) // 1
}
```

```java title="Gateway.java"
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public class Gateway {
    interface Handler {
        String serve(String path);
    }

    static final class LegacySystem {
        final List<String> routes = new ArrayList<>(List.of("/orders", "/users", "/reports", "/billing"));

        String serve(String path) {
            return "legacy handles " + path;
        }
    }

    record NewOrders() implements Handler {
        public String serve(String path) {
            return "new orders handler: " + path;
        }
    }

    record NewUsers() implements Handler {
        public String serve(String path) {
            return "new users handler: " + path;
        }
    }

    private final LegacySystem legacy;
    // Every request enters here; the table is the migration state.
    private final Map<String, Handler> table = new LinkedHashMap<>();

    Gateway(LegacySystem legacy) {
        this.legacy = legacy;
        for (String route : legacy.routes) table.put(route, legacy::serve);
    }

    /** One entry flip is one migration step - and its own rollback. */
    void migrate(String route, Handler handler) {
        if (!table.containsKey(route)) throw new IllegalArgumentException("unknown route " + route);
        table.put(route, handler);
    }

    String handle(String path) {
        Handler handler = table.get(path);
        return handler == null ? "404" : handler.serve(path);
    }

    List<String> legacyRoutesLeft() {
        List<String> left = new ArrayList<>();
        for (Map.Entry<String, Handler> e : table.entrySet()) {
            if (e.getValue() instanceof LegacyServing) left.add(e.getKey());
        }
        return left;
    }

    /** Marks handlers that still route to the legacy system. */
    private interface LegacyServing {}

    static void main(String[] args) {
        LegacySystem legacy = new LegacySystem();
        Gateway gateway = new Gateway(legacy);
        // Route the legacy entries through the marker interface.
        for (String route : legacy.routes) gateway.table.put(route, marking(legacy::serve));

        gateway.migrate("/orders", new NewOrders());
        gateway.migrate("/users", new NewUsers());

        System.out.println(gateway.handle("/orders"));  // new orders handler: /orders
        System.out.println(gateway.handle("/reports")); // legacy handles /reports
        System.out.println("legacy routes left: " + gateway.legacyRoutesLeft().size()); // 2

        gateway.migrate("/reports", p -> "new reports handler: " + p);
        System.out.println(gateway.handle("/reports")); // new reports handler: /reports
    }

    private static Gateway.Handler marking(Gateway.Handler inner) {
        return new LegacyServing() {
            public String serve(String path) {
                return inner.serve(path);
            }
        };
    }
}
```

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](https://aipatterns.dev/design-patterns/facade.md): 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](https://aipatterns.dev/design-patterns/proxy.md): the facade's production position; in practice it is a reverse proxy or gateway, and per-route control is the feature that matters.
- [Adapter](https://aipatterns.dev/design-patterns/adapter.md): when a new handler must serve a legacy-shaped request during transition, an adapter at the facade absorbs the interface difference.
- [Transactional Outbox](https://aipatterns.dev/design-patterns/transactional-outbox.md): 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.
