← Architecture
Deploy and evolve Replace it while it runs

Strangler fig migration

The proxy decides, on evidence.

If you have moved an app from one framework to another one route at a time, or put a new service behind the same URLs as an old one, you have run a strangler fig migration. The name comes from a vine that grows around a tree until the tree is no longer needed. Let’s move a city’s parking-permit portal and watch the three routes it has, and the one database they share.

TypeScriptGoOne portal, two routers, two recorded agent runs.

01 / The prompt

“Put a proxy in front and move the routes over one by one.”

The city’s parking-permit portal runs on an app nobody wants to touch. A new service is ready for the same three routes: the list of zones, a permit’s page, and the renewal that extends a permit for a year and charges the driver’s card. Nobody wants a big-bang cutover, so the plan is a routing proxy: every route starts on the legacy app, and moves when the new service is ready for it. You ask an agent for the proxy, with a “shadow” mode to compare the two apps before trusting the new one. What comes back routes correctly and compares answers.

Martin Fowler named the approach after strangler figs, vines that “germinate in a nook of a tree” and grow until the host is no longer needed (Strangler Fig Application, updated 22 August 2024, fetched 23 September 2026). The idea is easy. The hard part is what the prompt never said: that shadowing is only for reads, that a route moves on evidence and not on a date, and that a page and the action that changes its data must never be served by different databases.

GitHub’s Scientist, the best-known library for running a new code path beside an old one, says the first part in its README: it “is only safe for wrapping methods that aren’t changing data” (github/scientist, fetched 23 September 2026).

02 / Name the shape

The proxy decides, on evidence.

A strangler fig migration replaces a running system piece by piece. A router in front of both old and new sends each piece of traffic to one of them, and the pieces move one at a time until the old system serves nothing and can be switched off. Before a piece moves, a shadow run sends the same request to both and compares the answers, while only the old system’s answer reaches the user.

Shadow reads, never writes. Move a route when the shadow says the answers match, and move the reads and writes of the same data together, with the data.

Who owns what during the migration
PartOwnerWhy
Which app answers each routeThe routing proxy’s tableOne place to move, and one place to move back.
Whether a route may moveThe evidence: shadow comparisons“The new service is ready” is a claim until the answers match.
A permit’s data, before the moveThe legacy databaseThe legacy app still writes it.
A permit’s data, after the moveThe new databaseHanded over at cutover, with a final sync; handed back on rollback.
The card chargeWhichever app serves the renewalExactly one of them, never both.

Words to put in a prompt or a review

Routing proxy (facade)
The layer in front that decides which system answers.
Shadow traffic
A copy of a real request sent to the new system; its answer is compared, not used.
Cutover
The moment a route starts being served by the new system.
Final sync
Copying the latest data just before the owner changes.
Rollback
Moving a route back, with any data it changed.
Retirement
Switching the old system off, once no route and no data depend on it.
Why not rewrite it and switch over one night?The big bang

A single cutover is simpler to plan and has no in-between state to manage. Its cost is that the first real evidence arrives all at once, from every user, with no route to move back to except all of them. The lab lets you try it: move all three routes with the router that does what it is told, and every driver sees the new service’s first-release bug at the same moment.

03 / Follow one migration

Watch the same migration through two routers.

First a router that does what it is told: the operator shadows the renewal and moves the permit page without evidence. Then a guarded router: it refuses the shadowed write, finds the new service’s bug in shadow, and moves the page and the renewal together after a final sync. Step through, or open Try it and run the migration.

Strangler fig migration

Which app answers this route, and why?

Router that does what it is told · new service release 1

Route table

  • GET /zoneslegacy
  • GET /permits/P-104legacy
  • POST /permits/P-104/renewlegacy

Last answer

—

Data and money

Legacy database: P-104 expires 2026-10-01

New database: P-104 expires 2026-10-01

Card charges: 0

 

01/ 03
Move it all, as told

Shadow the renewal “to compare”.

The router says accepted.

Reduced motion: choose a scene to see its completed state.

Read this scene

The router says accepted.

Move it all, as told. Shadow the renewal “to compare”. Route table: zones legacy, permit legacy, renew legacy. Legacy expiry 2026-10-01, new expiry 2026-10-01, card charges 0.

