The big-bang rewrite has a long, consistent history of going badly. A team spends 18 months rebuilding a system while the old one keeps changing underneath them, the new one never quite reaches parity, and the cutover weekend becomes a cutover quarter. Incremental migration exists because of that history: take one capability out at a time, put each piece into production as soon as it works, and keep the option to go back at every step.
Interviewers asking about monolith migration want to hear a sequence, not a slogan. “Use the strangler fig pattern” is the slogan. The sequence covers which piece to move first, how to separate it while it’s still inside the monolith, what happens to its data, how to prove the new version behaves the same, and how to switch traffic without a point of no return.
Picking the first slice
The first extraction sets the tone, so choose for learning rather than for impact. Good first candidates share some traits:
- A clear business capability with a name the business uses: shipping quotes, notifications, invoicing. Not a technical layer like “the data access code”.
- Few inbound dependencies. If 40 parts of the monolith call it synchronously in the middle of transactions, extracting it means 40 new network calls and 40 new failure modes.
- Data it can own. A capability that reads and writes its own tables is far easier than one that joins across half the schema.
- A reason to move. It changes often, needs to scale differently, or a separate team wants to own it. Extracting something nobody touches gains little.
Shipping cost calculation is a typical good first choice: it’s a function of the cart and the address, it’s called from checkout and the cart page, and the logic changes whenever a carrier changes its rates. Order management, by contrast, is usually the worst first choice, because everything touches it.
Cut the seam inside the monolith first
Before anything crosses a network, make the boundary real in the monolith. Move all shipping logic behind one interface. Find every place that reads the shipping_rates table directly and route it through that interface. Remove the joins between shipping tables and order tables.
// Illustrative: the only way the rest of the monolith reaches shipping.
public interface ShippingQuotes {
Money quote(Cart cart, Address destination);
}This step is unglamorous and it’s where most of the real work happens. If the boundary is leaky inside one process, where a violation is just a method call, it will be far worse once violations become remote calls. Tools like ArchUnit can enforce the rule in the build: nothing outside the shipping package may touch shipping’s tables or internals.
Once every caller goes through ShippingQuotes, swapping the in-process implementation for an HTTP client is a small, contained change.
Moving the data owner
Data is where migrations stall. During the transition, shipping data exists in the monolith’s database, and the new service needs it too. Options, from worst to best:
- Shared tables. The new service reads the monolith’s tables directly. It’s quick, and it means the service isn’t really separate: every schema change needs both teams, and the monolith can still write data the service doesn’t expect.
- Dual writes. The monolith writes to both its table and the service’s. If one write fails, the two stores diverge, and there’s no transaction spanning them. Avoid it unless there’s a reconciliation job that checks and repairs differences.
- One owner plus replication. Pick an owner. While the monolith is still the owner, the service gets a read copy through change data capture or events. At cutover, ownership flips: the service becomes the writer and the monolith reads through the service’s API or its events.
The third option is more work, but at every moment there’s exactly one place where a shipping rate is authoritative. That’s what makes each step reversible.
Proving equivalence with a parallel run
A rewrite of business logic will differ from the original somewhere, usually in ways nobody wrote down. A parallel run surfaces those differences on real traffic before any customer is affected: the monolith keeps serving the answer, the new service computes its answer too, and a comparator records disagreements.
Here’s that idea in miniature. The monolith’s shipping calculation uses integer cents with half-up rounding and free shipping from 50.00 euros. The new service was written from the specification:
// Monolith: integer cents, rounds half up, free shipping from 50.00 euros.
function monolithShipping({ weightGrams, subtotalCents, country }) {
if (subtotalCents >= 5000 && country === 'FR') return 0;
const perKgCents = country === 'FR' ? 115 : 240;
return 390 + Math.floor((weightGrams * perKgCents + 500) / 1000);
}
// New shipping service: rewritten from the spec, floats, and "over 50" read as strictly greater.
function newServiceShipping({ weightGrams, subtotalCents, country }) {
if (subtotalCents / 100 > 50 && country === 'FR') return 0;
const perKg = country === 'FR' ? 1.15 : 2.4;
return Math.round((3.9 + (weightGrams / 1000) * perKg) * 100);
}Running both over 10,000 generated requests (with a seeded random generator so the run repeats) and grouping the disagreements:
compared 10000 requests
threshold at exactly 50.00: 1199 mismatches, e.g. {"weightGrams":8825,"subtotalCents":5000,"country":"FR","served":0,"candidate":1405}
rounding: 4 mismatches, e.g. {"weightGrams":12300,"subtotalCents":1999,"country":"FR","served":1805,"candidate":1804}Two real bugs. The spec said free shipping “over 50 euros”, the monolith has always meant “50 or more”, and customers with a cart of exactly 50.00 would suddenly pay 14.05 euros. The rounding difference is rarer: 12.3 kg at 1.15 euros per kg is 14.145, and in floating point that becomes 18.04499… after adding the base fee, which rounds down, while the monolith’s integer arithmetic rounds the half cent up. Four mismatches in 10,000 is exactly the kind of bug that slips past a test suite and generates support tickets for months.
The parallel run is safe because the candidate’s answer is discarded. That only holds for reads and pure calculations. For operations with side effects (charging a card, sending an email), you can’t run both. There you compare at the data level instead, or run the new service against a sandboxed downstream.
Switching traffic, with a way back
Once mismatches are understood and fixed, the routing facade starts sending real traffic to the service: 1% of requests, then 10%, then all. The facade can be a feature flag inside the monolith’s ShippingQuotes implementation, or a rule in the gateway for externally visible endpoints.
Keep the old path working until the new one has handled full traffic through at least one peak period and one carrier rate change. Rolling back should mean flipping the flag, not redeploying. Only after that, delete the old code and the old tables. Teams skip this step surprisingly often, and the result is two implementations that drift apart and a monolith that never actually shrinks.
Mistakes that turn a migration into a distributed monolith
- Extracting by technical layer. A “data service” that every other service calls for every query couples everything to it.
- Fine-grained synchronous calls. If checkout now makes 15 calls to the new service per request, latency and failure probability go up with each one. Design the service’s API around the caller’s use case.
- Moving code but not ownership. If the monolith team still has to approve every change to the shipping service, nothing got more independent.
- No end state per step. Each extraction should finish (traffic moved, old code deleted) before the next one starts, or you accumulate half-migrated capabilities.
Interview practice: extracting notifications
“Your monolith sends emails and SMS from 30 places in the code, usually right after a database commit, using a shared NotificationHelper. You want a separate notification service. Describe the steps.”
Answer
First, the seam: make NotificationHelper the only path to sending anything, and change its interface from “send this email now” to “this event happened, notify the customer”. That’s a small refactor in 30 places, and it’s also where I’d fix the existing bug class: sending right after commit means a crash between commit and send loses the notification. I’d have the helper write a row to an outbox table in the same transaction as the business change.
Second, the new service. A relay publishes outbox rows as events, and the notification service consumes them and sends. While building it, I’d run it in parallel: it consumes events and renders the messages it would send, without sending, and a comparator checks them against what the monolith actually sent. Template and localisation differences show up here.
Third, cutover by notification type, starting with something low-risk like “password changed” emails. A flag per type decides whether the monolith sends or the service does, never both, so there’s no double-send. Once every type has moved and been stable for a while, delete the monolith’s sending code and the email templates from its repository. The notification service owns the templates, delivery logs and preferences from then on.
Further reading
- Microservices interview questions
- Microservices MCQs
- The strangler fig pattern
- Anti-corruption layers
- Martin Fowler: Strangler Fig Application