Strangler Pattern A Detailed Example
The idea in one sentence
The Strangler (Fig) Pattern retires a legacy system incrementally. A thin facade sits in front of the old code and, request by request, routes a growing slice of traffic to a new implementation until the old system is fully strangled and can be deleted. Nothing is rewritten big-bang: the two systems run side by side while trust in the new one is earned.
This page builds a concrete Java example, a banking backend, and then traces a single account through the facade using real Java hashCode() values so you can reproduce every routing decision yourself.
The legacy and the new implementation
Both systems implement one shared interface, keyed by accountId so the facade can delegate to either without the caller knowing which is live:
interface AccountService {
void createAccount(String accountId, String name);
void deposit(String accountId, int amount);
void withdraw(String accountId, int amount);
int checkBalance(String accountId);
}
class LegacyBank implements AccountService { /* battle-tested old logic */ }
class NextGenBank implements AccountService { /* the new implementation */ }LegacyBank is the system we trust today; NextGenBank is unproven and may still throw, return wrong numbers, or be temporarily unavailable. Every design decision below flows from that asymmetry.
The facade and the router
The facade owns both implementations plus a Router that decides, per call, where a request goes. Routing is a percentage rollout: hash the account into a bucket in [0, 100) and send the lowest dualWritePercent of buckets down the new path.
enum Route { LEGACY, DUAL_WRITE }
class Router {
private final int dualWritePercent; // e.g. 30
private final Set<String> enabledOps; // ops we've begun migrating
Route routeFor(String accountId, String op) {
if (!enabledOps.contains(op)) {
return Route.LEGACY; // not migrating this op yet
}
int bucket = Math.floorMod(accountId.hashCode(), 100);
return bucket < dualWritePercent ? Route.DUAL_WRITE : Route.LEGACY;
}
}Why Math.floorMod and not %? A Java String.hashCode() is a signed 32-bit int and is very often negative. Plain % preserves the sign, so hash % 100 can be negative and fall outside a valid bucket; Math.floorMod always returns a value in [0, 100). Because the hash is deterministic, an account keeps the same bucket forever, so it does not flip between paths mid-migration.
The safety contract: dual write, legacy stays authoritative
For an account in the DUAL_WRITE cohort the rule is strict: legacy is always the source of truth. Writes go to legacy first and are then mirrored to the new store; reads are always answered from legacy, with an optional comparison read against the new store. Both interactions with the new store are best-effort: they run inside try/catch so that a bug, a timeout, or an outage in the unproven path can only raise a metric, never break the customer's operation.
public void deposit(String accountId, int amount) {
Route route = router.routeFor(accountId, "deposit");
if (route == Route.LEGACY) {
legacy.deposit(accountId, amount); // unchanged 100% path
return;
}
// DUAL_WRITE cohort: legacy is the source of truth.
legacy.deposit(accountId, amount); // authoritative write
// Mirror to the new store — BEST EFFORT ONLY.
try {
next.deposit(accountId, amount);
} catch (RuntimeException e) {
// A bug in the new path must never corrupt the balance.
metrics.recordShadowFailure("deposit", accountId, e);
}
}The read path must be symmetric. The comparison read against next exists precisely to catch cases where the new store is wrong or failing, so it too must be wrapped: fetch the authoritative value from legacy, then attempt the shadow read inside try/catch, and return the legacy value no matter what happened.
public int checkBalance(String accountId) {
Route route = router.routeFor(accountId, "checkBalance");
if (route == Route.LEGACY) {
return legacy.checkBalance(accountId);
}
// DUAL_WRITE cohort: the answer is STILL served from legacy.
int authoritative = legacy.checkBalance(accountId);
// Compare against the new store — BEST EFFORT ONLY.
try {
int shadow = next.checkBalance(accountId);
if (shadow != authoritative) {
metrics.recordMismatch("checkBalance", accountId, authoritative, shadow);
}
} catch (RuntimeException e) {
// The comparison read must never break a customer's balance query.
metrics.recordShadowFailure("checkBalance", accountId, e);
}
return authoritative; // always the legacy value
}Now the guarantee holds end to end: a fault in the new path can never corrupt a balance and can never fail a read — it only raises an alert. Had the shadow read been left unguarded, an account in the exact scenario dual-write is meant to survive (a broken new store) would have its balance query throw after the authoritative value was already in hand, which is the failure mode the pattern is supposed to eliminate.
Is this not the dual-write trap?
No — and the distinction is the whole design. The trap is dual-writing two authoritative stores: both feed real readers, so a partial failure corrupts someone's truth with no detector. Here the new store is a disposable shadow: no reader depends on it, legacy remains the single source of truth, and a partial failure only moves a mismatch metric. The moment you promote the new store to authoritative (rollout done), you stop dual-writing and switch to single-writer + outbox/CDC — the mechanism the Key Insights page traces.
Worked trace: acct-73 with dualWritePercent = 30
Compute the bucket once; it is fixed for the account's lifetime:
"acct-73".hashCode() = -1177240874
Math.floorMod(-1177240874, 100) = 26 // -1177240874 % 100 = -74, +100 = 26
26 < 30 (dualWritePercent) = true // cohort = DUAL_WRITEBecause 26 falls inside the migrating cohort, every enabled operation for this account takes the DUAL_WRITE path. Suppose the account starts empty:
| Step | Call | Route | What happens |
|---|---|---|---|
| 1 | createAccount("acct-73", "Mara") | DUAL_WRITE | legacy.createAccount runs (authoritative); new store mirrored best-effort. |
| 2 | deposit("acct-73", 200) | DUAL_WRITE | legacy balance → 200 (authoritative); shadow write to new store. |
| 3 | deposit("acct-73", 50) | DUAL_WRITE | legacy balance → 250 (authoritative); shadow write to new store. |
| 4 | checkBalance("acct-73") | DUAL_WRITE | returns 250 from legacy; shadow-read of new store compared (mismatch or failure only logs a metric). |
Every step routes DUAL_WRITE because the bucket check (26 < 30) is evaluated identically on each call. Contrast acct-42: its hash is -1177240968, Math.floorMod(…, 100) = 32, and 32 < 30 is false — so acct-42 stays entirely on LEGACY. Two similar-looking IDs land in different cohorts purely because of their hash.
Rolling the migration forward, and the trade-offs
Advancing the migration is now a matter of two dials, both changeable without a deploy if they are config-backed:
- Enable an operation by adding it to
enabledOps(e.g. flipdepositon once the new path is written). - Widen the cohort by raising
dualWritePercentfrom 30 → 50 → 100 as mismatch metrics stay clean. When it reaches 100 and the mismatch rate is zero for long enough, you promote the new store to authoritative (swap the read source), then retire legacy for that operation.
When this shape is worth it
- Use it when a big-bang rewrite is too risky, when you can run both stores in parallel, and when you have the observability (mismatch and shadow-failure metrics) to earn trust gradually.
- Weigh the cost: dual-write doubles write load, and the two stores can diverge (partial failure mid-write) — which is exactly why legacy stays authoritative and reconciliation/mismatch alerting is mandatory, not optional.
- Prefer a simpler cutover (feature flag with a hard switch) when the new path is cheap to fully validate offline, or when running two systems in parallel is more expensive than the risk it removes.
Source
Pattern based on Martin Fowler, StranglerFigApplication (2004), and the Microsoft Azure Architecture Center, Strangler Fig pattern. The banking facade and percentage-based dual-write rollout are an original worked example; all Java hashCode() and Math.floorMod values were computed against the standard JDK String.hashCode() algorithm and are reproducible.
🤖 Don't fully get this? Learn it with Claude
Stuck on Strangler Pattern A Detailed Example? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Strangler Pattern A Detailed Example** (System Design) and want to truly understand it. Explain Strangler Pattern A Detailed Example from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Strangler Pattern A Detailed Example** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Strangler Pattern A Detailed Example** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Strangler Pattern A Detailed Example** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.