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.
- Sam How do you replace a system that can't be switched off?
- Priya In slices, while it keeps running. Module thirty introduced the strangler fig. This module is the fuller playbook: the four activities, 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.
- Priya Think of replacing a railway bridge while trains keep running. You build the new span beside the old one, lay temporary switches, run an empty test train, then move one line at a time. At the end you demolish the old bridge, and then the temporary switches too.
- Sam Where does the playbook come from?
- Priya Three Thoughtworks technical directors, Ian Cartwright, Rob Horn and James Lewis, published Patterns of Legacy Displacement on Martin Fowler's site. They group the work into four activities: understand the outcomes, break the problem up, deliver the parts, and change the organisation.
- Sam Why not just rebuild what the old system does?
- Priya That's feature parity, and the authors found it among the factors in several failures. People underestimate the effort, rebuild features nobody uses, turn old workarounds into requirements, and end up with one big-bang cutover. It only fits power-user screens and systems built on a fixed specification.
- Sam What are the patterns?
- Priya Event interception: a router at a seam decides who handles each request. Legacy mimic: the new system talks to the old one so the old one doesn't notice. Dark launching: call the new part on real traffic, but don't use its answers. Canary release: a small share of customers first. Divert the flow: move one flow at a time. And transitional architecture: all that scaffolding, removed at the end.
- Sam Where do I put the router?
- Priya At the earliest seam you can find: a proxy or gateway at the edge, then the messaging layer, then the page template. Database triggers are the latest seam, and the authors warn they may be too late, because the old logic has already run.
- Sam Show me.
- Priya An online shop moves pricing out of a fifteen-year-old order system. A router goes in front. Pricing is dark-launched, and the comparison catches a rounding bug before any customer sees it. After the fix, five percent of customers get the new prices. Then all pricing moves, and a legacy mimic keeps writing prices into the old reports database that finance still reads. Slice by slice, legacy shrinks, and at the end the router rules and the mimic are removed too.
- Sam Can I try it?
- Priya Run the router on a thousand invented orders with a rounding bug in the new service. In dark mode, the comparison finds the wrong prices and no customer sees one. Divert straight to the new service, and the bug becomes an incident.
- Priya Your turn. Is it safe to dark-launch "send the order confirmation email"?
- Priya No. Shadow calls run for real, so every customer would get two emails. Dark-launch reads and calculations; canary anything that writes, charges or sends.
- Sam And the code?
- Priya A small router in Python, TypeScript and Java: four modes per path, and a stable bucket per customer, so nobody flips between old and new prices mid-session.
- Sam The memory hook?
- Priya "Intercept, compare, divert, dismantle." Route at the earliest seam, prove the new part in the dark, move the flow over, then remove the scaffolding.
- Sam Common mistakes?
- Priya Chasing feature parity. Intercepting too late, at the database. Dark-launching writes. Choosing the canary per request instead of per customer. And leaving the scaffolding behind to become the next legacy system.
1. The idea
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:
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:
- The effort is underestimated. Even writing down everything the old system does can take longer than planned.
- Old systems carry features nobody uses. Rebuilding them is wasted work.
- Workarounds become requirements. Fixes for old bugs and limits turn into "must haves".
- It copies old constraints. A design shaped by 1990s hardware rarely makes sense to rebuild as it is.
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
| Pattern | What it does | Typical use |
|---|---|---|
| Event interception | Intercept updates to the system's state and route some of them to a new component | A proxy or message router in front of the legacy system decides, per request, who handles it |
| Legacy mimic | The 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 launching | Call the new component without using its results | Compare answers and measure load before anyone depends on it |
| Canary release | Send a small share of users to the new component first | Limit the damage of a bug you didn't catch |
| Divert the flow | Move activity away from the legacy system, one flow at a time | Pricing first, then orders for one product line, then the rest |
| Revert to source | Integrate with the system where the data starts, not a copy | Stop new systems depending on the legacy system's copies |
| Transitional architecture | Temporary software that eases the move, removed once it's done | The 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:
- A reverse proxy or API gateway in front of the system: route by host, path, header or customer.
- The messaging layer: a message router or wire tap that copies or redirects messages.
- The web page itself: a script added to a legacy template, for example to change where a form submits.
- 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
- "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.
- "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
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 bridge | Displacing a legacy system |
|---|---|
| Temporary track and switches | Transitional architecture: the router, mimics, adapters |
| An empty test train | Dark launching |
| Moving one line at a time | Canary, then divert the flow |
| Demolishing the old bridge, then the switches | Switching off the legacy system, then the scaffolding |
The trap it saves you from
Feature parity and a big-bang cutover weekend.
7. Common mistakes
8. Design exercise (30 minutes)
- Name the outcome (5 min). For a legacy system you know, write the one business outcome it blocks, with a number.
- Find the seams (10 min). List where requests enter it: proxy, queue, page template, database. Which is the earliest you could route at?
- Pick the first slice (10 min). One product line or flow. How would you dark-launch it, and what would you compare?
- 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?
What does dark launching do?
Which call is unsafe to dark-launch?
Why bucket canary traffic by customer, not by request?
What happens to transitional architecture at the end?
10. Go deeper
| Resource | Time | Why |
|---|---|---|
| Patterns of Legacy Displacement (Cartwright, Horn, Lewis) | 45 min | The four activities and every pattern, with case examples. |
| Feature Parity | 15 min | Why it fails, and the two cases where it works. |
| Transitional Architecture | 15 min | The scaffolding, why it pays, and how it's removed. |
| Event Interception and Legacy Mimic | 20 min | The seams, and keeping the old system unaware. |
| Module 30: Migration, refactoring and roadmaps | Revisit | The business case and the roadmap around these patterns. |