Two Code Paths During a Strangler Migration
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.
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.
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.
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.
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