InterviewKata
Software architecture · Level 6: Governance and evolution · Module 30a of 47

Legacy modernization: displacement patterns

Most engineers spend more time replacing old systems than building new ones, and the old system has to keep working the whole time. Module 30 introduced the strangler fig. This module is the fuller playbook: the four activities of a legacy displacement, why "rebuild everything it does" is a trap, the patterns that let old and new run side by side, and a router you can run that dark-launches, canaries and diverts traffic.

A 4-minute conversation: Sam asks, Priya explains
0:00 / 3:44

1. The idea

Replacing a railway bridge while trains keep running. Engineers build the new span beside the old one, lay temporary track and switches, run an empty test train across, then move one line at a time. When the last line has moved, they demolish the old bridge, and then the temporary switches too. Nobody closes the railway for two years.

Thoughtworks' Ian Cartwright, Rob Horn and James Lewis collected what works in Patterns of Legacy Displacement (first published July 2021, revised March 2024). They group the work into four activities:

1 · Understand the outcomesWhat the business needs that the old system blocks: speed of change, cost, risk, a new market.
2 · Break the problem upFind slices that can move on their own, such as one product line or one value stream.
3 · Deliver the partsMove each slice while the old system keeps running, with temporary scaffolding.
4 · Change the organisationSo that modernization keeps happening, instead of the next system becoming legacy too.

The feature-parity trap

Feature parity means "replicate existing functionality of a legacy system using a new technology stack". It sounds safe, and it's one of the factors the authors found in several failures, because it usually leads to a single big-bang cutover. Their reasons:

It does fit two cases: power-user screens where people rely on every shortcut, and systems built on a fixed specification, such as a solver for physical laws, ideally with a strong test suite.

Patterns for delivering the parts

PatternWhat it doesTypical use
Event interceptionIntercept updates to the system's state and route some of them to a new componentA proxy or message router in front of the legacy system decides, per request, who handles it
Legacy mimicThe new system talks to the old one so that the old one "is not aware of any changes"New code behind a legacy interface, or new code writing into the legacy reporting database
Dark launchingCall the new component without using its resultsCompare answers and measure load before anyone depends on it
Canary releaseSend a small share of users to the new component firstLimit the damage of a bug you didn't catch
Divert the flowMove activity away from the legacy system, one flow at a timePricing first, then orders for one product line, then the rest
Revert to sourceIntegrate with the system where the data starts, not a copyStop new systems depending on the legacy system's copies
Transitional architectureTemporary software that eases the move, removed once it's doneThe router, the mimics and the adapters above

Where to intercept: finding the seams

A seam is a place where you can change behaviour without editing the legacy code. The authors list the usual ones, from earliest to latest in a request's path:

  1. A reverse proxy or API gateway in front of the system: route by host, path, header or customer.
  2. The messaging layer: a message router or wire tap that copies or redirects messages.
  3. The web page itself: a script added to a legacy template, for example to change where a form submits.
  4. Database triggers that record writes for a new component. This is the latest seam, and the authors warn it may be "too late": by then the legacy logic has already run.

2. How it works

Displacing an order system, one flow at a time

An online shop's 15-year-old order system is replaced, starting with pricing. This is an invented example shaped like the cases in the article.

3. Try it yourself

Run the router on 1,000 orders

The new pricing service has a rounding bug: it rounds the 10% coupon discount down instead of to the nearest cent. Choose how the router treats /price, and see who finds the bug: your comparison, or your customers.

In code: a strangler router you can run

The whole program: a router with four modes per path prefix, a stable per-customer bucket for canaries, and the same buggy pricing service. It was run in all three languages; each prints the output below it.

def bucket(customer: str) -> int:
    """A stable 0-99 bucket per customer (FNV-1a hash), so each customer always sees the same system."""
    h = 2166136261
    for b in customer.encode():
        h = ((h ^ b) * 16777619) & 0xFFFFFFFF
    return h % 100


class StranglerRouter:
    """Sends each request to the legacy system or the new one, rule by rule (path prefix)."""

    def __init__(self, legacy, new):
        self.legacy, self.new = legacy, new
        self.rules = {}                  # prefix -> (mode, percent); mode: legacy | dark | canary | new
        self.mismatches = []

    def set_rule(self, prefix, mode, percent=0):
        self.rules[prefix] = (mode, percent)

    def handle(self, path, customer, cents, coupon):
        prefix = max((p for p in self.rules if path.startswith(p)), key=len, default=None)
        mode, percent = self.rules.get(prefix, ("legacy", 0))
        if mode == "new" or (mode == "canary" and bucket(customer) < percent):
            return "new", self.new(cents, coupon)
        answer = self.legacy(cents, coupon)
        if mode == "dark":               # ask the new system too and compare, but answer from legacy
            shadow = self.new(cents, coupon)
            if shadow != answer:
                self.mismatches.append((customer, answer, shadow))
        return "legacy", answer


