CMD Guide
HomeSystem DesignMicroservices Patterns

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.

diagram
diagram

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.

diagram
diagram

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_WRITE

Because 26 falls inside the migrating cohort, every enabled operation for this account takes the DUAL_WRITE path. Suppose the account starts empty:

StepCallRouteWhat happens
1createAccount("acct-73", "Mara")DUAL_WRITElegacy.createAccount runs (authoritative); new store mirrored best-effort.
2deposit("acct-73", 200)DUAL_WRITElegacy balance → 200 (authoritative); shadow write to new store.
3deposit("acct-73", 50)DUAL_WRITElegacy balance → 250 (authoritative); shadow write to new store.
4checkBalance("acct-73")DUAL_WRITEreturns 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:

When this shape is worth it

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.

🎨 Explain it visually

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.
🤔 Walk me through it (interactive)

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.
🧪 Quiz me & fix my gaps

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.
🧠 Make it stick

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.

📝 My notes