Strangler at the Code Level — Tasks¶
Source: Martin Fowler, "StranglerFigApplication", martinfowler.com/bliki/StranglerFigApplication.html
Work each task before reading its solution. Most are design exercises; some ask for code. The goal is the sequence of behavior-preserving moves, not just the end state.
Task 1 — Introduce the seam¶
You have LegacyShipping.cost(Order) called directly in 9 places. Before you can strangle it, introduce a seam. Write the seam class and describe the caller migration.
Solution
Migrate the 9 callers from `legacyShipping.cost(o)` to `shippingCalculator.cost(o)`. Each edit is same-in/same-out, so tests stay green. This is the only "wide" change, and it's mechanical. Behavior is unchanged; you've now created a single choke point to divert at later. **Check:** if some callers reach `LegacyShipping` via reflection or DI config, find those too — a seam that only some callers pass through can't give trustworthy parity.Task 2 — Slice by behavior¶
LegacyShipping.cost does: zone lookup, weight surcharge, then a fuel adjustment. You want to replace only the fuel adjustment. Show the diverted seam behind a flag.
Solution
The slice is one step. The rest still flows through legacy helpers. The flag defaults to off (legacy). You can verify just the fuel step in isolation and roll it back without touching the other two steps.Task 3 — Write the parity check¶
For Task 2's fuel slice, write an offline diff test that proves newFuel.adjust matches legacy.fuelAdjustment across realistic inputs.
Solution
@ParameterizedTest
@MethodSource("realShippingSamples") // pulled from anonymized prod data
void newFuelMatchesLegacy(double weighted, Order order) {
double oldV = legacy.fuelAdjustment(weighted, order);
double newV = newFuel.adjust(weighted, order);
assertEquals(oldV, newV, 0.001,
() -> "fuel mismatch for order " + order.id() + " weighted=" + weighted);
}
Task 4 — Slice by data with a canary ramp¶
You're replacing a fraud-scoring module. The new scorer should behave identically but you want to limit exposure. Design a slice-by-data divert that ramps by percentage.
Solution
public Score score(Transaction tx) {
// Slice by data: a deterministic percentage of standard transactions.
if (flags.isOn("fraud.new") && tx.isStandard()
&& bucket(tx.id()) < flags.percent("fraud.new.ramp")) {
return newScorer.score(tx);
}
return legacyScorer.score(tx);
}
private int bucket(String id) { // stable 0..99 per transaction
return Math.floorMod(id.hashCode(), 100);
}
Task 5 — Shadow a pure computation safely¶
You want to shadow the new fraud scorer before making it authoritative. The scorer is pure (no writes). Write the shadow harness so it can't slow or break the real request.
Solution
public Score score(Transaction tx) {
Score authoritative = legacyScorer.score(tx);
if (flags.isOn("fraud.shadow")) {
shadowPool.submit(() -> { // off the request thread
try {
Score candidate = newScorer.score(tx);
if (!candidate.equals(authoritative)) {
diffLog.record(tx, authoritative, candidate);
}
} catch (Exception e) {
diffLog.recordError(tx, e); // never propagates to caller
}
});
}
return authoritative; // legacy stays authoritative
}
Task 6 — Handle shared state¶
A LoyaltyAccrual step mutates cart.points. You want to strangle it but both old and new versions write cart.points. Redesign so old/new can coexist without double-mutation.
Solution
Make both implementations **pure** — return the value, let the seam commit it once.// BEFORE: both mutate shared state.
void accrue(Cart cart) {
legacy.accruePoints(cart); // writes cart.points
if (shadow) next.accruePoints(cart); // BUG: writes cart.points again
}
// AFTER: each computes; the seam owns the single write.
void accrue(Cart cart) {
int legacyPoints = legacy.computePoints(cart); // pure
if (flags.isOn("loyalty.shadow")) {
int candidate = next.computePoints(cart); // pure
if (candidate != legacyPoints) diffLog.record(cart, legacyPoints, candidate);
}
int chosen = flags.isOn("loyalty.new") ? next.computePoints(cart) : legacyPoints;
cart.points = chosen; // single writer
}
Task 7 — Sequence a subsystem strangle¶
A BillingEngine has four entry points: invoice() (read-only), applyCredit() (writes balance), accrueInterest() (reads balance, writes ledger), closeMonth() (rare, reads everything, writes balance + ledger). Propose a migration order with justification.
Solution
Order: **`invoice()` → `accrueInterest()` (read side first) → `applyCredit()` → `closeMonth()`**. Reasoning: 1. **`invoice()` first** — read-only, high frequency, low risk. Perfect plumbing slice to prove seam/flags/shadow/metrics/rollback. 2. **Reads before writes** — migrate the read parts before the write parts. `accrueInterest` reads `balance` (written by `applyCredit`); migrate its *read* behavior and shadow it while `applyCredit` is still legacy and authoritative for the write. 3. **`applyCredit()`** — a write to shared `balance`; do it after read-only slices have de-risked the seam and after you've established single-writer ownership of `balance`. 4. **`closeMonth()` last and smallest** — rare and touches everything; migrate when the machinery is battle-tested. Crucially, *tombstone-and-wait must span a month-end* before you can call the old `closeMonth` dead. Watch for the cycle: if `accrueInterest`'s new write depends on `applyCredit`'s new output and vice versa, break it by extracting the shared balance calculation into its own seam first.Task 8 — Declare the old code dead¶
You believe the closeMonth slice has been at 100% for three weeks. Write the steps and code to confirm the legacy path is truly dead before deleting it.
Solution
Steps: 1. Confirm no static callers (IDE find-usages), accounting for reflection/DI-config callers. 2. Confirm the flag is forced on in *all* environments with no config that can flip it back. 3. Tombstone the legacy path (above) and wait a **full business cycle** — for `closeMonth` that means at least one real month-end close, ideally a quarter-end too. Three weeks is *not enough* if the slice only runs at month-end. 4. Zero hits over that period → delete the legacy class, the dead branch, the flag, the shadow harness, and the old-vs-new parity tests (keep tests pinning the new behavior). The whole point of the tombstone is to catch a route you didn't know about *before* deletion makes it a production incident.Task 9 — Argue for cutover instead¶
A teammate proposes strangling a 60-line DateFormatter utility that has 100% test coverage and is called from 12 places. Make the case for a direct cutover.
Solution
Cut over. The strangler's machinery — seam, flag, shadow harness, diff metrics, ramp, tombstone — is far more code and process than the 60 lines being replaced, and it buys safety you already have: - **Tests already cover it 100%**, so a clean swap will catch regressions in CI; you don't need production shadowing to gain confidence. - **No shared mutable state** in a pure formatter, so the multi-writer risk the strangler protects against doesn't exist. - **Small and bounded** — 60 lines, 12 callers — so a single reviewed commit keeps the build green and is easy to revert with `git revert`. The strangler is for *risky, large, hard-to-test* replacements. This is none of those. Spending its carrying cost here is over-engineering. Write the new formatter behind the existing tests, swap it, delete the old one — one PR.Task 10 — Remove the scaffolding¶
A strangle of the pricing tax slice reached 100% a month ago and the legacy tax path is confirmed dead. The code still has: the pricing.new-tax flag, a legacy ? : new branch, a shadow block, and a parity test. List exactly what to delete and what to keep.
Solution
**Delete:** - The `pricing.new-tax` flag (config and code references) — a flag with one live branch is dead weight. - The `legacy ? new` branch — collapse to just the new call. - The shadow block and its diff logging/metrics for this slice. - The `LegacyTax` class/method, now unreferenced. - The parity test that compared `newTax` against `legacyTax` — its baseline (the old code) no longer exists. **Keep:** - Tests that pin the *new* tax behavior directly (these are now your regression suite). - General routing metrics infrastructure if other slices still use it.// AFTER cleanup — the seam wraps a single implementation.
public double calculate(Order order) {
double base = sumLineItems(order);
double discounted = applyQuantityDiscount(base, order);
double taxed = newTax.tax(discounted, order.region()); // no flag, no branch
return addShipping(applyLoyalty(taxed, order), order);
}
In this topic