Strangling a Legacy Monolith Without Stopping the Business: The Pattern in Practice
Propose rewriting a monolith from scratch after it has spent years in production, and the idea rarely survives the first serious conversation about budget and risk. The business cannot pause the system that bills customers, handles support, or processes orders while a team rebuilds everything in parallel, and a full rewrite tends to run far longer than estimated -all while the old system keeps receiving changes the new one then has to catch up on. The strangler fig pattern sidesteps that dilemma: instead of rewriting and then swapping over, you pull modules out of the monolith one at a time, route the matching traffic to the new service, and leave the monolith running in production until, piece by piece, it has nothing left to do.
The name comes from the strangler fig tree, which grows around a host tree until it fully replaces it, without the host ever failing to hold it up during the process. The metaphor maps directly onto the technical design: the new system grows by wrapping the old one, not by swapping it out in one move.
The piece that makes gradual migration possible
The centerpiece of the pattern isn't the new service, it's the router that decides, for every incoming request, whether the monolith or the newly extracted service handles it. Without that piece there is no incremental migration: any extraction would force moving all the traffic at once.
In practice this shows up as a reverse proxy layer sitting in front of both systems, with routing rules keyed on path or business resource:
# nginx as a facade layer during the migration
location /api/catalog/ {
proxy_pass http://catalog-service:8080;
}
location /api/ {
proxy_pass http://legacy-monolith:8080;
}
Putting the specific route ahead of the generic one isn't a minor detail: nginx matches location blocks by specificity, so every new extraction needs its own explicit block, or traffic quietly keeps landing on the monolith even after the new service is deployed. That is an easy mistake to make on a first extraction: the team ships the service, tests it in isolation, and because nobody updated the routing rule, real traffic never actually reaches it.
For cases where the routing decision depends on more than the path -say, migrating a subset of customers or tenants ahead of everyone else- that logic moves into an application layer instead of static proxy config:
function resolveHandler(req) {
if (req.path.startsWith('/api/catalog') && isMigratedTenant(req.tenantId)) {
return catalogServiceClient;
}
return legacyMonolithClient;
}
This version supports a progressive rollout tenant by tenant, with the option to roll a single tenant back to the monolith if something breaks, without touching routing for anyone else.
Which module to extract first
The criterion we apply isn't the module most urgently in need of a rewrite, nor the smallest one because it looks easiest. It's the module with the clearest domain boundary and the least data coupling to the rest of the system. A module that only reads and writes its own tables, with no direct joins against tables owned by other domains in the same monolith, is a far better candidate than one that looks independent in the code but shares a database schema with half a dozen other features.
The reason is that the real difficulty of an extraction is almost never in the business logic -that can be rewritten fairly mechanically- it's in untangling the data dependencies. A module with data that's already isolated can be extracted with its own database from day one. A module with entangled data forces you to decide, before writing a single line of the new service, how synchronization will work while both systems coexist, and leaving that question unanswered is exactly what produces the hardest bugs to track down later.
Two sources of truth: a risk you can't remove, only contain
For as long as the migration lasts -which can stretch across months depending on how many modules are left to extract- there's a stretch where the same piece of business data can exist in both the monolith's database and the new service's database. Ignoring that window and assuming synchronization is something to "figure out later" is the most common way a strangler fig migration stalls halfway through.
The approach that worked for us is treating the monolith as the source of truth until the extracted module is fully independent, and using a transactional outbox in the monolith to publish relevant changes as events the new service consumes:
-- Inside the same transaction that updates the business table
INSERT INTO outbox_events (aggregate_id, event_type, payload, created_at)
VALUES ($1, 'catalog.product.updated', $2, now());
A separate process reads the outbox_events table and publishes each row to a queue, then deletes it or marks it processed. Writing the event inside the same transaction as the business change matters because it rules out a monolith change ever slipping through without its matching event: if the transaction fails, neither the change nor the event exists; if it succeeds, both exist atomically.
The new service consumes those events to keep its own copy of whatever data it needs, but during the transition it still isn't the source of truth: any write the new service needs to make against data the monolith still owns gets forwarded back to the monolith rather than applied directly against the new service's own database. That asymmetry is awkward to carry in the code, but it avoids the much worse scenario of both systems accepting writes on the same data with no conflict resolution in place.
What didn't work: extracting a module before decoupling its tables
On one of our earliest extractions we tried to skip the work of decoupling the database schema, on the theory that the new service could query some of the monolith's tables directly while the full separation got sorted out later. The reasoning was to save time: the new service could go live without waiting on a separate data migration project.
The problem showed up fast. The monolith kept writing to those shared tables under its own validation logic and its own triggers, and the new service read them assuming invariants that logic guaranteed but that were documented nowhere outside the monolith's code. When a schema migration in the monolith touched one of those tables for a reason entirely unrelated to the extraction in progress, the new service started failing in a way nobody connected to that change right away, because from the perspective of the team touching the monolith that table already "belonged" to the new service. We rolled that module's routing back to the monolith while we sorted out the data decoupling, and only attempted the extraction again once the new service had its own schema and consumed data exclusively through events, never through direct access to tables it didn't own.
The rule we ended up with
The sequence we follow now inverts the intuitive order: decouple the data model of the module being extracted first, even when that means writing synchronization code that looks like it adds no new functionality on its own, and only then move the business logic into the new service. The temptation to extract the code first and sort out the data afterward is exactly what caused the problem described above. The domain boundary that actually matters for deciding extraction order isn't the one visible in the monolith's folder structure, it's the real boundary in the database schema, and that's the one that has to be resolved first.