Ground Truth

Two Code Paths During a Strangler Migration

Mostafa DhouibMostafa Dhouib·9 min read
The short answer

Incremental replacement behind an existing system means two implementations coexist for the whole migration, with a routing seam between them. That is a real cost rather than a detail. Routing rules, shared state, sessions that cross the boundary and the criteria for declaring a path finished all have to be designed up front, because the alternative is discovering the seam's behaviour from incidents.

Two Code Paths During a Strangler Migration

The short answer. Replacing a system path by path behind the one already in production means two implementations run side by side for the entire migration, with a routing seam deciding which one serves each request. That is the price of avoiding a single cutover, and it is a genuine cost rather than an implementation detail. The seam needs designed behaviour: explicit routing rules, one owner for every piece of shared state, a defined answer for a user session that crosses the boundary mid-flow, and a written test for when a path is finished. Teams that treat the seam as temporary plumbing end up learning its behaviour from incidents.

The argument for incremental replacement is strong and well rehearsed. Each step is reversible, value arrives continuously, and the old system remains available to answer questions about correct behaviour.

What gets understated is what you are agreeing to in exchange. For the duration of the migration, which is longer than planned, the system is two systems and a decision layer. Everything that was one thing is now two things that have to agree.

The seam is a component, not a config flag

In the first week the seam is a conditional. Some requests go left, the rest go right.

By the sixth month it has accumulated real behaviour. It knows which paths are live in the new implementation and for which population. It knows which users are in a progressive rollout. It holds overrides for individual accounts that hit a bug. It has rules for what to do when the new path errors. Somebody has added logic to force a specific case to the old path because of a support escalation that nobody has written down.

That component is now carrying operational policy, and it was never designed, specified or tested as a component. It got there one commit at a time.

The seam in the first week
A conditional
some requests go left, the rest go right
The seam by the sixth month
Which paths are live, for which population
Which users are in a progressive rollout
Overrides for individual accounts that hit a bug
Rules for what to do when the new path errors
A forced route from a support escalation
which nobody has written down
one commit at a time
Treating it as a component from the start costs very little. Routing decisions held in data are auditable, so you can answer where a given request would go without reading a diff, which is the first question asked in every incident. Every decision logged with its inputs is the only useful evidence when two users report different behaviour on the same feature.
FigureThe seam does not stay a conditional. It accumulates operational policy one commit at a time, and by the time it is carrying that policy it has never been designed, specified or tested as a component, because at the point when design would have been cheap it did not look like one.

Treating the seam as a first-class part of the architecture from the start costs very little and changes several things.

Routing decisions are data, not code. A table of path, population and destination, readable at runtime, is auditable. You can answer the question of where a given request would go without reading a diff. Hard-coded conditionals cannot answer that question, and it is the first question asked in every incident.

Every routing decision is logged with its inputs. When two users report different behaviour on the same feature, the only useful evidence is which implementation each one actually reached. Without that log you are guessing, and you will guess wrong in a way that costs a day.

The seam has its own owner and its own tests. Not the old team, not the new team. The failures in a two-path system concentrate in the layer between them, because that is the layer each side assumes the other is handling.

Shared state is where this gets hard

Routing is the visible part and it is the easy part. The difficult question is what both implementations read and write.

Three patterns, in increasing order of pain.

Both paths read and write the same store. This is the usual starting point and it is the most workable, provided one rule holds: the schema has exactly one owner. If the new implementation is permitted to add a column that the old one does not know about, and the old one writes a row without it, you have a record that is valid for one path and incomplete for the other. Any schema change during the migration has to be compatible with both readers, which means additive changes only and no renames, for the whole duration.

Each path has its own store with synchronisation. Sometimes unavoidable, usually regretted. Two stores means a synchronisation lag, and a user who completes an action on the new path and then lands on the old path sees stale data and reports it as a bug. Every synchronisation mechanism also has a failure mode where it stops quietly, so it needs its own reconciliation and its own alerting, which is a second system to operate on top of the two you already have.

Derived state computed independently on both sides. The worst case. If both implementations compute a balance, a total or an eligibility decision from base data, any difference in rounding, filtering or ordering produces two different answers to the same question. The resolution is to designate one side as the authority for each derived value and have the other read it rather than recompute it, even when recomputing would be faster.

The session that crosses the boundary

The case that gets discovered rather than designed is a user part way through a multi-step flow when their next request routes differently.

This happens more than people expect. A rollout percentage changes. A path gets promoted. The new implementation errors and the request falls back. A load balancer sends the second request to a different place than the first.

What breaks is anything held between steps. Partially completed form state. An in-progress transaction. A session identifier the other side does not recognise. An idempotency key allocated by one implementation and unknown to the other.

Designing for it means picking one of three answers per flow and writing it down.

Pin the flow. Once a user enters a multi-step flow, record the chosen implementation and route every subsequent request in that flow to it, regardless of what the general rules say. The cleanest option and it requires the seam to hold flow-level state.