def legacy_price(cents, coupon):         # 10% coupon, rounded half up
    return cents - (cents * 10 + 50) // 100 if coupon else cents


def new_price(cents, coupon):            # the new service's bug: rounds the discount down
    return cents - cents * 10 // 100 if coupon else cents


ORDERS = [("c1", 1999, True), ("c2", 500, False), ("c3", 1250, True), ("c4", 875, True),
          ("c5", 4200, True), ("c6", 1005, True), ("c7", 300, False), ("c8", 2995, True)]

router = StranglerRouter(legacy_price, new_price)
router.set_rule("/price", "dark")
for c, cents, coupon in ORDERS:
    router.handle("/price", c, cents, coupon)
print("dark launch:", len(router.mismatches), "mismatches, 0 customers affected")
for c, old, new in router.mismatches:
    print(" ", c, "legacy", old, "new", new)

router.set_rule("/price", "canary", 25)
print("canary 25%:", " ".join(f"{c}:{router.handle('/price', c, cents, coupon)[0]}" for c, cents, coupon in ORDERS))
print("buckets:", " ".join(f"{c}={bucket(c)}" for c, _, _ in ORDERS))
type Mode = "legacy" | "dark" | "canary" | "new";
type Price = (cents: number, coupon: boolean) => number;

function bucket(customer: string): number {
  // A stable 0-99 bucket per customer (FNV-1a hash), so each customer always sees the same system.
  let h = 2166136261;
  for (const b of new TextEncoder().encode(customer)) h = Math.imul(h ^ b, 16777619) >>> 0;
  return h % 100;
}

class StranglerRouter {
  // Sends each request to the legacy system or the new one, rule by rule (path prefix).
  rules = new Map<string, [Mode, number]>();
  mismatches: [string, number, number][] = [];
  constructor(private legacy: Price, private fresh: Price) {}

  setRule(prefix: string, mode: Mode, percent = 0) { this.rules.set(prefix, [mode, percent]); }

  handle(path: string, customer: string, cents: number, coupon: boolean): ["legacy" | "new", number] {
    const prefix = [...this.rules.keys()].filter((p) => path.startsWith(p)).sort((a, b) => b.length - a.length)[0];
    const [mode, percent] = prefix !== undefined ? this.rules.get(prefix)! : (["legacy", 0] as [Mode, number]);
    if (mode === "new" || (mode === "canary" && bucket(customer) < percent)) return ["new", this.fresh(cents, coupon)];
    const answer = this.legacy(cents, coupon);
    if (mode === "dark") {             // ask the new system too and compare, but answer from legacy
      const shadow = this.fresh(cents, coupon);
      if (shadow !== answer) this.mismatches.push([customer, answer, shadow]);
    }
    return ["legacy", answer];
  }
}

const legacyPrice: Price = (cents, coupon) => (coupon ? cents - Math.floor((cents * 10 + 50) / 100) : cents);  // 10% coupon, rounded half up
const newPrice: Price = (cents, coupon) => (coupon ? cents - Math.floor((cents * 10) / 100) : cents);         // the bug: rounds the discount down

const ORDERS: [string, number, boolean][] = [["c1", 1999, true], ["c2", 500, false], ["c3", 1250, true], ["c4", 875, true],
  ["c5", 4200, true], ["c6", 1005, true], ["c7", 300, false], ["c8", 2995, true]];

const router = new StranglerRouter(legacyPrice, newPrice);
router.setRule("/price", "dark");
for (const [c, cents, coupon] of ORDERS) router.handle("/price", c, cents, coupon);
console.log(`dark launch: ${router.mismatches.length} mismatches, 0 customers affected`);
for (const [c, old, fresh] of router.mismatches) console.log(`  ${c} legacy ${old} new ${fresh}`);

router.setRule("/price", "canary", 25);
console.log("canary 25%: " + ORDERS.map(([c, cents, coupon]) => `${c}:${router.handle("/price", c, cents, coupon)[0]}`).join(" "));
console.log("buckets: " + ORDERS.map(([c]) => `${c}=${bucket(c)}`).join(" "));
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.function.BiFunction;

public class Main {
    enum Mode { LEGACY, DARK, CANARY, NEW }
    record Rule(Mode mode, int percent) {}
    record Mismatch(String customer, int legacy, int fresh) {}
    record Answer(String system, int cents) {}

