The 100 problems, solved / Data pipelines and operations / 091 · Migrate a monolith to microservices

Migrate a monolith to microservices

Carve a legacy core service apart in phases without a big-bang cutover.

Concepts this design leans on

01The design, in the six-step framework

1. Requirements

Functional.

  • A large legacy service (one codebase, one database, 300 engineers committing to it) handles accounts, catalogue, orders and payments for a product with millions of users.
  • Carve it into independently deployable services owned by teams, without a big-bang cut-over, while features keep shipping.
  • Each extracted service owns its data; cross-service operations that used to be one database transaction must still be correct.
  • Out of scope: the choice of language or framework; organisational changes beyond naming service owners.

Non-functional.

  • No user-visible regression: error rate and p99 latency within 5 percent of today throughout.
  • Each extraction is reversible for at least two weeks after it ships.
  • Deploy frequency per team goes from weekly (whole monolith) to daily (own service) by the end.
  • Program length: 18 months, with value delivered every quarter, not only at the end.
Back of the envelope

Today: 20,000 requests per second into one service tier, one primary database at 60 percent capacity with 10 TB of data, deploys of 2 hours that fail 1 in 10 times.

A request that used to make 3 in-process calls makes 3 network calls at about 2 ms each: +6 ms of latency per request, which is within the 5 percent budget only if p99 is above 120 ms today; measure first

Data split: orders and order items are 7 of the 10 TB; extracting them is the largest and riskiest move and should not be first. Accounts (200 GB, few joins) is a good first extraction.

2. Core entities

  • Bounded context: a named area (accounts, catalogue, orders, payments) with the tables and code paths it owns and the calls that cross its boundary.
  • Service: owner team, API contract, database, deploy pipeline, SLO.
  • Integration event: type (OrderPlaced, PaymentCaptured), producer, schema version, consumers.
  • Routing rule: path or operation → monolith or service, with a percentage and a cohort; the strangler switch.
  • Migration record: context, phase, start, rollback plan, owner.

3. API

  • Edge: an API gateway or reverse proxy in front of the monolith with route(path, method) → monolith | service rules, changeable without a deploy.
  • Each extracted service exposes a narrow contract, for example GET /accounts/{id}, POST /accounts, PATCH /accounts/{id}, versioned under /v1/, and publishes events like AccountUpdated on a topic.
  • Inside the monolith, an anti-corruption layer: the old code calls an interface (AccountsClient) whose implementation is first the local database and later the remote service, switchable by configuration.
  • Operator: POST /routes {rule, percent} and GET /routes/diff-report for shadow-traffic comparison.

4. High-level design

ClientsEdgeServicesDataaccounts routesvia clientAccountUpdatedWeb and mobileAPI gateway(strangler routing)Monolith(shrinking)Accounts service(extracted)Orders service(next)Event bus(integration events)Monolith DBAccounts DBOrders DB

Extracting one context, accounts, end to end:

  1. Draw the boundary: list the tables accounts owns, every piece of monolith code that touches them, and every join that crosses the line. Replace direct table access from other code with calls through an internal interface.
  2. Build the accounts service with its own database, initially empty. Migrate the data with a change stream and a backfill (the same pattern as any live migration), keeping the monolith's tables as the source of truth.
  3. Shadow: the gateway sends a copy of accounts traffic to the new service and compares responses; the interface inside the monolith does the same for internal calls. Fix diffs until the rate is zero.
  4. Switch reads to the service by percentage, then writes; the service now owns the data and publishes AccountUpdated events, which the monolith consumes to keep a read-only copy of what it still needs for joins.
  5. Delete the accounts code and tables from the monolith. Repeat for the next context, with orders last because it has the most joins and the cross-service transaction problem.

5. Deep dives

How do we split a database that everything joins against?

One table at a time, and never by letting two services share one database. The sequence is: find every query that joins across the future boundary; replace each with either a service call (if the data is small and the call is on a read path that tolerates 2 ms), a locally maintained read replica fed by events (if the join is hot or on a write path), or a redesign (the join existed because of the schema, not the business). The event-fed replica is the usual answer: the orders service keeps a small copy of customer name and tier, updated by AccountUpdated, so an order page needs no synchronous call. It is eventually consistent by seconds, which must be stated and accepted. The one thing never to do is a service calling another service's database directly; it recreates the monolith with network hops.

What replaces the database transaction that spanned orders and payments?

A saga: a sequence of local transactions, each in one service, with compensating actions for failure. Placing an order becomes: orders creates the order in state pending; payments captures the charge with an idempotency key derived from the order id; on success orders marks it confirmed and inventory reserves stock; on any failure the earlier steps are compensated (refund, release). Orchestrated (one coordinator drives the steps) is easier to debug than choreographed (each service reacts to events); for a payment flow with money involved, orchestration with a durable state machine is the safer choice. The interviewer will ask what the user sees in the middle: a pending order, which the product must already tolerate because payment providers are asynchronous anyway.

Cross-service needMonolith hadReplacementCost
Read customer on order pageJOINEvent-fed local copySeconds of staleness
Place order and chargeOne transactionOrchestrated saga with compensationPending states, more code
Report across everythingSQL on one DBEvents into a warehouseMinutes of lag; not for serving

How does the strangler routing keep every step reversible?

The gateway's rules are configuration: a context's traffic moves to its service by percentage and cohort, and moves back in seconds if error rate or latency crosses a threshold; automated rollback on SLO breach is worth building once and using for every extraction. Shadow traffic before any real traffic gives the diff report that justifies the first 1 percent. Contracts are versioned so the monolith and the service can evolve independently; the monolith keeps calling /v1/ until it is deleted. Two weeks of reversibility means the old code path stays deployable for two weeks after the service takes 100 percent, with its data kept current by the reverse event stream; only then is the code deleted.

What does the 18-month program plan say, for a TPM or EM?

Quarter 1: gateway in place, observability (traces across the boundary from day one), the internal interfaces for the first context, and a platform team that owns the templates (service skeleton, pipeline, SLO dashboards). Quarter 2: accounts extracted and deleted from the monolith, the first team deploying daily, the saga framework proven on a low-stakes flow. Quarters 3 to 5: catalogue, then payments, then orders, each with the same shadow, switch, delete rhythm. Quarter 6: the monolith is a thin remainder or gone. Risks with owners: the latency budget (measure per extraction), the database split for orders (prototype the event-fed copies early), team capacity (features do not stop; budget 30 percent of each team for the migration), and the organisational one, services without clear owners becoming nobody's problem.

6. What a strong answer sounds like

  • Start with the gateway and the strangler pattern, and say the order of extraction is decided by coupling, smallest and least-joined first, orders last.
  • Treat the database split as the real problem, with three concrete replacements for joins.
  • Replace the big transaction with an orchestrated saga and be clear about pending states and idempotency.
  • Make every step reversible by configuration and gate it with shadow diffs and SLOs.
  • Expected follow-ups: how many services is too many (one per bounded context, not per table); shared libraries versus duplication; how to test (contract tests per service, end-to-end tests only for sagas).
  • Common mistake: a shared database between services, or extracting the most coupled context first because it is the most painful.

02Where this question was reported