Make the intermediate state shared and neutral. Both implementations read and write the same in-progress representation, in a format neither owns. More work, and the only option that genuinely survives a mid-flow switch.

Fail the flow explicitly. Detect the crossing, abandon the attempt and tell the user to start again. This is a poor experience and it is strictly better than silently losing the second half of a submitted form, which is what happens when nobody chooses.

The one unacceptable answer is to leave it undefined, because the undefined behaviour is partially completed work with no record of what happened.

Pin the flowroute every later request to itthe cleanest option, and it requires the seam to hold flow level state
Share the intermediate statein a format neither side ownsmore work, and the only option that genuinely survives a mid flow switch
Fail the flow explicitlydetect and abandona poor experience, and strictly better than silently losing the second half of a submitted form
Leave it undefinedthe one unacceptable answerthe undefined behaviour is partially completed work with no record of what happened, taking out form state, in progress transactions, session identifiers and idempotency keys the other side has never seen
A path is finished when the old code for it has been deleted rather than disabled, with no overrides still pointing back and the shared state it required under a single owner again. Each remaining override is an unresolved defect wearing a disguise, and paths that are mostly done accumulate into a permanent seam.
FigureA user part way through a multi step flow whose next request routes differently is the case that gets discovered rather than designed. It happens whenever a rollout percentage changes, a path gets promoted, the new implementation errors and falls back, or a load balancer sends the second request elsewhere. Three answers are valid and one is not.

Deciding a path is done

A path is not finished when the new code is written. It is finished when the old code for that path can be deleted.

A workable definition has four parts. All traffic for the path has been served by the new implementation for a period that includes the path's cyclical events. No routing overrides remain pointing that path back to the old implementation, because each remaining override is an unresolved defect wearing a disguise. The old implementation's code for that path has been removed rather than disabled. And any shared state the path required has a single owner again.

That last condition is the one that gets skipped, and skipping it is how a migration finishes on paper while the two-path constraint on schema changes stays in force indefinitely.

The honest limit

Incremental replacement is the right default for a production system, and it is not free, and the cost is not only engineering time.

Two implementations mean every cross-cutting change is made twice, so a security fix, a logging change or a compliance requirement lands in two codebases. Both need deployment pipelines, monitoring and people who understand them. The old system needs maintainers for longer than anyone budgeted for, and those are the staff most likely to leave during the migration.

There is also a failure mode specific to this approach. A migration that never finishes leaves the seam in place permanently, and a permanent seam is worse than either system alone, because it has the complexity of both plus a routing layer nobody owns. The guard against that is finishing paths completely, including deleting the old code, rather than accumulating paths that are mostly done.

FAQ

What does a strangler migration actually cost? Two implementations running side by side for the whole migration, plus a routing seam between them. Every cross-cutting change is made twice, both paths need pipelines and monitoring, and the old system needs maintainers for longer than budgeted. That cost buys individually reversible steps, continuous delivery of value, and the old system remaining available as a reference for correct behaviour.

How should routing between old and new implementations be designed? As a first-class component with routing decisions held in data rather than in conditionals, so you can answer where a given request would go without reading code. Every decision should be logged with its inputs, because the first question in any incident is which implementation the user actually reached. The seam needs its own owner and its own tests.

What happens to shared state during an incremental migration? It needs exactly one owner per item. If both paths read the same store, schema changes must stay compatible with both readers for the entire migration, which means additive only and no renames. If each path has its own store, synchronisation lag becomes visible to users and needs its own reconciliation and alerting, which is a third system to operate.

What happens when a user session crosses the routing boundary mid-flow? Whatever the design says, and if the design says nothing, partially completed work is lost silently. The three valid answers are to pin the flow to one implementation once it starts, to hold intermediate state in a shared format both sides understand, or to detect the crossing and explicitly fail the flow. Leaving it undefined is the only unacceptable choice.

Why should derived values not be computed on both sides? Because any difference in rounding, filtering or ordering produces two different answers to the same question, and users will see both. Designate one implementation as the authority for each derived value, such as a balance or an eligibility decision, and have the other read it rather than recompute it, even when recomputing locally would be faster.

When is a migrated path actually finished? When the old code for it has been deleted, not disabled. All traffic has been served by the new implementation across the path's cyclical events, no routing overrides still point it back, and any shared state it required has a single owner again. Paths that are mostly done accumulate into a permanent seam, which is worse than either system on its own.

Free worksheet
The Seam Register

The five signs that a failure is in the seams, then one register per boundary covering units, ranges, timing, retry ownership and partial-success behaviour. Filled in separately by both sides, which is the whole method.

One email, the resource, and nothing else unless you reply.
Carrying a program like this one?

Tell us the system, the stakes, and the date that matters. You get a straight technical reply from the person who would lead the work, within 24 hours.

Bring us the program