    static int bucket(String customer) {
        // A stable 0-99 bucket per customer (FNV-1a hash), so each customer always sees the same system.
        int h = 0x811C9DC5;
        for (byte b : customer.getBytes(StandardCharsets.UTF_8)) { h ^= (b & 0xFF); h *= 16777619; }
        return (int) (Integer.toUnsignedLong(h) % 100);
    }

    static class StranglerRouter {
        // Sends each request to the legacy system or the new one, rule by rule (path prefix).
        final Map<String, Rule> rules = new HashMap<>();
        final List<Mismatch> mismatches = new ArrayList<>();
        final BiFunction<Integer, Boolean, Integer> legacy, fresh;
        StranglerRouter(BiFunction<Integer, Boolean, Integer> legacy, BiFunction<Integer, Boolean, Integer> fresh) { this.legacy = legacy; this.fresh = fresh; }

        void setRule(String prefix, Mode mode, int percent) { rules.put(prefix, new Rule(mode, percent)); }

        Answer handle(String path, String customer, int cents, boolean coupon) {
            Rule rule = rules.keySet().stream().filter(path::startsWith).max(Comparator.comparingInt(String::length))
                .map(rules::get).orElse(new Rule(Mode.LEGACY, 0));
            if (rule.mode() == Mode.NEW || (rule.mode() == Mode.CANARY && bucket(customer) < rule.percent()))
                return new Answer("new", fresh.apply(cents, coupon));
            int answer = legacy.apply(cents, coupon);
            if (rule.mode() == Mode.DARK) {   // ask the new system too and compare, but answer from legacy
                int shadow = fresh.apply(cents, coupon);
                if (shadow != answer) mismatches.add(new Mismatch(customer, answer, shadow));
            }
            return new Answer("legacy", answer);
        }
    }

    public static void main(String[] args) {
        BiFunction<Integer, Boolean, Integer> legacyPrice = (c, coupon) -> coupon ? c - (c * 10 + 50) / 100 : c;  // 10% coupon, rounded half up
        BiFunction<Integer, Boolean, Integer> newPrice = (c, coupon) -> coupon ? c - c * 10 / 100 : c;           // the bug: rounds the discount down
        Object[][] orders = {{"c1", 1999, true}, {"c2", 500, false}, {"c3", 1250, true}, {"c4", 875, true},
            {"c5", 4200, true}, {"c6", 1005, true}, {"c7", 300, false}, {"c8", 2995, true}};

        StranglerRouter router = new StranglerRouter(legacyPrice, newPrice);
        router.setRule("/price", Mode.DARK, 0);
        for (Object[] o : orders) router.handle("/price", (String) o[0], (Integer) o[1], (Boolean) o[2]);
        System.out.println("dark launch: " + router.mismatches.size() + " mismatches, 0 customers affected");
        for (Mismatch m : router.mismatches) System.out.println("  " + m.customer() + " legacy " + m.legacy() + " new " + m.fresh());

        router.setRule("/price", Mode.CANARY, 25);
        StringJoiner canary = new StringJoiner(" "), buckets = new StringJoiner(" ");
        for (Object[] o : orders) {
            canary.add(o[0] + ":" + router.handle("/price", (String) o[0], (Integer) o[1], (Boolean) o[2]).system());
            buckets.add(o[0] + "=" + bucket((String) o[0]));
        }
        System.out.println("canary 25%: " + canary);
        System.out.println("buckets: " + buckets);
    }
}
dark launch: 4 mismatches, 0 customers affected
  c1 legacy 1799 new 1800
  c4 legacy 787 new 788
  c6 legacy 904 new 905
  c8 legacy 2695 new 2696
canary 25%: c1:legacy c2:new c3:new c4:new c5:legacy c6:legacy c7:legacy c8:legacy
buckets: c1=57 c2=0 c3=19 c4=14 c5=33 c6=76 c7=95 c8=90

Two details matter in real systems. The canary bucket comes from the customer, not the request, so a customer never flips between old and new prices mid-session. And dark mode only suits calls without side effects: a dark-launched "send confirmation email" sends two emails.

4. Trade-offs

Scaffolding costs vs risk

The router, mimics and adapters are extra software to build, run and later delete. The authors compare it to painter's tape: it costs time to put on and take off, and saves more while you paint, by cutting risk and letting value ship early.

Early vs late interception

A proxy at the edge sees requests before any legacy logic runs, and can send them back in one change. Database triggers are easier to add but see only the result, after the old rules have already run.

Dark launch: confidence vs load

Every dark-launched call runs twice, so the new system takes full load before it serves anyone. It's ideal for reads and calculations, and unsafe for anything that writes, charges or sends.

