Why rewrites fail
The plan is always the same: build the new system alongside the old one, then switch. It fails for structural reasons, not for lack of effort.
- The old system keeps changing, so the target moves for the entire duration.
- Undocumented behaviour is discovered only in production, after cutover.
- No value ships until the very end, so the project is politically fragile.
- Rollback means reverting months of work — which means there is no rollback.
The strangler fig idea
Put an interception layer in front of the monolith. Move one capability at a time behind it. Each move is small, independently shippable and independently revertible. The monolith shrinks until deleting it is uneventful.
Step 1: put a façade in front
Before migrating anything, route 100% of traffic through a proxy or gateway that currently forwards everything to the monolith. This changes nothing functionally and is therefore safe to ship on a Tuesday.
routes: - path: /* -> monolith # everything, for now # later, one line at a time: - path: /products/* -> catalog-svc - path: /search/* -> search-svc - path: /* -> monolith
You now own the routing decision. Without this layer, every migration requires client changes, and clients you do not control (old mobile builds) will never change.
Step 2: choose the first slice
The first slice sets the tone for the whole programme, so pick for learning value, not for impressiveness.
| Property | Good first slice | Bad first slice |
|---|---|---|
| Coupling | Few reads of shared tables | Joins across half the schema |
| Risk | Read-heavy, degrades gracefully | Payment capture |
| Clarity | Obvious bounded context | 'Utils' or 'core' |
| Size | Ships in weeks | Ships in quarters |
| Value | Something that is currently painful | Something nobody uses |
Product catalogue reads, search, notifications and reporting are common first choices. Do not start with the ledger.
Step 3: the hard part is the data
Splitting code is a refactor. Splitting data is the migration. Options, in increasing order of independence:
- Shared database, new service. Fastest, and a trap if you stop here — you have a distributed monolith with two deploy pipelines and one schema.
- New service owns writes, monolith reads a replica or view. Clear ownership, read-only coupling.
- Dual write during transition. Both stores updated, reads from one, with a continuous reconciliation job comparing them.
- CDC-fed sync. Change data capture keeps the new store current without application dual writes; usually the cleanest for large tables.
// nightly, and after every deploy
const drift = await compare({
left: monolith.products.checksumByPage(),
right: catalogSvc.products.checksumByPage(),
});
if (drift.length) alert("catalog drift", { pages: drift.length });Step 4: shadow, canary, cut over
- Shadow traffic. Send a copy of production reads to the new service, discard its response, and diff it against the monolith's. This finds the undocumented behaviour before users do.
- Canary. Route 1% → 5% → 25% → 100%, watching error rate and latency at each step.
- Keep the flag. The route switch must be a config change that reverts in seconds, not a redeploy.
- Hold the old path warm for at least one full business cycle — month-end reveals code paths nothing else does.
Step 5: actually delete the old code
This is the step teams skip, and skipping it is what turns a strangler migration into permanent dual maintenance. Once traffic has been at 100% for a cycle:
- Delete the monolith's implementation, not just its route.
- Drop the abandoned tables — after a verified backup and a waiting period.
- Remove the dual-write code and the reconciliation job.
- Record the date. Sequencing evidence of completed slices is what keeps funding alive.
Pitfalls
- The distributed monolith. Services that must deploy together are worse than the monolith, because now you have network calls too.
- Migration fatigue. Three slices in, momentum dies and you live in both worlds forever. Timebox and sequence explicitly.
- Rewriting behaviour while migrating. Move first, improve second. Changing both at once makes every diff ambiguous.
- No façade. Without the routing layer, you are doing a rewrite with extra steps.
Interview framing
“First I put a routing façade in front of the monolith with no behaviour change. Then I take catalogue reads as the first slice — low coupling, read-heavy, degrades gracefully. The new service is fed by CDC initially so the monolith stays authoritative, I shadow production reads and diff responses, then canary 1% to 100% behind a flag that reverts in seconds. Once it has survived a month-end I flip write ownership and delete the monolith's implementation.”