The strangler-fig migration: how we rewrite legacy monoliths without downtime
Rewriting a legacy system is risky — but rewriting in-place, route-by-route, alongside the old one, lets you ship the new stack while the business keeps running. Here's how we've done it five times.
Aditya Kumar
Founder & Lead Engineer
Every agency has seen this: a PHP monolith, a Rails 3 app, or an Angular 1.x SPA that the client can no longer iterate on. The obvious answer — rewrite it — is rarely the right one. Big-bang rewrites fail because the team underestimates every implicit rule the legacy system has learnt over years of production use.
The strangler-fig pattern (named by Martin Fowler after the Australian tree that grows around its host) lets you incrementally replace a legacy system with a new one, route by route, while both run side-by-side in production.
Why big-bang rewrites fail
Two reasons. First, the legacy system has undocumented behaviour — edge cases, race conditions, and hard-coded workarounds that only reveal themselves under production load. A rewrite, even a faithful one, will miss half of them.
Second, business needs don't pause for the rewrite. While the team is building v2, the market moves, competitors ship, and the product manager keeps queuing up features for the legacy system — which now has to be maintained in parallel.
The strangler-fig, step-by-step
We put a proxy (or a feature-flag layer) in front of the existing app. New routes, new features, and gradually migrated routes are handled by the new service. Everything else falls through to the legacy system.
// Route /dashboard to the new Next.js app;
// everything else falls through to the legacy PHP monolith.
export function middleware(request: Request) {
const url = new URL(request.url);
const migrated = new Set(["/dashboard", "/settings"]);
if (migrated.has(url.pathname)) {
return rewriteToNewApp(request);
}
return fetch(`https://legacy.internal${url.pathname}`);
}The three hard problems
Session sharing. If the legacy system uses its own auth, the new system needs to read the same session cookie. We usually solve this by having the new app validate tokens against the legacy session store — read-only — until auth itself is migrated.
Database coupling. The new service initially reads and writes to the same database as the legacy system. This is fine — it lets you migrate UI before infrastructure. Plan a later phase to split the schema once enough routes have moved.
Observability. You need to know which system handled each request. Add a header ("X-Served-By: new" / "legacy") and chart the ratio over time. When it's 100% new, delete the old code.
What we ship first
The smallest route that has the most value: usually the login page or the main dashboard. Users see the new experience on day one, which builds internal momentum and surfaces cross-cutting issues early.
We also ship the deployment pipeline, logging, and monitoring before we ship any feature. The new system's observability story must be better than the legacy one from the first day — otherwise stakeholders can't trust it.
Enjoying the read?
Get notes like this in your inbox every month. No spam, unsubscribe anytime.