Mimic vs changing callers

Changing every caller to use the new system spreads the work and the risk across many teams. A mimic keeps one legacy interface in place while the code behind it changes.

5. In real projects

Slice by product line or value stream, not by layer. "Move the database first" delivers nothing anyone can use. "Move gift-card orders end to end" delivers a working slice and teaches you the routing, data and monitoring costs early.

Keep the old reports working. Finance, regulators and downstream systems often read the legacy database directly. A legacy mimic that writes the new system's results into it keeps them working until they can move.

Plan the removal. Transitional architecture that never gets removed becomes the next legacy. Put the switch-off of every router rule, mimic and adapter on the roadmap (Module 30), with an owner.

Moving data is its own project. Copying live data with no downtime needs dual writes, backfills and comparisons; see Module 23 and the Stripe case study in this set.

Say it in the interview

Two-sentence answers
  • "How would you replace a legacy system that can't go down?" Put a router at the earliest seam, then move one flow at a time: dark-launch it and compare, canary it to a few customers, then divert it fully. Each step can be undone with one routing change.
  • "Why not rebuild it with the same features?" Feature parity underestimates the work, rebuilds features nobody uses and ends in a big-bang cutover. I'd start from the outcomes the business needs and move the most valuable slice first.
  • "How do you know the new system is right?" Dark launching: it handles real traffic in parallel and we compare answers before anyone depends on it.
Red flags interviewers notice
  • "First we document everything the old system does."
  • A cutover weekend as the only plan.
  • No way back if the new system misbehaves.

6. Remember it

Memory hook: "Intercept, compare, divert, dismantle"Route at the earliest seam, prove the new part in the dark, move the flow over, then remove the scaffolding.
How this hook works: what "intercept, compare, divert, dismantle" means

What the slogan means, phrase by phrase

"Intercept"
Put a router at a seam, as early in the request's path as you can.
"compare"
Dark-launch: the new part handles real traffic, and its answers are compared, not used.
"divert"
Canary, then move the whole flow; one routing change undoes it.
"dismantle"
When the last flow has moved, remove the legacy system and the temporary router rules, mimics and adapters.

Why this picture fits: a railway bridge is replaced with temporary switches and a test train, one line at a time, and the switches are lifted at the end.

The story → the concept

Replacing a bridgeDisplacing a legacy system
Temporary track and switchesTransitional architecture: the router, mimics, adapters
An empty test trainDark launching
Moving one line at a timeCanary, then divert the flow
Demolishing the old bridge, then the switchesSwitching off the legacy system, then the scaffolding

The trap it saves you from

Feature parity and a big-bang cutover weekend.

7. Common mistakes

Chasing feature parityRebuilding everything the old system does, including what nobody uses, then switching over in one weekend.
Intercepting too lateStarting at the database means the legacy logic has already run. Look for a seam at the edge first.
Dark-launching writesShadow calls that charge cards or send emails do it twice. Dark-launch reads and calculations; canary the rest.
Canary by requestChoosing old or new per request makes one customer see two different prices. Bucket by customer.
Leaving the scaffoldingRouters, mimics and adapters nobody removes become the next legacy system.

8. Design exercise (30 minutes)

  1. Name the outcome (5 min). For a legacy system you know, write the one business outcome it blocks, with a number.
  2. Find the seams (10 min). List where requests enter it: proxy, queue, page template, database. Which is the earliest you could route at?
  3. Pick the first slice (10 min). One product line or flow. How would you dark-launch it, and what would you compare?
  4. List the scaffolding (5 min). Every temporary router rule, mimic and adapter, and the milestone that removes each one.

9. Self-check quiz

Why do the authors call feature parity a trap?

They found it among the factors in several failed displacements.

What does dark launching do?

You compare answers and measure load before anyone depends on it.

Which call is unsafe to dark-launch?

Shadow calls run for real: anything that sends, charges or writes happens twice.

Why bucket canary traffic by customer, not by request?

A stable bucket per customer keeps their experience consistent.

What happens to transitional architecture at the end?

Left in place, it confuses future teams and becomes the next legacy.

10. Go deeper

ResourceTimeWhy
Patterns of Legacy Displacement (Cartwright, Horn, Lewis)45 minThe four activities and every pattern, with case examples.
Feature Parity15 minWhy it fails, and the two cases where it works.
Transitional Architecture15 minThe scaffolding, why it pays, and how it's removed.
Event Interception and Legacy Mimic20 minThe seams, and keeping the old system unaware.
Module 30: Migration, refactoring and roadmapsRevisitThe business case and the roadmap around these patterns.