Skip to the content
Software Made Clear Diagrams that show the mechanism About

Strangler-fig migration, from the inside

ANSWER

Put a router in front of the old system and move one capability at a time behind it. What decides whether it works is not the routing — it is which side owns the data while both are running, because two systems writing the same rows is the failure discovered late.

IN PLAIN TERMS

A bridge gets replaced while the traffic keeps crossing: one new lane beside the old one, a single line of cars moved across, then lane by lane. Moving off an old system works the same way — the bridge comes down only once nothing is driving on it.

You have read that a strangler-fig migration means putting a facade in front of an old system and moving pieces behind it until nothing is left, and it told you nothing about Monday morning. Here is the first move: stand up a router that decides, per request, which system answers, and move one capability behind it. That move is close to mechanical. What actually decides whether the migration finishes is a question the router never touches — which system is allowed to hold the truth about a given piece of data while both are running. Get that wrong and the two systems disagree quietly, for a while, before anyone notices.

Put something in front of it#

Call it a router. The same idea also goes by facade, proxy, or gateway, and in a real system it might be an API gateway you already run, a reverse-proxy rule, or a single function inside the one process that used to be the whole application. The name does not matter; the property that matters is that it is the only component, on either side, that knows a migration is happening. The old system is written as though it is the only system. The new system is written as though it is the only system. Only the router carries the fact that there are two.

The unit that moves behind the router is a capability — one endpoint, one message type, one scheduled job — not a percentage of traffic and not a table. A percentage of traffic tells you how much load a system is carrying, not what it is responsible for; a table can be read by several capabilities that have no reason to move on the same day. Moving a capability means every request for that one thing now goes to the new system, and every other request keeps going where it always went.

That gives a concrete test for whether a capability has actually moved: can the router send it back? A capability the router can flip back to the old system by changing one entry has been moved. A capability that required deleting the old code path, or that now depends on a schema the old system can no longer read, has not been moved — it has been rewritten in place, and the router is decorative for it from that point on. The distinction matters because reverting is the only response available to a mid-migration incident, and a capability that cannot revert has already spent that option before the migration needed it.

router.py
ON_NEW = {"refund_authorization"}  # grows an entry per capability moved

def route(capability, request):
    if capability in ON_NEW:
        return new_system.handle(capability, request)
    return old_system.handle(capability, request)

The point of showing it is how little there is: a set, a membership check, and everything else falls through to the old system unchanged. No traffic percentages, no separate flag service — the flag is the fact of being in the set.

Who owns the data while both are running#

This is the section the article exists for. Routing the request is the easy part; the hard part starts the moment both systems need the same data during the transition, and there are only three honest arrangements for who owns it — the third one is a trap that is easy to walk into without deciding to.

ArrangementWhat it means in practiceWhat it costs
Old owns, new readsThe old system stays the writer; the new system reads through a replica or a call back to the old system.Cheap and safe, because the new system cannot yet change anything it does not own. The limit is that the new system is not really live for this data — it is watching, not deciding.
New owns, old readsThe write path has moved to the new system; the old system, if it still needs the data, reads it back the same way the new system used to.The direction the migration is heading. This is the point where the old system has become a client of the new one rather than the other way round.
Both writeEach system accepts writes to what looks like the same data, independently.The trap. Cheapest to build, because neither side has to call the other — and the one arrangement that guarantees the two copies can disagree.

In practice, no transaction spans both systems — they are different systems for exactly that reason — so “both write” means the two can drift apart silently, and the drift is usually found by a customer noticing a wrong balance, not by a test written in advance for a case nobody thought to imagine.

The rule that survives this: exactly one system owns a given piece of data at any moment. Moving that ownership from one side to the other is its own deliberate step, with its own cutover, not a state a team drifts into because reading from both sides was convenient for a sprint.

A capability that has just been flipped to the new system is, for a moment, answering requests from a client that has no idea anything changed — including a retried request whose first attempt went to the old system. Idempotency keys and safe retries is about what a server owes that retried request; the migration does not change the obligation, only which system has to meet it. This article is node six on the architecture path.

The migration that never finishes#

The failure mode is not a crash. It is a migration that never finishes. The router accumulates exceptions — a capability here, a capability there, kept on the old system because moving it is nobody’s clear priority.

01

Reporting

Nobody owns it, so it keeps querying the old system directly, and moving it means finding out who would even notice if it broke.

02

A batch job that reads every table

Moving it means every table it touches has to exist, populated, on the new side first — so it is the thing most often pushed to the end, which means it is pushed indefinitely.

03

An integration with a partner who cannot be rescheduled

The other side of that integration has its own backlog and its own priorities, so the capability’s fate now depends on someone else’s calendar rather than your own.

After enough of these accumulate, both systems are load-bearing at once, and the team is maintaining two of everything behind them — two deployment pipelines, two on-call rotations, two places a bug can hide. Neither system can be retired, because each still answers requests the other one does not.

The tell is not dramatic: the list of capabilities still on the old system stops shrinking. Nobody notices in the moment, because no single sprint is the one where it stopped — the last few items just keep losing to whatever is more urgent that week, until the backlog item to move them stops getting written at all.

A stalled strangler migration is not a failed pattern, it is an unfunded one. The pattern assumes investment continues until the old system is switched off — the router, the capability-by-capability moves, and the eventual decommission are all still owed. Choosing to stop halfway and run two systems indefinitely can be the right call for a system that no longer justifies the remaining work. What makes it damaging is arriving at that decision by drift, a capability at a time, rather than choosing it and saying so.

IF YOU REMEMBER ONE THING

The router decides which system answers a request. It does not decide which system is telling the truth about the data — that is a separate decision, made once per capability, on purpose.

A migration that runs for two years outlives everyone’s memory of why it was started that way. That is the argument for writing the decision down while the reasons are still in the room.

Questions people also ask

5 QUESTIONS
What is the difference between a strangler-fig migration and a rewrite?

A rewrite builds the replacement outside the running system and switches over once it is ready, so there is a moment where both exist but only one is live. A strangler-fig migration keeps both live behind a router for as long as the move takes, capability by capability, so the running system is never taken down to make progress.

Where should the router live?

Wherever requests already pass through on the way in — an existing API gateway, a reverse proxy, or a load balancer's routing rule are all reasonable homes, because none of them add a hop that was not already there. What matters more than the layer is that it is one shared place both systems' owners can see and change, not logic copied into every caller.

How do you handle a database that both systems need?

Decide which system owns the writes and have the other read through a replica or a call back to the owner — never let both accept writes to what is meant to be the same data. That is a separate decision from routing traffic, and moving ownership later is its own deliberate step, not a side effect of moving requests.

What if a capability cannot be moved independently?

Then it is not one capability yet — it is several capabilities entangled with each other, or with something the router does not control, like a scheduled job that reads across the whole database. The usual fix is to shrink the unit until it can move on its own, which is unglamorous and often the real blocker behind a migration that has stalled.

When is a big-bang replacement the better choice?

When the system can actually be taken offline for the cutover, so the read-through and shared-write problems this article describes never have to exist because only one system is ever live. That is rare for anything customers touch continuously, which is exactly why the strangler-fig approach exists for the cases where taking it offline is not an option.