Watch restarts the story when you come back. Step through keeps your step. Try it starts a fresh migration each time you open it.

04 / Read the shape

A shadow, a gate, and a group.

Basic form is the rule set: what a shadow compares, what may not be shadowed, and when a route may move. In the wild is the proxy that applies it and moves the data with the routes. At the call site the operator takes one step and sees the table, the charges, and both databases.

The rules that make a move safe: a shadowed request compares the two answers, a write is never shadowed, and a route moves only on enough matching comparisons and no mismatches since the new service last changed. Reads and writes of the same data are one group.

TypeScriptReading
portal.ts
/** A shadowed request: the legacy app answers, the new service is asked too, and the answers are compared. */
export function shadow(legacy: App, candidate: App, route: Route) {
	const body = legacy.handle(route);
	const other = candidate.handle(route);
	return { body, match: other === body };
}

/**
 * The rules that make a move safe. A write is never shadowed, because the
 * new service would run it too. A route moves only on evidence: enough
 * matching shadow reads and no mismatches since the new service last changed.
 * Reads and writes of the same data move together.
 */
const count = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 'es'}`;

export function checkMove(
	route: Route,
	mode: Mode,
	evidence: { matches: number; mismatches: number }
): string | null {
	if (mode === 'shadow' && route === 'renew') return 'a shadowed write runs twice';
	if (mode === 'new' && (evidence.mismatches > 0 || evidence.matches < EVIDENCE))
		return `${count(evidence.matches, 'match')}, ${count(evidence.mismatches, 'mismatch')}`;
	return null;
}

export const readOf: Record<Route, Route> = { zones: 'zones', permit: 'permit', renew: 'permit' };
export const group: Record<Route, Route[]> = {
	zones: ['zones'],
	permit: ['permit', 'renew'],
	renew: ['permit', 'renew']
};
GoAlongside
main.go
type Count struct{ Matches, Mismatches int }

// ShadowRequest lets the legacy app answer, asks the new service too, and
// compares the answers.
func ShadowRequest(legacy, candidate *App, route Route) (string, bool) {
	body := legacy.Handle(route)
	return body, candidate.Handle(route) == body
}

func count(n int, word string) string {
	if n == 1 {
		return fmt.Sprintf("%d %s", n, word)
	}
	return fmt.Sprintf("%d %ses", n, word)
}

// CheckMove says why a move is unsafe, or "" if it is safe. A write is never
// shadowed; a route moves only on enough matching shadow reads and no
// mismatches since the new service last changed.
func CheckMove(route Route, mode Mode, evidence Count) string {
	if mode == Shadow && route == Renew {
		return "a shadowed write runs twice"
	}
	if mode == New && (evidence.Mismatches > 0 || evidence.Matches < Evidence) {
		return count(evidence.Matches, "match") + ", " + count(evidence.Mismatches, "mismatch")
	}
	return ""
}

var ReadOf = map[Route]Route{Zones: Zones, Permit: Permit, Renew: Permit}
var Group = map[Route][]Route{Zones: {Zones}, Permit: {Permit, Renew}, Renew: {Permit, Renew}}
The behavior these examples promiseChecked by 11 shared scenarios in TypeScript and Go
  • Both apps start with permit P-104 expiring 2026-10-01. A renewal adds a year in the serving app’s database and charges the card once. The new service’s release 1 lowercases the zone.
  • In shadow, the legacy app answers, the new service handles the same request, and the proxy counts a match or a mismatch. A new release resets the counts.
  • The plain router sets any route to any mode. The guarded router refuses to shadow the renewal, refuses to move a route to new without 3 matches and no mismatches on its read route, moves the permit page and the renewal together, syncs the new database at that cutover, and copies the new database back before moving them to legacy.

Every expected result was produced by a separate model written from these rules, kept beside the examples in model/cases.py, not copied from either implementation.

Reading the TypeScriptA shared billing object and a string or null

Both apps hold a reference to the same billing object, so a shadowed renewal’s second charge is visible in one place. checkMove returns the reason as a string, or null if the move is safe, so the refusal the operator sees is the rule’s own words.

Reading the GoMaps as databases, copied on purpose

Each app’s database is a map, and copyDB makes a real copy at sync and rollback. Assigning one map to another would share it, and the two databases could never disagree, which would hide the exact failure the lesson is about.

Run it yourselfNo dependencies

Copy the complete TypeScript file and run node --experimental-strip-types portal.ts with Node 22.18 or later. For Go, save main.go next to this go.mod and run go run .. Both print:

go.mod
module heyrian.example/strangler-fig

go 1.22
plain: shadow renew → accepted
plain: one renewal, 2 charge(s)
plain: move permit after 3 shadow reads on release 1 → accepted
guarded: shadow renew → refused: a shadowed write runs twice
guarded: one renewal, 1 charge(s)
guarded: move permit after 3 shadow reads on release 1 → refused: 0 matches, 3 mismatches

05 / Review the agent’s diff

“Shadowing the renewal route too.”

Comparison data for every route before a cutover sounds like diligence. Read what a shadowed request does before you decide whether this route can have it.

The agent’s pull request

“Shadowing the renewal route too, so we have comparison data for every route before cutover. The proxy tests pass.”

// proxy/routes.ts
			export const table: Record<Route, Mode> = {
			  zones: 'new',
			  permit: 'shadow',
			(removed)  renew: 'legacy',
			(added)  renew: 'shadow', // compare renewals before we move them too
			};
			
You are reviewing this change. What do you do?

06 / How it fails

The migration fails between the routes.

Each app works on its own. The failures are in the seams: a request that runs twice, a route that moved too early, a page and a write served by different databases. Here is each one, what a driver sees, and what the guarded router does.

Failure modes of a route-by-route migration
What goes wrongWhat a driver seesWhat the guarded router doesBacked by
Duplicated: a shadowed writeTwo charges for one renewal.Refuses to shadow a write.Case “shadow the renewal”
Wrong: moved without evidence“zone b” instead of “Zone B”, for every driver.Refuses until 3 matches and no mismatches.Cases “move the zones list with no evidence”, “shadow the permit page against the first release”
Split: the write moved, the read did notA renewal that does not show on the permit page.Moves the page and the renewal together.Case “move only the renewal”
Stale: data changed after the copyAn old expiry after cutover.Syncs the new database at cutover; the shadow also catches the gap.Cases “a renewal on the legacy app after the evidence…”, “…after the new copy was taken”
Lost: rollback without the dataA renewal paid for and gone.Copies the new database back before moving home.Case “renew on the new service, then roll back”
The new service is slow or downFor moved routes, a slow or failed page.Nothing yet: this router has no timeout or fallback. Roll the route back.Authored

Containing failure covers the timeout and fallback this router lacks, and Idempotency and at-least-once covers why a write that can run twice needs a key.

07 / Is it worth it?

You pay for an in-between state. Here is what it buys.

A strangler keeps two systems and a proxy running for as long as the migration takes, plus the sync and rollback work. The simpler shape is a big-bang cutover. Hold both up against the changes a migration always meets.

The same four changes, made to each approach
ChangeBig-bang cutoverStrangler with a guarded proxy
A second client: a mobile app calls the same routesMoves with everything else, on the night.Goes through the proxy; moves with each route.
Replace a dependency: a new card processorPart of the one big release.Ships in the new service; reaches drivers when the renewal moves.
A rule changes mid-migration: renewals now need an address checkWritten in the new system, and in the old until the night.The same: whichever app serves the renewal needs it. No difference here.
A second team takes the zones listThey wait for the cutover.They move their route on their own evidence.

Before starting, decide what you will look at:

  • Shadow mismatch rate per route, and the number of comparisons behind it. This is the gate; decide the threshold before looking at the data.
  • Error rate and latency per route, per app, before and after each cutover. The accepted result is no worse than the legacy app’s own numbers the week before.
  • Charges per renewal and renewals missing from the serving database. The accepted number for both is exactly one and exactly zero.

This page did not migrate a real portal, so it has no numbers to give you. The baseline comes from the legacy app itself, measured before the first route moves.

08 / Ask for it

Two prompts, two routing proxies, one operator.

We sent two agents the same request at the same time, both running Claude Sonnet. Both folders held the same two upstream apps, written for the runs: the legacy portal and the new service, whose first release lowercases the zone. The shared prompt asked for a proxy with legacy, new, and shadow modes, changed at runtime. The architecture prompt added the rules: shadow reads only, move on evidence, move the permit page and the renewal together with a sync, and copy the data back on rollback. Then a script acted as the operator on each build.

What the checker found, run 2026-09-23
What the operator didPlain promptArchitecture prompt
Shadow the renewal, then renew oncemoved; one renewal charged the card 2 timesrefused (409); one renewal charged the card once
Move the permit page after three mismatched shadow readsmoved; drivers see “P-104 · 7ABC123 · zone b · expires 2026-10-01”refused (409); drivers see “P-104 · 7ABC123 · Zone B · expires 2026-10-01”
Move only the renewalmoved; after a renewal the permit page says “expires 2026-10-01”refused (409); after a renewal the permit page says “expires 2027-10-01”
A renewal lands on the legacy app between the evidence and the movemoved; the permit page then says “expires 2026-10-01” (renewed to 2027-10-01)moved; the permit page then says “expires 2027-10-01” (renewed to 2027-10-01)
Renew on the new service, then roll backafter rolling back, the permit page says “expires 2026-10-01” (renewed to 2027-10-01)after rolling back, the permit page says “expires 2027-10-01” (renewed to 2027-10-01)

The plain build is a good router. It forwards faithfully, answers from the legacy app in shadow, and compares bodies byte for byte. It also does whatever the operator asks. Shadowing the renewal charged the card twice. The agent’s own test did exactly that and counted it as a “match”, because both apps’ renewal replies were the same text. Moving the permit page after three mismatches showed every driver the lowercase zone. Moving only the renewal, or moving after a legacy renewal, or rolling back, each left the permit page showing an expiry the driver had already paid to extend.

The architecture build refused the first three with a 409 that said why, synced the new service before the page and the renewal moved together, and copied the new data back on rollback. Every permit page it served after a renewal said 2027-10-01.

proxy.mjs · plain prompt
const mode = parsed && typeof parsed === 'object' ? parsed.mode : undefined;
if (typeof mode !== 'string' || !MODES.has(mode)) {
	return sendJson(res, 409, { error: `mode must be one of legacy, new, shadow (got ${JSON.stringify(mode)})` });
}

state[routeName].mode = mode;
return sendJson(res, 200, { route: routeName, mode });
proxy.mjs · architecture prompt
if (mode === 'shadow' && methodFor(route) !== 'GET') {
	return send(res, 409, { error: `${route} is a write route; shadow mode only supports reads` });
}

The plain prompt described shadow mode exactly and never said what it is not for. The line that made the difference is the shortest one in the architecture block: Shadow only reads. The rest is the same idea for data: the page and the action that changes it move together, with their data.

How the runs were made and checkedOne run each, one checker run
  • Both agents received the prompts word for word, in fresh contexts, at the same time, in folders that already held the same two upstream apps, written for the runs and kept beside the evidence. Neither changed them.
  • The checker starts the two apps and the recorded proxy fresh for every question, acts as the operator, and reads charges and data from the apps’ admin endpoints. After each question it checks that nothing is still listening; nothing was.
  • Both agents tested on their own ports and stopped every process by its PID. The architecture agent wrote one file of PIDs to the system temp folder, outside its own folder.
  • One run of each prompt is a sample, not a measurement of a model.

09 / Hold it there

Keep the in-between state honest until it ends.

A migration lasts months, and every change to the proxy in that time is a chance to shadow a write or skip the gate. Three kinds of check keep the rules in force.

  1. The framework’s own door

    Next.js has the incremental-adoption version of this proxy built in: fallback rewrites “are applied before rendering the 404 page and after dynamic routes/all static assets have been checked”, so a route the new app has is served by it and everything else goes to the old site (rewrites, Next.js 16.3.6, fetched 23 September 2026). SvelteKit’s handle hook can forward what it does not serve. Neither has shadow traffic or a gate; those stay yours.

  2. An import rule an agent cannot argue with

    The new service talks to the legacy app over HTTP, the way the proxy does, and never imports its code; one import and the legacy app lives on inside its replacement. This rule, run with dependency-cruiser 18.3 against a three-file fixture, flagged the new permit module that imported a legacy helper, and nothing else. Enforcement layer runs rules like this against real code.

    .dependency-cruiser.cjs
    // .dependency-cruiser.cjs
    module.exports = {
    	forbidden: [
    		{
    			name: 'new-service-talks-to-legacy-over-http-only',
    			comment: 'The new service replaces the legacy app. Importing its code keeps it alive inside the replacement.',
    			severity: 'error',
    			from: { path: '^src/new/' },
    			to: { path: '^src/legacy/' }
    		}
    	]
    };
    depcruise output
      error new-service-talks-to-legacy-over-http-only: src/new/permits.js → src/legacy/permits.js
    
    x 1 dependency violations (1 errors, 0 warnings). 3 modules, 1 dependencies cruised.
  3. A check on what actually happens

    Import rules cannot see a write shadowed at runtime. So check the side effect: shadow the renewal in a test environment, renew once, and count charges across both apps. The checker in section 08 does it to the recorded builds, and the lesson’s spec does it to both routers.

    check-runs.mjs
    async 'shadow the renewal, then renew once'() {
    	const set = await setMode('renew', 'shadow');
    	await renew();
    	const total = await charges();
    	return { set, charges: total, verdict: `${move(set)}; one renewal charged the card ${total === 1 ? 'once' : `${total} times`}` };
    },
Where this lives in Next.js and SvelteKitEvery framework migration you did route by route was this. The links between old and new are where you own it.

Where it already is in your components

Moving from the Pages Router to the App Router one route at a time, from Create React App to Vite one page at a time, or putting a new SvelteKit app in front of an old server-rendered site: each is a strangler with the framework as the proxy. A fallback rewrite or a forwarding hook is the routing table, and adding a page is a cutover.

When you have to own it

The links between the two apps. While a route still belongs to the old site, a link to it from the new app must be a full page load, so the proxy decides who answers; client-side navigation would look for a page the new app does not have. In Next.js that is a plain <a> instead of <Link> for routes not yet moved; in SvelteKit it is data-sveltekit-reload, which “will cause a full-page navigation” (Link options). The list of moved routes becomes something your links read, and it moves with the proxy.

The framework as the proxy. Next.js falls back to the legacy portal for any route it does not have; SvelteKit’s handle hook forwards any route not in its migrated list.

ReactAlready in your code
next.config.ts
// The new Next.js app serves the routes it already has. Anything it does not
// have falls back to the legacy permit portal, checked after every page and
// dynamic route. Moving a route is adding its page; the config stays the same.
/** @type {import('next').NextConfig} */
const nextConfig = {
	async rewrites() {
		return {
			beforeFiles: [],
			afterFiles: [],
			fallback: [{ source: '/:path*', destination: 'https://legacy-permits.example.gov/:path*' }]
		};
	}
};

export default nextConfig;

10 / Make the call

Move routes one at a time when you cannot afford to be wrong everywhere at once.

A small app with few users, a quiet weekend, and a tested restore can move in one cutover, and the proxy, the sync, and the months of two systems are cost with no return. Reach for a strangler when the old system is busy, the new one has not met real traffic, and a mistake for every user at once is not acceptable.

Reopen the plan if routes stop moving, because the in-between state has its own cost every week it lasts, or if most changes need both apps, which says the split is in the wrong place.

Take it with you

Explain it without saying “strangler fig”: “A router in front of both apps decides who answers each route. We copy real reads to the new app and compare, move a route when the answers match, and move a page with the actions that change its data.” Then look at the last migration you ran and find the moment two databases could have disagreed.

Paste into your next prompt, and fill in the blanks

A routing proxy decides, per route, whether <the legacy app> or
<the new service> answers. Modes: legacy, shadow, new.
Shadow only reads: never shadow <writes such as a renewal>.
A route moves to new only after <3> matching shadow reads and no
mismatches since the new service's release last changed.
Reads and writes of the same data move together. Sync the new
service's data just before they move, and copy it back before a
rollback, so no <renewal> is lost.
The new service reaches the legacy app over HTTP only.
Connections to follow nextRelated lessons

Take the portal into your editor. Add a timeout and a fallback to the proxy for routes on the new service, and decide what a driver sees while it rolls back.

Back to architecture →