System Design Patterns
Strangler Fig Pattern
Imagine a checkout team that must replace a decade-old monolith before peak season. A big-bang rewrite would risk every payment and cart for weeks. Instead they put a stable front door in front of the old system and peel off one slice at a time: search first, then catalog reads, then checkout, until the legacy path has nothing left to serve.
That is the : keep the business running while a routes some traffic to the new service and the rest to legacy, with rollback and at every step.
Migration journey
Clear contracts beat random endpoints.
Compare without user impact.
1% → 5% → 25% → 100% with gates.
Delete legacy path
Only after a stability window.
Problem → naive → break
Problem: peak season is twelve weeks away and the monolith owns checkout, catalog, and search in one deploy unit. Every release risks payments.
Naive move: freeze features, rewrite everything in a parallel stack, flip DNS on Saturday night. Counterexample: a team that cut over search + catalog + checkout together discovered a tax-rounding mismatch only after real charges: rollback meant restoring a database dump while carts were mid-flight.
Break: the big-bang leaves no reversible seam. Dual ownership appears overnight; support cannot tell which path served a user; partial write success has no clean undo.
Correctness invariant
For every route/cohort at time t, exactly one path is authoritative for the user-visible response. Write ownership for a domain never splits across two live writers. If the candidate has accepted writes, rollback must replay or quarantine those writes: traffic reversal alone is not recovery.
What actually happens
Users still hit one entrypoint. The asks which path owns this route, cohort, or flag. Migrated paths go to the new service and its owned database; everything else stays on the monolith. A bridge or API adapter keeps data moving while both worlds exist.
Before real users depend on the candidate, exercises it and dashboards gate promotion. Then a widens exposure. After a sustained stability window, unreachable legacy code and schemas get deleted on purpose, not left forever "just in case."
The migration duration depends on the amount of hidden behavior, the data boundary, and the evidence required before moving traffic. A read-only search slice may move quickly; checkout may require months of compatibility and recovery work. Track progress by retired dependencies and transferred ownership rather than service count. A new endpoint that still writes legacy tables has changed deployment shape without completing the migration.
Users
Facade / gateway
Which path owns this route?
Legacy paths
Legacy monolith
Legacy database
Migrated paths
New service
Owned database
CDC or API bridge keeps data moving
Parity dashboards
Try it: split strangler traffic
Users still enter through one facade. A weight sends real responses from the candidate; shadow traffic duplicates requests for parity without changing what users see. Extra load is the sum of candidate QPS and mirrored shadow QPS.
- Legacy QPS
- 900
- Candidate QPS
- 100
- Shadow QPS
- 200
- Parity gate
- Fail
Traffic split
user-visibleLegacy 900 req/s
Candidate 100 req/s
Migration load
includes shadowCandidate serves 100 req/s to users. Shadow adds 200 req/s that do not change responses. Combined new-path work is about 300 req/s before legacy traffic (900 req/s).
Adjust how much user traffic hits the candidate, then add shadow traffic that exercises the new path without serving responses. Parity gates should compare error rates before you widen the slice. Recommendation: Hold promotion: candidate errors exceed the parity threshold.
Shadow cost. Twenty percent shadow on 1k QPS adds 200 mirrored requests per second on top of whatever slice already runs on the candidate. Budget CPU, databases, and observability for that overlap period.
Stop conditions. When candidate error rate exceeds roughly 125% of legacy baseline, hold or roll back even if product pressure wants a faster cutover.
How to choose a strangler migration
1. Is big-bang rewrite risk existential? If a failed cutover would halt checkout or payments, prefer reversible slices. For a healthy modular service that only needs cleanup, strangler overhead is often too heavy.
2. Do you have a clear seam? Start with a that has measurable outputs and known ownership: catalog reads beat tangled checkout workflows early.
3. Can you roll back and prove parity? Without feature-flag owners, dashboards, and deletion criteria, you will run two systems forever.
Simple rule of thumb
Treat migration as product work: measurable parity, staged traffic, and operational dashboards, not a one-shot PR. Pair routing changes with database ownership milestones so you do not accumulate hidden coupling behind new HTTP façades.
One writer plus outbox/ beats a . Translate legacy shapes through an so the new domain stays clean.
Forces that shape each slice
Intent: replace risky legacy behavior through reversible routing slices while keeping the business running.
Forces
Legacy coupling, unknown behavior, data ownership, customer tolerance, and team capacity decide how thin each slice should be.
Consequences
You operate old and new paths together for a while, which requires parity tests, dual dashboards, and deletion discipline.
Pain vs complexity
Worth it when big-bang rewrite risk is existential; too heavy for simple code cleanup inside a healthy modular service.
The incremental loop
Identify a seam, build the replacement, shadow or canary traffic, shift routing, delete dead code: repeat while observability proves parity.
Map bounded contexts
A useful seam has an identifiable input, output, and owner. Product search is often easier to isolate than order placement because its result can be compared without committing money or inventory. List hidden dependencies such as permissions, price rules, background jobs, and reporting before choosing the slice. The smallest URL path is not necessarily the smallest business boundary.
Stand up replacement
Deploy beside legacy; match external behavior before cutting traffic.
Route selectively
The gateway applies a versioned rule to choose the authoritative implementation for each request. For stateful journeys, assign a stable cohort such as tenant or customer so retries and later steps return to compatible ownership. Log the routing decision with the operation ID. Randomly routing each write independently can split one business workflow between old and new systems before their data is ready.
Retire module
After sustained stability, delete unreachable legacy paths and prune schemas deliberately.
Migration control plane
Before you move traffic
Define route key, cohort rule, feature flag owner, rollback owner, parity dashboard, diff thresholds, support contact, and deletion criteria for each slice.
Operating cost
During overlap, teams operate legacy + candidate + sync + observability. Product support and runbooks must know which path served a user.
0% shadow only
1% internal
1% real users
5%
25%
50%
100%
Stability window
Delete legacy path
Stop conditions
Parity diff or stop condition?
Yes: rollback
Rollback switch
Return to 0% shadow
No: continue
Keep ramping traffic
Routing tactics
Path prefix
Route narrow modern paths first; fall through to the legacy stack for everything else.
# gateway routing sketch
location /api/search {
proxy_pass http://search_service;
}
location /api/ {
proxy_pass http://legacy_monolith;
}Headers / flags
Opt users into candidates via headers or feature flags: pairs naturally with progressive delivery metrics.
Weighted split
routes:
- destination: legacy
weight: 90
- destination: modern
weight: 10Migration split math
Traffic slice
At 20k requests/sec, a 1% split still sends 200 Queries Per Second (QPS) to the new path. The candidate must be production-ready even for small percentages.
Error gate
If the old path errors at 0.1%, a 1% candidate slice should compare against about 0.2 candidate errors/sec at 200 req/sec before widening.
Shadow cost
Shadowing doubles selected read traffic. Mirroring 25% of a 40k requests/sec read path creates 10k extra candidate QPS without user-visible responses.
candidate_qps = total_qps * traffic_weight
20_000 qps * 0.01 = 200 qps
shadow_extra_qps = read_qps * mirror_percent
40_000 qps * 0.25 = 10_000 extra qps
backfill_time = rows / rows_per_sec
500_000_000 rows / 25_000 rows_per_sec ~= 5.6h
dual_run_cost = legacy_cost + candidate_cost + sync_cost + observability_costStop conditions
Error gate
Candidate error rate exceeds legacy baseline plus agreed tolerance.
Latency gate
p95 or p99 latency regresses on the migrated cohort.
Parity gate
Response diff or business mismatch crosses severity threshold.
Data gate
Change Data Capture (CDC) lag, replay backlog, or reconciliation errors grow.
Support gate
Tickets, refunds, or manual operations spike after exposure.
Verification before promotion
Parity means equivalent business behavior, not necessarily byte-for-byte identical responses. Normalize timestamps, ordering, and generated IDs before comparing, but preserve meaningful differences such as price rounding, permission decisions, and missing records. Break mismatches down by cohort and operation type. A global 99.9% match can hide a complete failure for a small but important customer group.
Shadow / dark launch
Shadow traffic sends a copy to the candidate while legacy continues serving the user. Mirror reads only after accounting for their extra database and cache load. A shadowed write must not make a second payment, send another email, or reserve inventory; use a controlled sink or simulation for those effects. Shadowing proves behavior on sampled inputs, while a canary additionally tests the actual user-facing path.
Controlled experiments
experiment("search-refactor") {
control { legacy.search(q) }
candidate { modern.search(q) }
}Data phases
Shared store (bridge)
Fastest start: risk coupling until boundaries firm.
Single writer domain
New service owns writes; legacy reads via API or replica rules.
Change Data Capture (CDC) sync
CDC can keep a candidate database current after an initial backfill by streaming committed changes from the existing owner. Record the backfill checkpoint, apply later changes in a defined order, and monitor how far behind the candidate is. At write cutover, ensure the new owner has caught up and fence the old writer. Copying rows continuously does not itself decide who may accept new writes.
Full separation
Foreign keys become service calls or async projections: expect eventual consistency trade-offs.
Break the improved design: facade failure timeline
Strangler does not remove risk. It relocates it into routing, sync, and dual-run ops. Walk one concrete timeline.
t=0 Canary at 25% for /api/checkout; candidate owns writes for that cohort t=12m Facade flag flips cohort back to legacy after p99 spike (ops panic) t=12m+2s Candidate already committed order O-991 with payment auth t=12m+5s Legacy path cannot see O-991; user retries → second charge attempt t=15m Support sees two paths; no shared idempotency key across writers Outcome Invariant broken: write ownership split + rollback without replay
Big bang rewrite
High cancel risk: prefer reversible increments.
Data coupling ignored
Ends as distributed monolith: plan ownership moves explicitly.
No rollback path
A rollback switch restores routing only while legacy can safely interpret all writes accepted by the candidate. Before promotion, define how those writes return to legacy or how legacy remains read-only during recovery. Test that plan with real state transitions. Keep the switch until the observation window and data compatibility conditions are satisfied, then retire it with the old path to avoid permanent dual operation.
Strangling too wide
Limit concurrent seams per team to preserve review depth.
+ silent facade drift
Two writers without shared idempotency, or a facade that routes inconsistently across retries, recreates the big-bang failure inside a "safe" migration.
What to say in a design review
Pattern selection
Choose strangler when risk comes from migration scale, not when a single refactor is enough. Start with a route that has clear ownership and measurable outputs.
Tradeoffs
Running two systems costs more temporarily, but buys rollback, learning, and production parity evidence.
Failure modes
Inconsistent routing, duplicate writes, stale replicated data, hidden legacy dependencies, and never deleting the old path.
Rollout and observability
At millions of users, use shadow traffic, diff dashboards, weighted canaries, error-budget gates, and per-slice rollback switches before deleting legacy paths.
Slice scorecard and migration math
| Migration slice | Good candidate | Bad candidate |
|---|---|---|
| Catalog read API | Clear contract, easy parity checks, reversible routing | Hidden side effects or mixed write ownership |
| Checkout workflow | Only after payments, inventory, and idempotency rules are explicit | Shared transactions and unclear rollback behavior |
| Reporting/export path | Tolerance for temporary drift; async validation possible | Business-critical correctness with no parallel audit |
9,000,000 / (1,200 - 900) ≈ 30,000 seconds, or about 8.3 hours. That math tells you whether nightly parity is realistic before cutover.Anti-corruption boundary
Translate legacy customer codes, error semantics, dates, and side effects at one versioned adapter. The new service owns its domain language; callers do not spread legacy shapes into the replacement model.
The dual-write trap
Writing two databases from one request creates an ambiguous partial commit. Keep one writer and publish through an outbox/CDC stream, or make the second path explicitly rebuildable and reconcile every gap.
Cutover gate and rollback contract
Promote 1% → 5% → 25% → 100% only when semantic mismatch, p99, error rate, saturation, and business totals remain inside declared thresholds for a full observation window. Once the new path accepts writes, rollback must replay them into the legacy path or leave legacy read-only: traffic reversal alone is not recovery.
Trade-offs
Nearest alternative
Modular monolith extract (library boundaries first) when the risk is code hygiene, not existential cutover risk. Branch-by-abstraction inside one deploy unit is cheaper than a full facade when data already has clear ownership.
What you pay
Dual-run cost = legacy + candidate + sync + observability for months. Facade becomes a critical SPOF for routing truth: version and canary the gateway itself.
Exercises
- At 40k read QPS you want 10% shadow. How many extra candidate QPS do you provision, and what stop condition freezes the mirror if CDC lag exceeds 30s?
- Checkout canary is at 5%. A payment webhook only hits the candidate. Design the rollback contract so flipping the flag cannot double-charge.
- Pick catalog-read vs checkout as the first seam. Defend the choice with parity measurability and write-ownership risk, not team preference.
Failure timeline: dual-write and CDC lag at cutover
Even with a facade, dual-write or CDC lag can leave two truths during the window when both paths still matter.
T+0 Cutover: candidate writes orders; CDC streams to legacy read models T+2m CDC lag hits 90s under load; shadow parity still green on sampled traffic T+3m Support lookup hits legacy replica → order O-441 missing T+3m+8s User retries checkout; candidate already charged; idempotency key not shared T+6m Ops flips facade back to legacy without replaying candidate commits Outcome Dual-write / lag gap: user-visible state diverges; refunds become archaeology
Primary references
Topic sources for incremental migration, not generic system-design books.
Takeaway
Strangle by reversible slices with a facade, shadow parity, and canary ramps. Then delete the old path on purpose. Hold the single-writer invariant; never roll back writes with a flag flip alone.
Practice and further reading
Use these exercises to connect the lesson to a design decision. State your assumptions, trace what happens to one request, and explain the rule that must remain true even during a failure. That rule is the design's correctness invariant.
Focus for this lesson: Migrate one business slice at a time through an anti-corruption layer, observable routing gates, data ownership transitions, rollback, and reconciliation.
Explain the decision
What does an anti-corruption layer do in a strangler migration?
Read the reasoning
It translates legacy models and semantics at the boundary so the new domain is not forced to reproduce legacy coupling and naming.
Apply it to a production scenario
Shadow reads differ by 0.8% after moving customer profiles. Can traffic cut over?
Read one approach
Classify differences, define a correctness threshold by field, repair data and translation bugs, then canary only after unexplained mismatches are below the gate.
Work through the numbers
Shadowing 25% of 40,000 reads/s to a new path adds how much request load?
Read the worked calculation
10,000 reads/s, plus downstream and egress amplification; shadow capacity must be isolated from serving capacity.
Reason through a failure
What must a rollback preserve after the new service has accepted writes?
Read the recovery reasoning
The legacy path must receive reconciled, ordered state or remain read-only; routing rollback cannot silently discard writes made under new ownership.
Sources and further study
Continue with Sidecar Pattern.