Strangler Fig Data Migration: Orders Service Extraction
The core problem: two systems (monolith DB + new Orders microservice DB) need to represent the same truth during the transition, with zero downtime and zero data loss, until you can cut over completely.
Here's the depth version, phase by phase.
Phase 1: Dual Writes (Synchronization begins)
Before the new service takes any real traffic, you need both databases populated and kept in sync.
Pattern: Event-driven sync via CDC (Change Data Capture), not application-level dual writes.
Why not just write to both DBs from application code?
// DON'T do this — the classic dual-write trap
func CreateOrder(o Order) error {
if err := monolithDB.Save(o); err != nil {
return err
}
if err := newOrdersDB.Save(o); err != nil {
// Now monolith has it, new service doesn't. Split brain.
return err
}
return nil
}
This has no atomicity across two databases — a crash between the two writes leaves you inconsistent, and there's no distributed transaction that saves you here without introducing 2PC (which nobody wants across service boundaries).
Instead: CDC off the monolith's DB.
- Use Debezium (or AWS DMS, or a custom binlog/WAL tailer) attached to the monolith's
orderstable. - Every INSERT/UPDATE/DELETE on
ordersgets captured from the transaction log and published to a Kafka topic (orders.cdc). - A consumer (part of your new Orders service, or a dedicated sync worker) reads this stream and applies changes to the new Orders DB.
This gives you:
- No dual-write race conditions — the monolith DB remains the single writer during this phase.
- Ordering guarantees — CDC preserves the commit order from the WAL/binlog, so you don't apply an UPDATE before its INSERT.
- Backfill + streaming in one pipeline — Debezium supports an initial snapshot (bulk copy of existing rows) followed by seamless handoff to streaming changes, so you don't need a separate backfill script that risks missing a window of writes.
Monolith DB (writer) --WAL--> Debezium --> Kafka (orders.cdc) --> Sync Consumer --> New Orders DB (read replica of truth)
At this stage, the new Orders DB is a shadow copy — not yet authoritative, not yet serving real requests.
Phase 2: Shadow Traffic / Verification (Read-path validation)
Before switching any real read/write traffic, validate correctness:
-
Shadow reads: for every read request to the monolith's order API, asynchronously fire the same read against the new service and diff the responses (log mismatches, don't serve them to users). Tools like GitHub's
scientistlibrary formalize this "science experiment" pattern. -
Reconciliation job: periodic batch job comparing row counts / checksums / hashes between monolith
ordersand new-serviceorderstable, alerting on drift. This catches CDC consumer bugs, lag, or dropped events. - Lag monitoring: track CDC consumer lag (Kafka consumer group lag) — if new-service data is 30s behind monolith, that matters for correctness guarantees later.
You stay here until mismatch rate is effectively zero over a sustained period.
Phase 3: Cutover of Writes (the actual dangerous part)
This is where "no downtime" gets hard, because now you need to flip who owns writes without losing anything mid-flight.
Strangler fig at the write path uses a facade/proxy layer — typically an API gateway or a thin routing layer in front of both systems:
Client --> Facade/Router --> [routes based on rule] --> Monolith Orders API
\--> New Orders Service
Common approach — write cutover via feature-flagged dual-write with new service as source of truth:
- Stop the CDC-based one-way sync.
- Flip writes to go to the new Orders service first (new service becomes authoritative writer).
-
New service now dual-writes back to monolith DB (or publishes an event the monolith consumes) — this is the reverse sync, needed because other still-unmigrated parts of the monolith (invoicing, inventory, whatever hasn't been strangled yet) still read
ordersdirectly from the monolith DB. - This reverse sync is temporary — it exists only until every consumer of monolith
ordersdata has itself been migrated to call the new Orders service (via API or its own CDC stream off the new DB).
This is the key insight of the strangler fig pattern: you don't migrate one big-bang cutover — you flip the direction of the sync arrow, and the "old" system becomes the dependent, then eventually gets its dependency removed entirely.
Phase 4: Handling In-Flight Writes During Cutover
The truly tricky moment is the write cutover instant. Techniques used:
- Dual-write window with idempotency keys: during a short window, both old and new API paths accept writes, each write carries an idempotency key (order ID + version/timestamp), so replays or races from CDC catch-up don't create duplicates.
- Version/vector clock on rows: each order row carries a monotonic version number. The sync consumer only applies an update if incoming version > current version — this makes the sync idempotent and order-independent (protects against Kafka redelivery or out-of-order application).
-
Brief write-freeze (seconds, not downtime): some teams accept a sub-second write lock on the specific resource being cut over (e.g., quiesce writes to
orderstable, drain CDC lag to zero, then flip router) rather than a fully lock-free cutover — this is often simpler and the "downtime" is only a few hundred ms of increased latency, not an outage. - Outbox pattern on the new service: once the new service is the writer, it uses a transactional outbox (write to its own DB + outbox table in the same local transaction) to reliably publish events for the reverse sync, avoiding its own dual-write problem.
Phase 5: Read Cutover
Reads are far less risky than writes since they're idempotent by nature:
- Router shifts a percentage of read traffic to the new service (1% → 10% → 50% → 100%), canary-style.
- Compare latency/error rates at each step.
- Because you validated correctness in Phase 2, this is mostly a confidence/load-bearing exercise now, not a data-integrity one.
Phase 6: Decommission
- Once 100% of reads and writes go through the new Orders service, and all other monolith modules that referenced
ordershave been updated to call the new service's API (or consume its event stream) instead of hitting the shared DB directly: - Turn off the reverse sync.
- Drop the monolith's
orderstable (or archive it). - Remove the facade routing rule — it's no longer a fork, just a straight call.
Summary of the sync topology over time
Phase 1-2: Monolith DB --(CDC, one-way)--> New Orders DB [monolith authoritative]
Phase 3: New Orders DB --(reverse sync)--> Monolith DB [new service authoritative]
Phase 4-5: Facade gradually shifts read/write traffic to new service
Phase 6: Monolith DB orders table retired, sync removed [new service sole owner]
The general principle: never have two systems both accepting independent writes to the same entity without a defined, single source of truth and a reconciliation mechanism. Every "dual write" period in a strangler fig migration should really be "one authoritative writer + one propagated-to replica," with the authority handed off deliberately, not two peers writing simultaneously.
Top comments (0)