A system can be difficult to change without being possible to replace in one safe step. The old application may serve real traffic, contain years of business rules, and depend on behavior that nobody has fully documented. A complete rewrite asks the team to reproduce all of that correctly before users receive any value from the new system.
The strangler pattern takes a different approach: replace the system one well-defined slice at a time while the old and new implementations coexist.
This article explains the mental model, how to choose useful migration slices, how routing and data ownership affect the design, and how to avoid turning a temporary migration into permanent complexity.
Treat replacement as a sequence of ownership transfers
The simplest mental model is not “build the new system beside the old one.” It is move responsibility across a boundary.
Imagine an old customer application that handles three capabilities:
customer application
├── view profile
├── update address
└── manage preferencesA big-bang rewrite would implement all three elsewhere and switch everything at once. A strangler migration can move only manage preferences first:
request
|
router
/ \
old new
| |
profile preferences
addressThe router represents any boundary that can direct a request or operation to one implementation or the other. Depending on the system, that boundary might be an HTTP gateway, an application facade, a message consumer, or an internal interface. The pattern does not require a particular networking technology.
The important property is ownership. For a migrated operation, the team should be able to answer: which implementation is authoritative now?
Choose a slice with a clear boundary
A good first slice is small enough to understand but complete enough to own.
Suppose preference management currently exposes two operations:
get_preferences(customer_id)
update_preferences(customer_id, preferences)Moving only the read operation may look easier, but it creates split ownership if the old system still writes the data. That can be reasonable when the new path deliberately reads from the old source, but it is not yet an independent replacement.
Moving both operations together often creates a clearer slice:
/preferences/* -> new implementation
/profile/* -> old implementation
/address/* -> old implementationThis example is intentionally simplified. Production boundaries rarely align perfectly with URL paths. The useful question is whether the slice has a coherent responsibility and whether its dependencies on the old system are explicit.
A practical candidate usually has several of these properties:
- its behavior can be described and tested independently;
- callers can be routed to one implementation without ambiguous ownership;
- its data dependencies are understood;
- failure can be detected before it damages unrelated capabilities;
- moving it teaches the team something useful about later slices.
Starting with the most complicated capability may expose every migration problem at once. Starting with a trivial capability can prove the routing mechanism while teaching little about the real constraints. Choose a slice that is representative without being critical to the entire system.
Put the switching decision at a controlled boundary
During migration, some mechanism must decide whether an operation goes to the old or new implementation. Keep that decision in as few places as practical.
A facade can make the idea explicit:
function update_preferences(customer_id, preferences):
if preferences_migrated(customer_id):
return new_preferences.update(customer_id, preferences)
return old_application.update_preferences(customer_id, preferences)This is not a recommendation to scatter feature checks through business code. The example demonstrates the opposite: migration routing belongs at a boundary where it can be observed, changed, and eventually removed.
Routing can be coarse-grained. For example, all preference operations may move together. It can also be gradual, such as moving selected tenants or accounts first. Gradual routing can reduce the impact of a defect, but it adds a temporary requirement: both paths must behave correctly for the populations assigned to them.
Whatever routing rule you choose, make it deterministic enough to diagnose. If the same request unpredictably reaches different implementations, comparing failures and reproducing bugs becomes harder.
Decide data ownership before moving writes
Routing code is often the visible part of a strangler migration. Data ownership is usually the harder part.
Suppose both systems can update the same preference record:
old system ----writes---->
preferences
new system ----writes---->Now correctness depends on conflict rules, ordering, and synchronization between two writers. If those rules are unclear, a successful request through one path can silently overwrite a change made through the other.
A simpler target is one authoritative writer for each migrated piece of data:
old system ----reads----> new preference store
new system ----writes---> new preference storeThat exact arrangement is not always possible. The old application may require its original database, or other capabilities may depend on the same tables. In those cases, the migration needs an explicit compatibility strategy. Examples include keeping the old database authoritative temporarily, synchronizing changes in one direction, or moving a larger slice so that shared writes disappear.
The key is to treat data movement as part of the migration design rather than as an implementation detail discovered after routing begins.
Be careful with dual writes
Writing the same logical change independently to two systems looks like an easy bridge:
write_old(change)
write_new(change)But two successful writes are not guaranteed merely because they are adjacent in code. One can succeed while the other fails, the process can stop between them, or retry behavior can produce different results.
Dual writes can be engineered with explicit recovery and reconciliation, but they introduce a consistency problem that the migration must own. Do not adopt them only because they make the first diagram look symmetrical.
Make each migrated slice observable
A migration should tell you whether the new path behaves as expected before you move more responsibility onto it.
Useful signals depend on the operation, but they commonly include request failures, latency distributions, domain-level rejection counts, and differences between old and new outputs where comparison is meaningful.
For read operations, a team may temporarily send a request to the authoritative implementation and separately compute the candidate result for comparison. The candidate result is not returned to the user yet:
old_result = old_system.read(customer_id)
new_result = new_system.read(customer_id)
record_difference(old_result, new_result)
return old_resultThis technique is useful only when the comparison itself has acceptable cost and side effects. Running a second implementation is much more dangerous for commands that send messages, charge money, reserve inventory, or mutate state.
Observability should answer engineering questions, not merely produce more dashboards. Before moving a slice, decide what evidence would make the team continue, stop, or route traffic back.
Design rollback around ownership, not deployment
A new deployment can often be rolled back quickly. A migration cannot necessarily be reversed as easily once the new system has accepted writes.
Suppose preference updates have been stored only in the new database for three days. Pointing traffic back to the old application is unsafe if its database does not contain those changes.
So distinguish two rollback cases:
- Code rollback: restore a previous version of the new implementation.
- Ownership rollback: make the old implementation authoritative again.
The second requires compatible state. Before moving writes, decide whether ownership rollback is necessary and, if so, how data will remain usable by the old path. For some low-risk migrations, the better plan is to fix forward after ownership moves rather than maintain expensive bidirectional compatibility. That is a trade-off to choose deliberately.
Remove the old path after the new one earns ownership
Coexistence is a migration state, not the goal.
Once a slice has moved successfully, remove the obsolete routing rule, old implementation, compatibility code, unused data synchronization, and monitoring that existed only to compare the two paths. Leaving both implementations indefinitely means developers must continue reasoning about both.
Before deletion, check for callers that bypass the routing boundary. An old endpoint may appear unused while a scheduled job, support tool, or internal service still invokes it directly.
A useful completion condition is stronger than “most traffic uses the new path.” The slice is complete when the new implementation owns the responsibility and the old implementation is no longer required for it.
Common ways the pattern goes wrong
Building the entire replacement before routing anything
If the team spends a year building a complete new system and only then switches traffic, coexistence has provided little risk reduction. The project has effectively become a big-bang rewrite with an extra routing layer.
Prefer slices that can reach production independently and provide feedback about assumptions early.
Slicing by technical layer instead of behavior
Replacing “the persistence layer” or “all utility classes” may create months of work without transferring a user-visible or business responsibility. Technical slices can be necessary, but they often leave old and new behavior tightly interdependent.
Where possible, migrate a vertical capability whose entry point, behavior, and ownership can move together.
Letting migration conditions spread everywhere
Checks such as if use_new_system become expensive when they appear throughout domain logic. They multiply the number of execution paths and make eventual cleanup harder.
Centralize routing at stable boundaries and keep the implementations unaware of the migration when practical.
Keeping two authoritative implementations
If both old and new systems remain valid places to change the same rule, they will eventually disagree unless every change is duplicated correctly. During coexistence, define which implementation owns each responsibility.
Forgetting the deletion plan
Temporary adapters and synchronization jobs have a tendency to become permanent because removing them is never scheduled. Give migration infrastructure an explicit exit condition when you introduce it.
When the strangler pattern is a good fit
The pattern is useful when a system must remain available while substantial behavior is replaced, useful boundaries can be introduced or already exist, and the team can migrate responsibilities independently enough to learn from each step.
It is less attractive when the old system is small enough to replace safely in one change, when old and new implementations cannot coexist at all, or when every capability depends on one inseparable state transition. In those cases, the routing and compatibility machinery may cost more than the risk it removes.
The pattern also does not eliminate the need to understand legacy behavior. It changes when that understanding is required. Instead of reconstructing the entire system before release, the team can investigate and validate one migration slice at a time.
Conclusion
A strangler migration works by making replacement incremental: establish a boundary, choose a coherent slice, route that slice deliberately, define its data ownership, observe the new path, and remove the old path when ownership has moved.
The most important design question is not how much new code has been written. It is which responsibility can move completely enough that the system becomes simpler after the old version is deleted.
If every step transfers clear ownership and has an explicit cleanup condition, the migration can reduce risk without leaving two permanent systems behind.