From Monolith to Modular: Using the Strangler Fig Pattern Without Breaking Production
A big-bang rewrite asks you to bet the business on one cutover weekend. The strangler fig lets you replace a system one route at a time, with a rollback plan at every step.
Most monoliths don't fail all at once. They get slower to change. Every deploy touches everything, the test suite takes most of an hour, and nobody wants to own the module that three other modules quietly depend on. Eventually someone proposes the rewrite.
I've come to believe the rewrite is usually the wrong move, not because the new design is bad, but because it asks you to freeze the old system, rebuild its behavior from memory, and switch over in one risky step. The strangler fig pattern, named by Martin Fowler after a vine that slowly grows around a host tree, offers a calmer path: put a seam in front of the monolith, move capabilities out one at a time, and let the old system shrink until it can be retired.
The shape of the migration
The key move is introducing a routing layer, usually an API gateway or reverse proxy, that every request passes through. On day one it does nothing interesting: it forwards everything to the monolith. That boring first step is what makes the rest possible, because once you control the front door, you can decide request by request where traffic goes.
Notice what the clients don't see: nothing. Their URLs stay the same. That's the property you protect above everything else. If a migration step requires a client change, it isn't a strangler step. It's a coordinated release, with all the risk that implies.
Routing traffic one slice at a time
Pick a capability with clear boundaries and real value, ideally something that changes often and is painful to deploy inside the monolith. Build the new service behind the gateway, then shift traffic gradually. I like to start with read paths, run new and old side by side, and compare their responses before the new service is allowed to answer for real.
# Strangler routing: the most specific match wins; the monolith is the default. routes: - match: { path: /api/orders/*, method: GET } backend: orders-service rollout: percent: 25 # canary slice; the rest stays on the monolith fallback: monolith # on 5xx or timeout shadow: true # mirror requests, compare, discard result - match: { path: /api/billing/* } backend: billing-service rollout: { percent: 100 } # fully migrated - match: { path: /* } backend: monolith # everything else, for now
The config is the migration plan, written down. Anyone on the team can read it and know exactly which system owns which route today, and rolling back is a one-line change instead of a redeploy.
Tip: make rollback boring. Before you send any real traffic to a new service, rehearse turning it off. If reverting a route takes a config change and a minute, people will migrate confidently. If it takes a hotfix and a meeting, they won't, and the migration will stall halfway.
The data is the hard part
Routing HTTP traffic is the easy half. The harder half is the data the monolith shares across modules. A new service that still reads the monolith's tables hasn't really been extracted. It's a remote module with extra latency.
A sequence that tends to work:
- Read through an interface. Inside the monolith, put an internal API in front of the tables the capability owns, so other modules stop querying them directly.
- Dual-write, then backfill. The new service writes to its own store while changes from the legacy tables are replicated across, through change-data-capture or events. Backfill history, then reconcile the two until the counts and checksums agree.
- Flip ownership. Make the new store the source of truth, reverse the sync direction for any remaining legacy readers, and set a date to remove them.
Knowing when you're done
The most common way these migrations fail isn't a production outage. It's that they never finish. The new services ship, the urgent pain goes away, and the monolith lingers forever as a half-alive dependency that still needs patching, monitoring, and someone who remembers how it works.
A few habits help:
- Count the routes. Track how much traffic still defaults to the monolith. It's a simple number, and a stalled number is an early warning.
- Treat deletion as a deliverable. A capability isn't migrated until its legacy code and tables are gone. Put that in the plan with an owner and a date.
- Don't extract everything. Some parts of the monolith are stable, rarely change, and work fine. A well-structured modular monolith is a perfectly respectable end state.
The strangler fig isn't glamorous. There's no launch day, no single moment where the new system goes live. That's the point. Each step is small enough to reason about, cheap to reverse, and safe to ship on a Tuesday afternoon. String enough of those together, and one day you notice the old tree is gone.