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.
| Part | Owner | Why |
|---|---|---|
| Which app answers each route | The routing proxy’s table | One place to move, and one place to move back. |
| Whether a route may move | The evidence: shadow comparisons | “The new service is ready” is a claim until the answers match. |
| A permit’s data, before the move | The legacy database | The legacy app still writes it. |
| A permit’s data, after the move | The new database | Handed over at cutover, with a final sync; handed back on rollback. |
| The card charge | Whichever app serves the renewal | Exactly 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.
Which app answers this route, and why?
Router that does what it is told · new service release 1
Route table
GET /zoneslegacyGET /permits/P-104legacyPOST /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
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.
/** 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']
}; 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:
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.
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.
| What goes wrong | What a driver sees | What the guarded router does | Backed by |
|---|---|---|---|
| Duplicated: a shadowed write | Two 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 not | A 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 copy | An 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 data | A 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 down | For 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.
| Change | Big-bang cutover | Strangler with a guarded proxy |
|---|---|---|
| A second client: a mobile app calls the same routes | Moves with everything else, on the night. | Goes through the proxy; moves with each route. |
| Replace a dependency: a new card processor | Part 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 check | Written 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 list | They 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 operator did | Plain prompt | Architecture prompt |
|---|---|---|
| Shadow the renewal, then renew once | moved; one renewal charged the card 2 times | refused (409); one renewal charged the card once |
| Move the permit page after three mismatched shadow reads | moved; 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 renewal | moved; 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 move | moved; 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 back | after 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.
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 }); 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.
The framework’s own door
Next.js has the incremental-adoption version of this proxy built in:
fallbackrewrites “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’shandlehook can forward what it does not serve. Neither has shadow traffic or a gate; those stay yours.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.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.
// 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
- Branch by abstraction is the same move inside one codebase: a seam instead of a proxy.
- Expand and contract is how a database schema changes while both old and new code read it.
- Extracting a service moves a module out of a monolith, which is often the new service a strangler routes to.
- Idempotency and at-least-once is why a renewal that can run twice needs a key.
- Baseline before you change is where the legacy app’s numbers come from before the first route moves.