Somewhere in the architecture review, somebody says rebuild. New stack, clean domain model, eighteen months, then a cutover weekend. The plan sounds decisive because it is a single decision, and that is the problem: every risk in the system lands on one date you have to survive.
You do not have to choose between a rewrite and rot. You evolve architecture by moving one capability at a time behind a seam, keeping the old path live until the new one has earned trust against real traffic, and deleting the old path on a date you wrote down before you started.
Everything below is the mechanics of that sentence. One boundary first: the trade-off between one deployable and many is monolith vs microservices: the trade-offs, and that argument stays there. This post is about movement — how you change a running system's shape without a flag day.
How do you evolve software architecture without a rewrite?
You move capability. You do not replace code. Pick one capability, put a seam in front of it, run your new implementation beside the old one, compare the two answers on real requests, and retire the old one when the comparison stops being interesting.
Three properties separate that from a rewrite. Your change attaches to a seam instead of a plan. Your new path is proven by traffic instead of by a test suite you hope is representative. And your old path carries an end date from day one, which gives the transition a cost ceiling.
A rewrite has none of the three. You swap the seam for a schedule, proof for a specification, and the deadline for the day when everything must work at once. Martin Fowler's own summary of the gradual route is careful rather than triumphant: the point is to make both investment and returns occur gradually and visibly
. Visibility is the part a two-year programme cannot buy, because you learn whether the plan was right only at the end of it.
That is the trade I would take every time: slower start, earlier evidence, exit available at every step.
What is a seam, and why is it the unit of architectural change?
A seam is a place where you can change behavior without editing the callers. Michael Feathers defined it for testing in his 2004 book — A seam is a place where you can alter behavior in your program without editing in that place
— and nothing in that definition is specific to tests.
The definition matters because the seam sets the size of your migration. A seam at the HTTP edge moves a route. A seam at an interface moves an implementation. A seam in the middle of a function moves a statement.
You can migrate only as much as the seam can hide, and that limit is why your first job is never writing new code. Your first job is finding a place where new code can live without anyone noticing.
You will find four seams in almost every system:
- The route. An HTTP path, a gateway rule, or a proxy config that decides which backend answers. Your client knows a URL and nothing behind the URL.
- The interface. A port, protocol, or abstract class with one implementation and ten callers. Your callers know a signature.
- The view. A database view or read model that keeps a stable name while the tables underneath move.
- The message. A queue or topic, where the producer does not know who consumes and the consumer does not know who produces.
You find them by asking what the caller knows. If your caller knows a URL, you can replace the application. If your caller knows a table, you can replace the schema. If your caller knows a class name and a constructor, you can replace the class and not much else.
One pattern creates seams where none appear to exist. Event interception, from the legacy displacement catalogue, means you intercept updates to system state and route some of them to a new component. The old system keeps writing, the new one starts receiving the same state changes, and nobody has to ask the old system for permission.
Do not confuse this with boundary design. Making a module boundary real — the enforcement, the ownership, the data isolation — is the subject of modular monolith vs microservices. This post assumes you have a seam and asks what you do with that seam next.
How does the strangler fig pattern work on a live system?
You put a façade in front of the old system and move routes behind the façade, one at a time, until nothing routes to the old system and you can delete the façade. Your clients keep the same address the whole way through, which is what keeps your migration invisible to your clients.
The mechanism is unglamorous, and Azure's pattern description is exact about the moving parts: A façade (proxy) intercepts requests that go to the back-end legacy system. The façade routes these requests either to the legacy application or to the new services.
Start with the façade routing everything to the old system, then shift routes, then shift percentages inside a route.
Fowler's version of the idea is older and less mechanical: grow the new system around the edges of the old one until the old one is strangled. Growth is your unit of progress. A route moved is growth. A design document approved is not.
Your new component often has to honour a contract nobody wrote down, which the catalogue names Legacy Mimic: the new system interacts with the legacy system in such a way that the old system is not aware of any changes. Expect to build that mimic deliberately rather than discover the contract in production.
Then pay the cost honestly. AWS says the proxy layer can become a single point of failure or a performance bottleneck
, and both are true the moment every request in the company passes through a component one team added last quarter. Treat your façade as production infrastructure from day one: capacity, alerts, an owner, a runbook.
And plan the façade's death. Azure's fourth phase removes the façade once every request goes to your new system; that phase is part of the pattern, not an afterthought. A façade with no removal ticket is not a migration. You have simply built a new permanent tier that nobody agreed to own.
How do you replace an implementation in place with branch by abstraction?
You put an abstraction between your callers and the thing you are replacing, build a second implementation behind your abstraction, move the callers across one at a time, then delete both the old implementation and the abstraction. Fowler's description has four moves, and the load-bearing sentence is Use an abstraction layer to allow multiple implementations to co-exist in the software system.
This is the technique for replacements with no network edge: a library, a framework, an internal component, an ORM, a payment client. You cannot put a façade in front of a function call, so you put an interface where the call is and hide both implementations behind your abstraction.
The order that works is: abstract, implement, switch, delete. Add the abstraction while the old implementation is still the only one, then build the new implementation behind the abstraction. Switch callers in slices, with a toggle deciding which implementation answers for which caller. Then delete.
Pete Hodgson's feature toggles article is the honest companion here, because a toggle is what buys you coexistence and a toggle is also what you will forget. Toggles let your teams modify system behavior without changing code, and they introduce complexity, which is why he recommends constraining the number you keep. A migration toggle is temporary by definition, so give the toggle the same deletion date as the old implementation.
The last step is what separates a real branch by abstraction from a permanent indirection layer. Once your old supplier has no callers, delete the old supplier. Then look hard at the abstraction: if the abstraction exists only to support the migration, delete that too. Code that survives a migration without a reason is a tax with a nice comment.
How do you change a database schema while both versions are live?
Expand, migrate, contract. Add the new structure so old code and new code both work, move the code and backfill the data, then remove the old structure. Danilo Sato's name for this is parallel change, also known as expand and contract
, and his list of applications puts database refactoring second, right after method signatures.
Your expand step is additive only: a new nullable column, a new table, a new view. Nothing reads it yet. Writes go to both the old and the new structure, and a backfill job fills in history in batches so your primary database is not asked to do the whole move in one transaction.
Stripe's account of moving hundreds of millions of Subscriptions objects
is the best public walkthrough of the full sequence: dual write, switch reads, switch writes, delete old data. The scale arithmetic in that post explains why you batch and go offline: at one second per object, migrating one hundred million objects would take over three years.
The detail most teams miss is the order of the two writes. Stripe began by writing the old store and copying to the new one, then reversed the direction — writing the new store first, archiving into the old one — before removing the old write entirely. That reversal is not cosmetic. Whichever store you write last is the store a crash can leave stale, so you choose which one you are willing to lose, and you make that the one you can rebuild.
Then make your old path complain. Stripe's write-up describes guarding the retired field so that any code still reading the field raises an error rather than quietly serving stale data. Run that guard before the contract step, not after: it is your migration's smoke alarm.
Your contract step is the drop: indexes, columns, tables, that awkward join nobody wanted, the deploy step that still references the old column. On MySQL the online part is solvable without a maintenance window, since GitHub's gh-ost is a triggerless online schema migration tool
built for exactly this. The tooling is the easy half. The calendar is the hard half.
How do you prove the new path is correct before you trust it?
You run both paths on real requests and compare the answers. Not in staging, not in a test environment with fixtures you wrote — on production traffic, with the old path still deciding. That is the only test whose inputs you did not choose.
Parallel run, shadow traffic, or an experiment framework?
A parallel run calls both and compares. Shadow traffic copies the request and throws the response away. An experiment framework wraps the comparison inside your code. Pick by where your switch lives, not by which one sounds more advanced.
Zalando's returns team documented a parallel run while extracting a service from a monolith: every request to the old endpoint was also forwarded to the new service, results went to Prometheus and Grafana, and an endpoint was declared ready only when the new service matched a consistency percentage the team set for that endpoint. The team states the cost in its own limitations list: increased load, potentially doubling.
Shadow traffic is the cheaper cousin. Istio's mirroring sends a copy of live traffic to the new service, out of band of the critical request path,
and the mirrored requests are fire and forget, which means that the responses are discarded.
You learn about errors, latency and capacity from real traffic without any risk to your client. You learn nothing about whether the answer was right, because nobody looks at it.
An experiment framework is the code-level version. GitHub's Scientist runs your original code path as the control and the rewritten one as a candidate. Both paths execute with the order randomized to avoid ordering issues, the control's result is what your caller gets, and every mismatch is recorded. Stripe used it twice in the Subscriptions migration, first to verify the read paths and later to catch inconsistencies when the write paths changed.
A canary applies the same pattern to users rather than responses: Sato calls canary release an application of parallel change, where the migrate phase lasts until all users have been routed. You are not comparing answers there. You are comparing error rates, which is a weaker question asked more cheaply.
What can a comparison not tell you?
A comparison cannot tell you which difference is a bug. Zalando gave each endpoint its own tolerance, on the reasoning that fixing those last few percentages has a cost higher than the value it brings.
Some response headers carry no meaning, PDFs differ in metadata, collections come back in a different order, and a framework upgrade changes defaults nobody documented.
Write three limits into your plan before the first comparison runs. A non-idempotent request cannot be executed twice without an idempotency key, so your POSTs need a reason before they need a shadow. Comparison means storing payloads, which means personal data in a second place — Zalando names GDPR explicitly as a constraint, and that is a privacy decision rather than a debugging one. And a comparison at 100% of traffic doubles your load exactly when the system is already under strain, so ramp it.
What breaks when you plan a big-bang cutover?
Everything the plan deferred arrives on the same day. Big-bang is not a discipline problem. The problem is structural: one release converts every unknown in your system into a single correlated risk, and unknowns are the one thing a long migration accumulates.
The legacy displacement catalogue names your trap precisely. Teams try to reach Feature Parity
with the current system. Defining the current behavior turns out to be an enormous effort, and per the authors that led to a plan for a large single 'big bang' cut-over release.
When the programme then meets a change in business needs, the authors report organizations where the waterfall big-bang approach meant abandoning the majority of the work.
Joel Spolsky wrote the canonical version of this in 2000 about Netscape, and his sentence has not aged: Netscape made the single worst strategic mistake that any software company can make
when the company decided to rewrite the code from scratch. His argument is not that rewrites fail technically. Your old code contains accumulated knowledge that no specification captures, so a rewrite re-learns it at full price while your market keeps moving.
I would add the property that decides whether a cutover is survivable at all: can you point traffic back? Asset capture, another pattern from the same catalogue, works because assets can be migrated from the old application to the strangler and back again, and reverse migration is very effective at reducing risk. A migration with no reverse route is a bet. A migration with a reverse route is a switch.
One honest exception, because the case shows where big-bang is legitimate. Shopify deliberately moved roughly 6,000 Ruby classes into new component directories in one pull request. The safety came from the nature of the change, in the team's own sentence: the changes introduced were just file moves,
so failures could only be lookup errors and CI could see them.
Move code in one commit. Change behavior in small ones.
Why do dual writes drift, and what reconciles them?
Because two writes are two transactions, and the second one can fail on its own. Debezium states the problem without softening it: your database and your broker cannot share a transaction, so either the order exists without the event or the event exists without the order. We cannot have one shared transaction
is the whole failure mode, and the failure does not care how careful your code looks.
The outbox pattern is the standard answer for the event case, and Chris Richardson's catalogue entry states your requirement directly: the command must atomically update the database and send messages to avoid data inconsistencies. One local transaction writes state and event; a relay publishes afterwards.
During a data migration your second write is usually another table rather than a broker, and the ceiling is the same. Two stores, two commits, one crash window. A dual write is not a consistency mechanism. The pattern is a bet that the window stays small, and a bet that somebody notices when the window grows.
Reconciliation turns the bet into a control. Every dual-write migration needs three things: a query that compares the two stores, a number that says how far apart they sit, and a repair job with a schedule. If your dual write has no reconciliation query, you do not have a migration in progress. You have two databases with a shared assumption.
This is where comparison frameworks earn their second keep. Stripe compared the write paths, not only the reads, because the write path is where drift starts and where a mismatch is cheapest to catch.
What happens to a migration with no deletion date?
Your old path becomes permanent, and you now pay for two systems. Not for a quarter — forever, with two sets of tests, two failure modes, two on-call surfaces, and every future change implemented twice or implemented once and quietly inconsistent.
Google's deprecation chapter names the two ways this ends. Advisory deprecation has no deadline and often lacks enforcement: we hope that clients move, but can't force them to
. Compulsory deprecation usually comes with a deadline for removal,
and the chapter is blunt about what turns a date into a plan: its schedule needs to have an enforcement mechanism.
Sato says the same from the pattern side: if the contract phase is not executed, you might end up in a worse state than you started,
which is why the pattern asks for discipline to finish. Two mechanisms coexisting is the midpoint of a parallel change, not its destination.
Transitional architecture is defined by its own removal: software elements installed to ease the displacement of a legacy system that we intend to remove when the displacement is complete.
The same article refuses to pretend the work is free, and tells you that during a displacement you will have to invest in work that will be thrown away.
Budget for the throwing away, or your temporary architecture quietly becomes the architecture.
How do you enforce a deletion date?
You make the old path fail loudly, count your remaining callers, and put the removal in the same backlog as the feature work. A date in a document is a wish. A date with a mechanism is a plan.
Three mechanisms do the work, and all three are cheap:
- An error on use. Any read or write against the retired path raises, in production, with a stack trace that names the caller. Stripe did exactly this with the old subscription field, and the error turns
nothing uses this
into a list. - A counter per caller. One metric per remaining client, visible to the team that owns the migration. When your counter reaches zero for a full business cycle — the monthly job, the quarterly report — you have a green light instead of an opinion.
- A removal ticket with the priority of a feature. The date goes in the tracker with an owner and a quarter. The contract phase is a work item, not the cleanup somebody gets to after the roadmap.
That leftover transitional code is where the distinction in technical debt vs architectural debt stops being academic. A dual-write path left running is not a shortcut you pay down later at the same price. The leftover path is a standing commitment to keep two models correct, and the bill grows with every feature you ship.
What does evolving architecture cost, and when is a rewrite the better call?
Two live paths cost real money, and the bill arrives in load, in availability, and in attention. The load is measurable: Zalando's parallel run notes the increase can approach doubling. The availability exposure is your façade, which AWS warns can become a single point of failure. Attention is the one nobody budgets, and it decides whether you finish.
Shopify's account of its own modularization is the clearest statement of that. Making a fundamental architecture change to a system under active development is in large part a people problem,
and the program ran for years against a codebase of over 2.8 million lines of Ruby code and 500,000 commits.
Shopify chose the incremental route because the alternative had no credible end state, not because the incremental route was quick.
So there is a size below which this is the wrong tool, and AWS says it out loud: for small applications where a full rewrite is cheap, it might be more efficient to rewrite the application …
You can put that line where the system stops being worth preserving — no users depending on legacy quirks, no data you cannot export, no team whose knowledge lives only in the code. Below your line, a rewrite is one decision, and one decision is fine.
Above your line, the honest comparison is not rewrite versus evolve. You are choosing between one large correlated bet and a series of small reversible ones. I know which side I argue for in a review, and the Gantt chart is not on my side.
What should you do in the first two weeks?
You pick one capability, find its seam, and ship the comparison before you ship the replacement. That order is the whole post in one line, and small enough to start on Monday.
- Write the outcome and the delete date. One page: capability, seam, what
done
means, and the date the old path dies. GOV.UK's repos keep these as architecture decision records, and a 2025 example approves removing Sidekiq from the RabbitMQ consumers. Small decisions, written down, dated. - Place the seam and prove it carries traffic. Route, interface, view or message — put the seam in front of the old implementation first, and move no behavior while you place the seam.
- Build the comparison. A shadow, a parallel run, or an experiment wrapper, with per-endpoint counters for matched, unmatched and failed, and a tolerance you chose before you saw the numbers.
- Shift a percentage, then let it sit. Your first percentage is a load test and a correctness test at once, and the failures you find there are the cheap ones.
- Contract on the date. Delete the old path, the toggle and, where the migration is the only reason the abstraction exists, the abstraction. Then write down the next capability.
No single step is hard. The trouble is that step five has no champion once the interesting work is done, which is why the date belongs in your tracker before the work begins.
What this post is not — and what to read instead
Boundaries, service counts, consistency theory and small-system design each have their own post; this post owns movement. Three siblings, one clause each:
- How to design a distributed system is the ordered procedure for the place you are moving toward — where state lives, how services talk, what breaks first.
- Ecommerce architecture at 100 users is the counterweight for a system that has not earned a migration, and the honest answer for most greenfield work.
- How to make better architecture decisions is how your seam, comparison rule and deletion date become a record instead of a memory.
Boundary enforcement belongs to the modular monolith post, and the one-deployable-versus-many ledger belongs to the monolith comparison. Neither is reopened here.
Related Reading
Monolith vs Microservices: The Trade-offs
The monolith vs microservices results are two-column tables, and the tables are the problem monolith vs microservices is
Modular Monolith vs Microservices: The Boundary You Can't Fake
The modular monolith vs microservices SERP stops at the folder diagram Every recent modular monolith vs microservices
Technical Debt vs Architectural Debt: A Triage Ledger
A missing index on one query and a shared database with eleven owning teams both get filed under technical debt.