Legacy code & the boy-scout rule
Legacy = code without tests (Feathers). Change it safely with the algorithm: find a seam, pin behaviour with characterization tests, then change and refactor under that net. Boy-scout incrementally; resist the rewrite and the gold-plating that balloons the diff.
You’re handed a 600-line OrderService to add one field to. It has no tests, the dependencies are new-ed inside the constructor, and a pricing branch reaches into a static Clock.now(). Every instinct says “this is garbage, rewrite it.” Every senior who has shipped the rewrite knows how that ends: six months later you have two systems to maintain, the new one missing the edge cases the old one quietly handled, and a business that has stopped trusting your estimates.
The other path is unglamorous and it works. You don’t need to love the code; you need to change it safely. That requires one thing the code doesn’t have yet — a way to know you didn’t break it. This lesson is the discipline for getting that net under untested code and then leaving the area a little better than you found it, without letting “a little better” devour your week.
After this lesson you can apply Feathers’ working definition of legacy code (code without tests) and explain why it changes your strategy; run the change algorithm — find a change point, find a seam, write characterization tests, make the change, refactor under the net; apply the boy-scout rule as opportunistic cleanup bounded by the blast radius of the change you’re already making; and recognise the two failure modes that wreck it — the doomed big rewrite, and gold-plating that balloons the diff and the regression risk.
Legacy code is simply code without tests — and that definition tells you what to do. Michael Feathers’ working definition strips the word of its emotion: legacy isn’t “old” or “someone else’s” or “in a language I dislike.” It’s code you cannot change with confidence because nothing tells you when you’ve broken it. Brand-new code you wrote this morning with no tests is already legacy by this measure.
The point of the definition is operational. If the problem is “no safety net,” the solution is not “be careful” or “read harder” — it’s get a net under it first. Everything else in this lesson follows from that. You don’t refactor legacy code and then test it; you test it and then you can refactor it.
Before you can test it, you need a seam — a place to alter behaviour without editing the code in that spot. The reason legacy code is hard to test is usually that it wires its own collaborators: it news a database client, calls a static singleton, reads Date.now() directly. A seam is the point where you can substitute a fake. Often the cheapest seam is dependency injection at the boundary you already need to touch.
// No seam: the dependency is welded in, so the branch can't be tested in isolation.
class OrderService {
price(order: Order): number {
const now = new Date(); // welded to wall-clock time
const fx = new FxClient().rateFor(order.currency); // welded to network
return applyRules(order, now, fx);
}
}
// A seam introduced by parameterizing the boundary — behaviour identical today.
class OrderService {
constructor(private clock: () => Date, private fx: FxRates) {}
price(order: Order): number {
return applyRules(order, this.clock(), this.fx.rateFor(order.currency));
}
}You haven’t changed any rules. You’ve made the code substitutable, which is the precondition for pinning its behaviour.
Pin the current behaviour with characterization tests — not “correct” behaviour, current behaviour. A characterization test documents what the code actually does today, bugs included. You don’t assert what the price should be; you run the code, observe what it returns, and freeze that as the expected value. The test’s job is not correctness — it’s a tripwire that fires the moment your edit changes any observable output.
// You didn't decide 47.5 is "right" — the code produced it. You pinned it.
test("characterize: GBP order at 2021 rates", () => {
const svc = new OrderService(() => new Date("2021-06-01"), fakeRates({ GBP: 1.18 }));
expect(svc.price(gbpOrder)).toBe(47.5); // whatever it actually returns, locked in
});If a characterization test surfaces what looks like a bug, you do not fix it now. You pin the buggy output, ship your real change, and fix the bug as a separate, visible change with its own test. Conflating “make my change” with “fix the bug I found” is how a one-line change becomes an unreviewable diff.
Now make the change, then boy-scout only inside the blast radius you’ve already touched. With the net in place, your real edit is safe: if you break pinned behaviour, a test goes red. The boy-scout rule — always leave the code a little cleaner than you found it — is the incremental strategy that, repeated across hundreds of commits, drags a codebase off the high-cost end of the change curve without anyone ever scheduling a “cleanup sprint.”
The senior qualifier is the word opportunistic. You improve the code you’re already reading and already covering with tests — rename the misleading variable in the function you’re editing, extract the duplicated branch you just had to understand. You do not open neighbouring files to “tidy them while you’re here.” The cleanup must ride inside the change’s existing blast radius. The moment it leaves that radius, it’s a new piece of work that needs its own justification, its own tests, and its own review — and folding it into this diff just makes both changes harder to review and riskier to ship.
A real ticket: “add a loyalty discount to checkout.” The function is untested and reaches into the clock and an FX singleton. The naive move is to edit it in place and eyeball the result. The legacy-safe move:
// BEFORE — untested, dependencies welded in. Touching this is a gamble.
function checkoutTotal(cart: Cart): number {
const now = new Date();
const rate = FxSingleton.rateFor(cart.currency);
let total = cart.lines.reduce((s, l) => s + l.qty * l.unitPrice, 0);
if (now.getMonth() === 11) total *= 0.95; // december promo, magic literal
return total * rate;
}Step 1 — seam: parameterize the two welded boundaries so a fake can drive them. Step 2 — characterize: pin what it does today, December promo and all.
function checkoutTotal(cart: Cart, clock: () => Date, fx: FxRates): number {
const now = clock();
const rate = fx.rateFor(cart.currency);
let total = cart.lines.reduce((s, l) => s + l.qty * l.unitPrice, 0);
if (now.getMonth() === 11) total *= 0.95;
return total * rate;
}
test("characterize: december promo applies", () => {
expect(checkoutTotal(cart, () => new Date("2023-12-10"), fakeRates({ USD: 1 })))
.toBe(/* whatever it returns now */ 95);
});Step 3 — make the real change under the net (add the loyalty discount). Step 4 — boy-scout within the function you already opened: the bare 0.95 and === 11 are magic numbers you had to decipher to make your change, so naming them is inside the blast radius:
const DECEMBER = 11, DECEMBER_PROMO = 0.95;
function checkoutTotal(cart: Cart, clock: () => Date, fx: FxRates, loyalty: LoyaltyTier): number {
const total = subtotal(cart);
const promo = clock().getMonth() === DECEMBER ? DECEMBER_PROMO : 1;
return total * promo * loyaltyRate(loyalty) * fx.rateFor(cart.currency);
}What you did not do: chase the FxSingleton into the three other files that use it, or rewrite Cart because its shape annoys you. Those are real debts — but they’re outside this ticket’s radius, so they get their own ticket. The diff a reviewer sees is “add loyalty discount + name two constants in the function it touched,” not “rewrote pricing.”
▸Why this works
Why incremental boy-scouting beats the big rewrite, every time the rewrite is even an option. A rewrite throws away the one asset the legacy code has: years of accumulated edge cases encoded as ugly branches. Each weird if is usually a bug someone hit in production. The rewrite starts from a clean mental model that omits those cases, so it re-discovers them one outage at a time — while the old system still has to be maintained in parallel because you can’t cut over until the new one reaches parity, which is always later than promised. Incremental refactoring under tests keeps the system shippable and the edge cases intact at every step. You’re never more than one green test run away from a deployable state. That continuity, not elegance, is why it wins.
▸Common mistake
The boy-scout rule’s failure mode is gold-plating: “while I’m in here” becomes a license to reformat, rename, and re-architect code unrelated to your change. The diff balloons from 8 lines to 800, the reviewer can no longer separate your behaviour change from cosmetic churn, and your one real change is now buried where a regression can hide. Worse, the unrelated edits aren’t covered by the characterization tests you wrote for this change, so you’ve increased blast radius and lowered confidence simultaneously. The rule is “a little cleaner,” bounded by the area you already had to touch and already pinned with tests. If a cleanup tempts you outside that radius, write a ticket, not a diff.
You're adding one field to an untested 400-line function. While reading it you spot three unrelated code smells in neighbouring functions and what looks like a latent bug in the function you're editing. Applying the change algorithm and the boy-scout rule, what do you do?
Legacy code is code without tests — the absence of a safety net, not its age — so the strategy is always get a net under it first, never “be careful.” The change algorithm: find the change point, introduce a seam so a dependency becomes substitutable, write characterization tests that pin current behaviour (bugs and all), make your real change under that net, then refactor the now-protected area. The boy-scout rule — leave it a little cleaner — is the incremental discipline that keeps the cost-of-change curve flat across hundreds of commits and beats the rewrite trap, which discards hard-won edge cases and forces parallel maintenance. The senior discipline is opportunistic and bounded: improve only what’s inside the blast radius you already touched and already covered with tests. Step outside that radius — gold-plating neighbouring code, fixing found bugs in the same diff — and you trade a small safe change for a large risky one. A little cleaner, under tests, inside the radius.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.