💥Why the big-bang rewrite keeps failing
Every monolith migration starts with the same seductive idea: freeze features, rewrite the system properly, cut over on a weekend. It fails for structural reasons, not lack of talent. The rewrite runs for eighteen months while the business keeps changing underneath it; the old system cannot actually be frozen, so the rewrite chases a moving target; and the cutover weekend arrives with two systems that have never run the same production traffic, compared only by hope.
The strangler-fig pattern — named by Martin Fowler after the fig tree that grows around a host tree and gradually replaces it — inverts the approach. The monolith stays live and keeps serving traffic. New and extracted capabilities are built alongside it, and a routing layer directs each request to whichever system currently owns that capability. Traffic moves route by route, each move is reversible, and the system is never down and never half-rewritten.
What "without downtime" actually requires is not uptime heroics but a property: at every moment of the migration, every request has exactly one correct owner, and that ownership can be flipped back in minutes. Everything in this guide — the routing layer, the data strategy, the rollback design — exists to preserve that property.
The strangler-fig insight that matters: the migration is never the project. The project is a series of small, reversible traffic moves, each boring on its own. If any single step requires a maintenance window, the design is wrong.
🗺️Step 1: map capabilities and pick the extraction order
Before any code moves, map the monolith by business capability — invoicing, user management, catalog, notifications, reporting — not by technical layer. Technical layers (controllers, services, models) are how the code is organized; capabilities are how the business is organized, and extraction boundaries that follow the business produce services that can actually be owned and deployed independently.
The map is built from evidence, not org charts: route tables and controllers show the entry points, foreign keys show which tables each capability touches, and production traffic data shows which capabilities are hot. A capability that shares its core tables with everything else is coupled no matter how clean its code looks; the database is where coupling actually lives, which is why the table-touch analysis matters more than the code structure.
Sequence extraction by two axes: business value (what separating this capability buys the business — independent deploys, scaling, team autonomy) and extraction difficulty (data coupling, shared logic, transactional entanglement). The first extraction should be low-difficulty and medium-value: you are buying organizational learning cheaply. Notifications, document generation, and reporting read-models are classic first candidates because they read more than they write and their data dependencies point one way.
| Candidate order | Typical capability | Why this position |
|---|---|---|
| First | Notifications, emails, PDF/document generation | One-way data flow, mostly reads and sends; cheapest place to learn the tooling |
| Early | Reporting and analytics read-models | Can be rebuilt as a read replica consumer; zero write risk |
| Middle | Catalog, search, content — read-heavy domains | Cacheable, tolerant of sync lag, clear ownership |
| Late | Billing, orders, inventory — transactional cores | Real money and real consistency requirements; needs mature tooling |
| Last | Identity/auth and the shared kernel | Everything depends on it; extract only when nothing else will teach you more |
🔀Step 2: put a routing layer in front of the monolith
The routing layer is the load-bearing piece of the whole migration, and it goes in before anything is extracted — while it still routes 100 percent of traffic to the monolith and changes nothing. That is the point: you validate the hop, the latency overhead and the operational story at zero risk, and every subsequent cutover is a config change, not a deploy.
In practice the router is a reverse proxy or API gateway — NGINX, Envoy, HAProxy, Kong, or a cloud load balancer with path-based rules — that maps URL paths (and occasionally headers or hostnames) to backends. /api/invoices/* goes to the new invoice service; everything else falls through to the monolith. Keep the rules data-driven and version-controlled, because this routing table becomes the living map of migration progress.
Design the router for the migration, not just for steady state. It needs percentage-based routing (send 5 percent of invoice traffic to the new service, then 50, then 100), header-based overrides (route your internal users first), and ideally request mirroring — sending a copy of production traffic to the new service and discarding its responses — which lets you compare behavior and load-test against reality before the new service answers a single real user.
| Routing mechanism | Strengths | Watch out for |
|---|---|---|
| Reverse proxy (NGINX/HAProxy) | Simple, fast, everyone can operate it | Rules live in config files; percentage splits need extra tooling |
| API gateway (Kong/Envoy-based) | Rich routing, auth plugins, observability | Operational weight; temptation to add business logic |
| Cloud load balancer path rules | Managed, zero new infra | Least flexible; mirroring and shadow traffic often unsupported |
| Service mesh (Istio/Linkerd) | Powerful traffic shaping, mirroring built in | Significant complexity; overkill until you have many services |
🗄️Step 3: the database split — last, and in stages
Every failed monolith migration has the same corpse: services extracted on top of a shared database. The services look independent in the architecture diagram and are welded together in the schema — a JOIN away from coupling, a migration away from an outage that spans "independent" services. The database is the actual monolith, and splitting it is the real project.
Do it in stages, and let the stages buy safety. Stage one: extract the service but leave it reading and writing the shared database, with a hard rule that it touches only its own tables. This is heretical to purists and invaluable in practice — it separates the risky thing (new service behavior) from the other risky thing (data movement) so you never debug both at once. Stage two: draw and enforce the table ownership boundary — revoke cross-schema access, replace cross-capability JOINs with API calls or replicated read models. Stage three: physically move the tables to their own database. Most of the benefit arrives at stage two; stage three is operational tidiness.
For data the new service needs but does not own, use change data capture — reading the monolith database transaction log with a tool like Debezium, or cloud-native equivalents — to publish changes into the service-owned read model. CDC gives you near-real-time replication without touching monolith code, which matters because the monolith is usually the codebase you least want to modify.
Legacy modernization servicesWhat legacy modernization costs
⚠️Dual-write pitfalls and how to avoid them
The dual-write problem is the single most common way migrations corrupt data. The scenario: during a transition, the system must write to both the old store and the new one. The application writes to the monolith database, then publishes an event or writes to the new service. The first write succeeds and the process crashes before the second. The systems are now permanently inconsistent, and nothing detected it.
There is no application-code fix for this, because the failure is fundamental: two writes, no distributed transaction. (Two-phase commit exists and is almost never the right answer at service boundaries — it couples availability of both systems and performs poorly.) The accepted solutions attack the atomicity differently.
The transactional outbox pattern is the workhorse: the write and an "event to publish" record are committed in the same database transaction, in the same database. A separate relay process reads the outbox table and publishes to the message broker, with at-least-once delivery. Consumers must be idempotent — the relay can deliver twice, so the consumer deduplicates by event ID. The alternative, CDC from the transaction log, achieves the same effect without an outbox table: the committed write itself becomes the event stream.
During cutover windows, run reconciliation as a first-class feature, not a debugging tool: a job that compares old-store and new-store state per entity and reports drift. The migrations that succeed treat drift as expected and measured; the ones that fail treat it as impossible and meet it in production, discovered by a customer.
| Pattern | How it works | Failure mode it prevents | Cost |
|---|---|---|---|
| Naive dual-write | App writes to both stores in sequence | Nothing — this is the anti-pattern | Silent, permanent inconsistency |
| Transactional outbox | Write + event committed in one transaction; relay publishes | Crash between write and publish | Outbox table, relay process, idempotent consumers |
| CDC (Debezium-class) | Transaction log itself becomes the event stream | Code forgetting to write the event | Connector infra; schema changes flow downstream |
| Two-phase commit | Coordinator locks both stores | Theoretically atomic | Availability coupling, lock contention — rarely right here |
Rule of thumb: if your migration plan contains the sentence "the application writes to both and we handle failures with retries," the plan has a data-corruption window in it. Outbox or CDC, every time.
↩️Step 4: design rollback before the first cutover
Rollback in a migration has two halves, and teams reliably design only one. Traffic rollback is the easy half: the routing layer points the path back at the monolith, and if you built the router as described, that is a config change measured in minutes. Data rollback is the half that gets skipped: during the window when the new service owned the traffic, it wrote data the monolith does not have. Flip the traffic back without a plan for those writes and users see stale or missing state — the rollback "worked" and the data is wrong.
The standard answer is reverse synchronization: while the new service owns a capability, its writes flow back to the monolith store via the same outbox/CDC machinery, keeping the monolith a valid fallback. This feels wasteful — writing everything twice for insurance — and it is the price of a rollback that is actually a rollback. The window can be bounded: many teams keep reverse sync for a defined stabilization period (two to four weeks is common), then retire it and accept that rollback past that point means a data migration, not a config flip.
Dark launching de-risks the moment before rollback matters: the new service receives mirrored production traffic, its responses are compared against the monolith responses, and discrepancies are logged rather than shown to users. A service that has matched the monolith on a week of real traffic is a very different cutover bet than one that passed a staging test suite.
🪜The full sequence, condensed
Pulling the sections together, a defensible migration run for one capability looks like the table below. Repeat it per capability, in the order from the capability map. The shape is deliberately boring: the entire methodology is an engine for converting one large terrifying change into many small dull ones.
Two cross-cutting practices make the repetitions compound. First, build the platform once and reuse it: the first extracted service should leave behind a service template — deployment pipeline, observability, auth integration, outbox support — that makes service number five a two-week effort instead of repeating the two-month first one. Second, keep a public migration scoreboard: capabilities extracted, traffic percentages, drift metrics. Migrations die of lost organizational patience more often than of technical failure, and visible progress is the counter.
What you do not do is as important: no feature freeze on the business (the monolith keeps evolving, and new features land in whichever system owns the capability), no extraction of everything (some capabilities may stay in a slimmed-down monolith indefinitely, and that is a legitimate end state), and no "cleanup phase" promised for later — strangulation that stops at 70 percent leaves you running two systems forever, which is strictly worse than one.
| Phase | Actions | Exit criteria |
|---|---|---|
| 0. Foundation | Routing layer live at 100% monolith; observability baseline; service template | Router changes are config-only; template deploys a hello-world service |
| 1. Shadow | New service built; mirrored traffic; response comparison | Discrepancy rate at zero or fully explained for ~1 week of traffic |
| 2. Canary | 1–5% real traffic to new service; internal users first | Error and latency budgets hold; drift metrics clean |
| 3. Ramp | 25% → 50% → 100% with soak time between steps | Full traffic on new service; reverse sync healthy |
| 4. Stabilize | 2–4 weeks reverse-synced fallback window | No rollback triggers; support tickets at baseline |
| 5. Decommission | Retire reverse sync; remove monolith code paths and tables | Monolith smaller; scoreboard updated; next capability starts |
⏳Honest timelines, costs, and when to hire a team
Timelines, stated plainly: the foundation — routing layer, observability, deployment pipeline, service template, first low-risk extraction — takes two to four months with a competent team. Each subsequent capability takes weeks to a couple of months depending on data coupling. A mid-size business monolith (hundreds of tables, a handful of major capabilities) typically runs twelve to twenty-four months of migration work, executed alongside feature development, not instead of it. Anyone quoting a full strangulation of a serious system in a quarter is describing the first two rows of the phase table.
Cost ranges at US-market rates, labelled as ranges because scope variance is enormous: the foundation phase typically lands at $80,000 to $200,000 of engineering; a full multi-capability migration program for a mid-size system commonly totals $300,000 to $800,000 spread over the timeline. The variables that move the number are data coupling (how entangled the schema is), test coverage in the monolith (none means you are building characterization tests before you can safely touch anything), and how much feature work must continue in parallel.
When to hire a team: when the monolith is revenue-critical and the in-house team has never run a strangler migration — the failure modes (shared-database services, dual-write corruption, rollback without data) are all known, all preventable, and all expensive to learn live. An experienced team brings the phase discipline, the service template, and the honesty about what should not be extracted.
We run legacy modernization as exactly this kind of program — capability mapping, routing-first architecture, staged database splits with reconciliation, and rollback designed before cutover. We have delivered 500+ projects since 2018 from Edmonton and Chandigarh, and modernization work is where boring methodology pays for itself most visibly. If you are staring at a monolith and a rewrite proposal, get a second opinion before the freeze date gets announced.
Legacy modernization servicesMicroservices vs monolith: the honest comparisonTalk through your migration with us