Architecture · Migration

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.

By Michael BoltonOctober 20, 20268 min read

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.

Before and after architectureBefore: clients call a single monolith backed by one database. After: clients call an API gateway that routes to the shrinking monolith and to new Orders and Billing services, each with its own data store.BEFOREAFTER (mid-migration)Web & MobileMonolithCatalogOrdersBillingAccountsone deploy · one codebaseShared DBWeb & MobileAPI Gateway / RouterMonolithCatalogAccountsOrdersnew serviceBillingnew serviceLegacy DBOrders DBBilling DB
Figure 1. Before and after. The gateway becomes the single front door; extracted capabilities (teal) get their own deploys and data, while the monolith (amber) keeps serving whatever hasn't moved yet.

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.

gateway/routes.yaml
# 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.

Request flow during migrationSequence diagram: the client sends GET /api/orders/42 to the gateway. The gateway forwards to the Orders service for the canary slice; on error it falls back to the monolith. A shadow copy goes to the monolith and responses are compared.ClientGatewayOrders svcMonolith1 GET /api/orders/422 match route · 25% canary3 forward (canary)4 shadow copy (async)5 200 OK (or 5xx / timeout)alt on error6 fallback → monolithresponse served from legacy path7 diff shadow vs. live, log8 response
Figure 2. One request during the canary phase. The gateway sends a slice of traffic to the new Orders service, mirrors a shadow copy to the monolith for comparison, and falls back to the legacy path if the new service errors or times out.
✓

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:

  1. 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.
  2. 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.
  3. 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.
Phased migration timelineFive phases from left to right: Seam, Shadow, Canary, Cut over, Retire. Each phase has an exit criterion. Rollback is available through the Cut over phase.01234SeamShadowCanaryCut overRetireGateway in front,100% → monolithexit: no latency changeMirror requests,compare responsesexit: diffs ≈ zero1% → 10% → 50%with auto-fallbackexit: error budget holds100% to service,new DB owns dataexit: stable for 2 releasesDelete legacycode & tablesexit: module gonerollback window: flip the route back to the monolith at any pointone-way door
Figure 3. The phases for a single capability, each with an explicit exit criterion. Everything up to cut-over can be reversed by changing a route. Deleting the legacy code is the only one-way door, so it comes last.

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.

Architecture