Grow the new system around the old one, one vine at a time.
You're migrating a large system or subsystem and want to do it piece by piece, routing between old and new at the boundary. Unlike Branch by Abstraction (which swaps implementations behind a single interface), Strangler Fig works at the system boundary level — you're replacing entire vertical slices of functionality.
You'll recognize the need when you have a legacy module or application that needs to be rewritten, but a big-bang rewrite would take months and carry enormous risk. The pattern names itself after the strangler fig tree, which grows around its host tree, eventually replacing it entirely while the host continues to support it during the transition.
Use this technique when:
The discipline is in migrating vertically — one complete user-facing slice at a time — rather than horizontally (all the database layer first, then all the business logic). Vertical slices ship value immediately.
How this looks in your git history:
The team has a legacy AdminPanel module built with class components and direct DOM manipulation. It handles four admin pages: user management (/admin/users), role assignment (/admin/roles), audit logs (/admin/audit), and settings (/admin/settings). The team is migrating to modern React (hooks, TypeScript, server components) one page at a time.
The migration plan: first introduce an AdminRouter that delegates all traffic to the legacy panel (the strangler fig root), then migrate each admin page to a new implementation one by one, updating the router each time. After all four pages are migrated, remove the router entirely and render the new pages directly.
Create AdminRouter — a thin routing layer that sits between the app and the legacy AdminPanel. The router inspects the current path and delegates to the matching handler. Right now, all four routes map to 'legacy', which renders the original AdminPanel.
This PR is a pure no-op: all behavior is identical. The AdminPanel renders exactly as before. What we've gained is the switching point — a single place where we can redirect each route to a new implementation, one at a time, without touching any consuming code.
| line number | line content |
|---|
Create UserListPage — a modern functional component with hooks and TypeScript — and update the routing table to send /admin/users to it. The diff here shows the new component; the router change is a one-line swap of 'legacy' for the import.
The legacy AdminPanel still handles /admin/roles, /admin/audit, and /admin/settings unchanged. Users on those pages see nothing different. Users on the /admin/users page now see the new implementation. One vertical slice shipped. Three to go.
| line number | line content |
|---|
Create RoleAssignmentPage and update the routing table to send /admin/roles to it. The pattern is identical to PR 2: a new functional component, a one-line router update.
The legacy AdminPanel now handles only two routes: /admin/audit and /admin/settings. Two slices migrated, two remaining. The routing table clearly shows the migration status at a glance — comments mark the remaining legacy routes.
| line number | line content |
|---|
Create AuditLogPage and SettingsPage, and update the routing table to remove the last two 'legacy' entries. The diff here shows the AdminRouter change — the routing table now points every route to a new implementation.
The legacy AdminPanel is now unreachable. No path through AdminRouter leads to it anymore. The code still compiles; the import is still there. But no user will ever render it again. This "unreachable but present" state is intentional — it lets you keep the legacy code available as a fallback for one more PR cycle before the final cleanup.
| line number | line content |
|---|
Delete AdminPanel entirely and remove AdminRouter. The consuming page now renders each admin component directly based on the route — no indirection layer.
The diff shows src/app/admin/page.tsx before and after: the AdminRouter wrapper is replaced with a direct route-to-component map, and the legacy import is gone. The AdminPanel directory is deleted from the repo.
Clean final state: no legacy code, no routing indirection, no TODO comments. The strangler fig has fully replaced its host. Every new component is battle-tested because it has been running in production since its migration PR.
| line number | line content |
|---|
Migrating horizontally instead of vertically. It's tempting to migrate "all the database access first, then all the business logic, then all the UI" — because that seems organized. But this approach gives you nothing to ship until the very end. Horizontal migration means users get no benefit until the full rewrite is complete; any slip in one layer blocks the entire sequence. Vertical slices — one complete user-facing capability at a time — let you ship value and validate the new system in production after each PR. Migrate the /admin/users page end-to-end: new API, new logic, new UI. Then /admin/roles. Each one is done.
Underinvesting in the routing layer. A well-designed routing layer (like the ROUTES table in this example) makes each migration PR trivially small — update one entry, add one import. A poorly designed routing layer — scattered if/else chains, duplicated route strings, or a layer that leaks implementation details — creates friction that makes engineers reluctant to migrate the next slice. Spend the time in PR 1 to make the switching mechanism clean and obvious. That investment pays off across every subsequent migration PR.
Leaving the routing layer in place after migration is complete. Once all routes point to new implementations, the proxy is pure overhead: an extra layer of indirection, an extra place for bugs to hide, and dead code that future engineers will waste time understanding. Remove it in the cleanup PR (the final step). Don't let the router become a permanent fixture — the whole point was to use it as a bridge and then burn the bridge once you've crossed.