How to Split a Monolith Without Stopping the Roadmap
Quick answer: Split a monolith with the strangler fig pattern: route a slice of traffic to a new service, leave the old code to wither, then delete it. Extract in order of risk — notifications, document generation, webhook receivers and provider integrations first, the payment core last. Split the data before the code: separate schemas and remove cross-domain joins inside the monolith first. Never share a database between services. Use the outbox pattern instead of dual writes, and reconcile both paths continuously whenever money moves.
A split is not a migration project. It is a series of small extractions, each of which has to pay for itself on its own — because the version where it doesn't, "we'll rewrite it over six months", has never once worked. The common outcome of a big-bang split isn't a failed split. It's a team stuck halfway forever, running two architectures and paying for both.
This article assumes the decision is made. If it isn't, when to split a monolith into microservices covers the signals that warrant one and the ones that only look like they do. The running example is a cross-border money transfer product, because payments make every mistake expensive enough to be instructive.
What does the strangler fig pattern actually mean?
The name comes from a fig that germinates in another tree's canopy, sends roots down around the trunk, and eventually leaves a hollow fig-shaped column where the host used to be. The metaphor is unusually accurate: you grow the new path around the old one, move traffic across a slice at a time, and the old code dies of disuse rather than demolition.
The mechanism is a routing decision. Something in front of the old implementation — an API gateway, an HTTP router, or most usefully a plain interface inside the monolith itself — decides per request whether the work goes to the old code or the new service. On day one everything goes to the old path. Then one country. Then one customer segment. Then all of it. Then you delete the old code.
Putting that seam inside the monolith first is what makes the rest cheap. Before extracting anything, introduce an interface with a single local implementation and route all calls through it. That refactor is compiler-checked, ships in a day and changes no behaviour. Later the second implementation is a thin client calling a service over the network, and switching between them is a flag rather than a deploy.
Three things follow, and they are the whole argument for this approach:
- There is no cutover date. Nothing is scheduled for a weekend; nothing needs everyone online at once.
- Every step is reversible. If the new path misbehaves you flip the flag back — a config change, not a redeploy and not a data restore.
- Almost nothing is rewritten. You are moving working code behind a network boundary. Rewriting and redeploying at the same time means that when something breaks, you can't tell which change caused it.
Which piece should you extract first?
The wrong answer, chosen surprisingly often, is the payment core — on the reasoning that it's the most important part so it deserves the good architecture first. That is backwards. Your first extraction is where you make every platform mistake: service scaffold, pipeline, secrets, deployment, tracing, alerting, contract tests, local development. Make those mistakes on notifications, where the worst case is a delayed email.
Extract outward-in: pieces loosely coupled to the core data, whose failure is visible but not financial.
| # | Candidate to extract | Why it belongs here | What the team learns on it |
|---|---|---|---|
| 1 | Notifications (email, SMS, push) | Write-only, owns almost no shared data, failure is annoying rather than expensive | Service template, pipeline, async messaging, retries |
| 2 | Document generation (statements, receipts) | Bursty and CPU-heavy, already batch-shaped, nobody reads its tables | Independent scaling, object storage, idempotent workers |
| 3 | Webhook receivers (bank and PSP callbacks) | Genuinely different reliability target — must accept callbacks mid-deploy | Idempotency at the edge, durable inbox, replay |
| 4 | KYC / provider integrations | Vendor-shaped model you want out of the core anyway; the boundary is obvious | Anti-corruption layer, contract testing, vendor failover |
| 5 | Reporting and analytics reads | Read-only; can start against a replica or event stream before owning anything | Event streams, eventual consistency in the UI |
| 6 | FX quoting / rate engine | Read-heavy, cacheable, load profile unlike anything else in the product | Latency budgets, caching, graceful degradation |
| 7 | Payments core and ledger | Last, and only once the earlier extractions proved the platform works | — |
Webhook receivers often deserve to move earlier than their position suggests, because "we must accept a settlement callback while we deploy" is a real availability requirement rather than tidiness. Reporting, meanwhile, is frequently the piece blocking everything else — see the FAQ.
Why do you split the data before the code?
Because a shared database between two "services" is not an intermediate step. It is a distributed monolith: you have taken on network calls, separate deployments, partial failure and eventual consistency, and kept every bit of the coupling you were trying to remove. It is strictly worse than the monolith you started with, and teams live in it for years because from the outside it looks like progress.
So the data moves first, while everything is still one deployable and one transaction — which is to say, while mistakes are cheap:
- Give each domain its own schema. One owner per table, enforced in CI rather than described in a wiki page.
- Remove cross-domain joins. This is the unglamorous majority of the work. A query joining
paymentstocustomersbecomes a call to the customer module's interface. Some get slower; that is information about a boundary you were about to draw badly. - Denormalise deliberately. If payouts needs the recipient's country on every request, it keeps a copy or receives it in the request. Duplication at a service boundary is a feature, not a smell.
- Drop foreign keys crossing the boundary, replacing them with application-level invariants and a scheduled consistency check.
- Only then move the tables.
Give reference data — currencies, corridors, fee tables, country rules — a single owner that publishes, rather than letting five domains join to it. Shared reference tables are how a clean decomposition quietly becomes a shared database again.
If the monolith was built with real internal boundaries, most of steps 1–4 are already done, which is the practical argument for modular monolith design. If it wasn't, this phase is where the honest cost of the split appears — before you've paid for any infrastructure.
How do you keep two datastores consistent without dual writes?
Once data lives in two places, something must tell the second place what happened in the first. The obvious approach is a dual write: commit the database transaction, then publish a message to a broker. Two operations, no shared transaction.
There are four outcomes and two are bad. The commit succeeds and the publish fails — downstream never learns the payment exists. Or the publish succeeds and the transaction rolls back — downstream acts on something that never happened. Both are rare, which is exactly the problem: rare enough that the handling code never gets exercised, and clustered around the moments when the broker is unhealthy, which is when volumes are unusual and attention is elsewhere.
The outbox pattern removes the second write. In the same transaction that records the business change, you insert a row into an outbox table in the same database. One transaction, so either both land or neither does. A separate publisher process then reads unsent rows, publishes them, and marks them sent.
If the publisher crashes between publishing and marking, the message goes out twice. That is fine: you get at-least-once delivery and consumers must be idempotent — which they must be anyway, because networks retry. What you never get is a business event that happened but was never announced, or an announcement of something that didn't happen.
The cost is one table, one publisher, care about ordering within a stream, and idempotency keys on consumers. In a transfer product the alternative cost is a customer whose money moved but whose ledger entry, payout instruction or notification never did.
What is an anti-corruption layer, and where do you need one?
Extracting the KYC provider integration comes with pressure to let the provider's vocabulary through: their status enum, their document type codes, their response shape. It's less code. Six months later their model is in your domain objects, your database columns and your admin UI, and their next breaking change is your migration.
An anti-corruption layer is a deliberate translation seam: their model in, yours out, with exactly one component in your system that knows the vendor exists. It costs a mapping layer and tests written against recorded real responses. It buys the ability to add a second provider, or swap the first, without touching the core.
The same rule applies internally. An extracted service's public contract is not its internal schema. Expose your tables as an API and you have made your storage layout a public contract you can never change — the coupling you just spent a quarter removing.
How do you extract without freezing the roadmap?
A freeze turns a technical project into a political one. The moment revenue work is blocked on a migration a clock starts, and when it runs out the migration is cancelled wherever it happens to be. So the constraint is non-negotiable: features keep shipping.
- Size extractions to fit alongside feature work. If a piece can't be finished by part of the team in a few weeks, it isn't an extraction, it's a project. Cut it smaller — the read path first, or one provider rather than all of them.
- One extraction at a time. Concurrent extractions multiply half-states, and half-states are the thing you're avoiding.
- Flag at a granularity you can reason about: staff accounts, one country, one merchant, then a percentage. Percentage alone is a poor first choice for money movement, because support can't tell which customers are on which path.
- Run both paths in parallel before cutting over. In shadow mode the new service does the work, its output is compared with the old path's, and only the old result is used. Log every difference; cut over when differences are zero or individually explained. With money this step isn't optional — and each discrepancy is either a bug in the new path or an undocumented behaviour of the old one, both cheaper to find here than in production.
- Deleting the old code is part of the extraction, not a follow-up ticket.
We've worked under exactly that constraint building and scaling a real-time market surveillance platform for regulated markets, delivering new microservices into an existing pipeline across three production regions. Nothing was frozen and nothing cut over globally at once; each region moved when its comparison was clean.
How long does an extraction take, and how do teams end up halfway forever?
The first extraction is the slowest by a wide margin, and almost none of that time is domain work. It's the service template, the pipeline, secrets, tracing, dashboards, alerts, the on-call runbook and the local development story. Budget it as platform work with a small feature attached. The second reuses all of it; the third is routine. Teams that judge the whole programme by the first one usually give up right where it starts paying.
"Halfway forever" has a predictable mechanism:
- The new service goes live and takes most of the traffic.
- The old code stays "for the edge cases", because deleting it is scary and invisible to anyone outside the team.
- A feature arrives that touches the domain, so it must be built twice — or, under deadline, once in the old path because that's faster.
- The paths diverge. The old one quietly becomes the source of truth again, and the flag becomes permanent configuration nobody dares change.
- Repeat across three extractions and you have neither a monolith nor a service architecture. You have both, and you maintain both.
The cure is procedural, not technical. An extraction is not done when the new service is live. It's done when the flag is gone, the old code is deleted, the old tables are dropped and the runbook is updated. Put that in the definition of done — and if a piece is too large to reach that state, don't start it.
What changes when the thing you're splitting moves money?
Reconciliation between the old and new paths stops being good practice and becomes a requirement. During any transition touching money movement you must be able to prove, automatically and continuously, that both paths produce the same result: same payments created, same amounts and fees, same state transitions, same ledger effects.
- Compare against the ledger, not application state. The ledger records what happened; everything else describes what the system currently believes. If yours can't answer that question yet, that's a prerequisite — payment ledger design is the place to start.
- Make it three-way. Old versus new catches divergence. Old versus new versus the provider's statement catches the error both of your systems share, which is the one that reaches a customer.
- Carry idempotency keys across the seam, so a retry landing on the new path cannot duplicate a payment the old one already created.
- Keep rollback flag-only. If reconciliation breaks at 02:00, whoever is on call moves traffic back without a deploy, a migration or a decision meeting.
- Decide the stop condition in advance. "Any unexplained difference in money terms halts the rollout" is a line you can hold. "We'll take a look" is not.
Our founder spent two and a half years on Wise's core payment platform team, responsible for payment creation and the full payment lifecycle. At that scale extraction is never a one-off project — pieces move more or less continuously — and what makes it survivable is that each one is accompanied by reconciliation running whether anyone is watching or not.
FAQ
Do we need a message broker before we can extract anything?
No. A first extraction can be a synchronous call behind the interface you already introduced. Add a broker when an extraction genuinely needs asynchrony. The outbox pattern works with any transport, including a table polled by a worker, so you can adopt the guarantee before the infrastructure.
Can two services share a database temporarily, just during the migration?
"Temporarily" is the word that becomes three years. The only tolerable version is asymmetric and dated: one service owns and writes, the other reads a replica or a published view, with a deadline and the read-side migration already scheduled. Symmetric shared writes are a distributed monolith — a failed extraction, not a phase.
Should we rewrite in a different language while extracting?
No. Change one variable at a time. Extraction changes where code runs; a rewrite changes what it does. Do both and you can't attribute any difference in behaviour, which destroys the value of shadow comparison — the main safety mechanism available to you.
How do we handle reporting queries that used to join across every table?
Separately, and early. Reporting is the most common reason teams abandon data separation, because someone's query joins eleven tables across six domains. Publish domain events or change data capture into a dedicated reporting store and point the queries there. Don't let a reporting query define your production schema.
What if we can't find a clean boundary for a service?
Then the boundary is in the wrong place. The symptoms are consistent: a chatty API, needing the other service's data on nearly every call, or a distributed transaction appearing in the design. Move the boundary rather than the network — redrawing it inside the monolith is an afternoon, redrawing it between two deployed services is a month.
How do we know when to stop splitting?
When the reasons run out. Each extraction should be justified by something specific: teams contending over a release, a different reliability or compliance requirement, a genuinely different load profile. When no remaining candidate has one, stop. A modular monolith with four well-chosen services around it is a destination, not an unfinished migration.
Thinking about extracting your first service?
The useful conversation isn't about the target architecture. It's about which piece goes first, what has to happen to the data before it can move, how you'll compare the two paths while both are live, and what your definition of done looks like — because that last one decides whether you end up with a split or with two architectures.
GPO-Tech builds commercial software and connected products from Tallinn, Estonia, inside the EU and in European working hours. Our payments background is first-hand: two and a half years on Wise's core payment platform team, plus cross-border transfer products and a real-time market surveillance platform for regulated markets, where we delivered new microservices into an existing pipeline across three production regions without stopping delivery.
Tell us what you're running and we'll map the first three extractions with you. Ask a question → or book a call with our engineers →.
GPO-Tech designs and builds connected products and commercial software end to end — from one team in Tallinn, Estonia.