Working with legacy code
Pin what it does today, odd results included; change it through seams a slice at a time; learn why odd parts exist before removing them.
Predict first
Before you read: predict
A payments ledger prints the month's statements from legacy code that no test covers:
Mia will refactor statementText in small steps, running it on the same 30 saved accounts as she goes and comparing what it prints with a saved copy.
Which comparison does she set up?
// ledger/statement.mjsexport function statementText(accounts) { const out = [`Statements printed ${new Date().toISOString()}`]; for (const a of accounts) { let balance = a.openingPence; for (const e of a.entries) { balance += e.pence; out.push(`${a.id} ${e.day} ${e.memo} ${(e.pence / 100).toFixed(2)} ${(balance / 100).toFixed(2)}`); } } return out.join('\n');}The 30-second version
Legacy code is code without tests you trust. Before changing it, pin what it does now: characterisation tests assert today's outputs for inputs seen in real use, odd results included, so they fail only when behaviour moves. Change it through seams, places where behaviour can switch without editing there, moving one slice at a time to new code.
Pin what it does today, odd results included; change it through seams a slice at a time; learn why odd parts exist before removing them.
How it works
Step 1 of 13
Analogy: Top-working an old cider-apple orchard (fruit growing)
An orchardist has just taken over an old cider orchard. She will graft a new variety onto the old trees, limb by limb, while the orchard keeps cropping.
| In the orchard | In code |
|---|---|
| before any cut she tags every limb with what it bore last autumn, the sour small-fruited limb too, because the cider maker who buys the crop has set his blend by it | pin today's output, odd results included (steps 2–3) |
| a graft cut is where she changes what a limb bears without replanting the tree; which scion goes in is chosen at the cut | a seam and its enabling point (steps 8–9) |
| two or three limbs are reworked each season while the others go on cropping; an old limb comes off once its grafted neighbour bears | strangle gradually (step 12) |
| a gnarled crab-apple branch looks useless, but the previous owner's spray diary shows it pollinates the whole row | find out why first (step 13) |
In real terms: you record what the code does now, change it only where a seam lets you choose, and move it to new code a slice at a time.
Breaks down when: a tag records one autumn, and next year's weather changes the crop; code gives the same output for the same input every run, unless it reads the clock or a random number (step 6). And a graft takes seasons to bear, while a seam switches at once.
Contrasting case: planting a new orchard on cleared ground is green-field work: no crop to keep, so nothing to tag first.
Examples, run for real
4.1 ExamHall EXH-2540: pinning the results export
The 2019 export's formatPercent(points, max) has no test anyone trusts. Run it first, then pin what it printed:
4.1 EXH-2540 formatPercent(51, 80) prints '63.7' · as a double, 51 / 80 * 100 is 63.74999999999999
4.1 EXH-2540 formatPercent(41, 80) prints '51.2' · as a double, 51.24999999999999
4.1 EXH-2540 formatPercent(84, 80) prints '100.0' · 84 / 80 * 100 is 105, and `if (p > 100)` caps it
4.1 EXH-2540 formatPercent(0, 0) prints 'NaN'
4.1 EXH-2540 characterisation.test.mjs pins each of these as printed: 5 of 5 pass on legacy.mjs
51 of 80 is exactly 63.75%, yet the export prints 63.7: the double 51 / 80 * 100 is just under 63.75. A school's report may already show 63.7, so the test pins it (steps 2–3). MR !2840 proposed deleting the if (p > 100) line, since a percentage cannot pass 100. Bonus questions push points past the maximum, as the third line shows, and the ticket behind the line, EH-512, says so (step 13; reading the history belongs to judgment-codebase-orientation).
4.2 Payments ledger: a probe through a seam
The ledger's nightly postInterest(accounts, rateFor = legacyRate) takes its rate lookup as a parameter whose default is the old code. The nightly job passes a wrapper that counts calls by product:
4.2 seam: postInterest(accounts, rateFor = legacyRate) · the nightly job passes countCalls(legacyRate)
4.2 961 accounts posted · rateFor called 961 times: isa 152, savings 799, junior 10
4.2 postings that differ from a run without the probe: 0 of 961
The function was edited once, to add the parameter. The job, the enabling point, now holds the opening question, and every posting is the same as without it (steps 8–11).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 6
A payments ledger splits a bill between payers. Today's code, which no test covers:
In the REPL:
> splitPence(900, 3)
[ 300, 300, 300 ]
> splitPence(1002, 3)
[ 334, 334, 334 ]
> splitPence(1001, 3)
[ 333, 333, 333 ]
Finance's rule is that the shares add up to the bill, any spare pennies going to the first payers, and finance has its own ticket open to make the code follow it. Kofi is about to refactor splitPence in small steps. Before step 1 he writes one test of splitPence(1001, 3).
Which test does he write?
// ledger/split.mjsexport function splitPence(totalPence, ways) { const share = Math.floor(totalPence / ways); const shares = []; for (let i = 0; i < ways; i++) shares.push(share); return shares;}Question 2 of 6
A streaming service's nine-year-old royaltyFor(play) is called from 31 places in its code. A new royalty engine, built and tested on its own, handles podcast plays. Music plays must stay on the old code for now, and other kinds of play will move later.
Which plan holds up?
Question 3 of 6
A chat app's renderMessage(text) is 2,000 lines long, has no tests and no spec, and its author has left. Lena must change how it shows links, and before she touches it she will write characterisation tests.
Which inputs does she pin?
Question 4 of 6
A box office's booking fee lives in one module, and two modules import it:
// checkout/total.mjs
import { bookingFee } from '../fees/booking-fee.mjs';
export function totalPence(order) {
return order.seats * order.seatPence + bookingFee(order);
}
// receipts/receipt.mjs
import { bookingFee } from '../fees/booking-fee.mjs';
export function feeLine(order) {
return `Booking fee: ${bookingFee(order)}p`;
}
A new fee engine is ready for group orders, of eight seats or more. routed sends a group order to it and any other order to today's sum:
// fees/group-fee.mjs
export const groupFee = (order) => 300 + 20 * order.seats;
// fees/routed.mjs
import { groupFee } from './group-fee.mjs';
export const routed = (order) => (order.seats >= 8 ? groupFee(order) : 150 + 40 * order.seats);
Ravi must send group orders to it wherever the fee is charged or shown, while every other order keeps the fee it pays today. At start-up, app.mjs runs once, before any order is taken.
Which change does it?
// fees/booking-fee.mjsexport function bookingFee(order) { return 150 + 40 * order.seats;}Question 5 of 6
A ledger's monthly account fee, in code nobody on the team wrote, and its tests:
// ledger/monthly-fee.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { monthlyFeePence } from './monthly-fee.mjs';
test('a low balance pays 400p', () => assert.equal(monthlyFeePence({ type: 'current', balancePence: 5000, openedYear: 2019 }), 400));
test('100,000p or more pays nothing', () => assert.equal(monthlyFeePence({ type: 'current', balancePence: 100000, openedYear: 2019 }), 0));
test('just under the threshold pays 400p', () => assert.equal(monthlyFeePence({ type: 'savings', balancePence: 99999, openedYear: 2021 }), 400));
test('a large old balance pays nothing', () => assert.equal(monthlyFeePence({ type: 'savings', balancePence: 250000, openedYear: 2008 }), 0));
test('an empty account pays 400p', () => assert.equal(monthlyFeePence({ type: 'current', balancePence: 0, openedYear: 2024 }), 400));
test('an old current account pays 400p', () => assert.equal(monthlyFeePence({ type: 'current', balancePence: 20000, openedYear: 2005 }), 400));
The bank has opened no student account since 2016, so Dee wants to delete the if on lines 3–5 as dead code.
The statement archive keeps last month's 1,200 account records.
Which check can show her that something still uses the if?
// ledger/monthly-fee.mjsexport function monthlyFeePence(account) { let fee = account.balancePence >= 100000 ? 0 : 400; if (account.type === 'student' && account.openedYear < 2012) { fee = 0; } return fee;}Question 6 of 6
A shop's catalog code works out VAT in a legacy module that 40 files import. To move product categories to a new tax service one at a time, starting with books, Ivy added this seam, and start-up code now passes useVat a function that sends books to the new service:
A reviewer comments: "A reassignable module let is a hack.
Make it a VatServices class with a static init(), the proper object-oriented seam, before this merges." Which reply holds up?
// catalog/vat.mjsfunction legacyVat(item) { /* 300 lines, unchanged */ }export let vatFor = legacyVat;export function useVat(fn) { vatFor = fn ?? legacyVat; }Go deeper
Pro tip
- When you don't know what the code returns, write the characterisation test with a placeholder expected value, run it once, and paste the actual value from the failure into the test.
When: pinning a function nobody can predict (steps 2–3). Verify (RAN,
tips.mjs, on the row's legacyformatPercent):tip 1 placeholder: assert.equal(formatPercent(41, 80), '?') · node --test: 0 pass, 1 fail · the failure reports actual: '51.2' tip 1 pasted: assert.equal(formatPercent(41, 80), '51.2') · node --test: 1 pass, 0 fail
Source
Wikipedia and MDN change with edits, so their claims are labelled as of 2026-10-02; Wikipedia's text is revision oldid=1374190248.
- Martin Fowler, Legacy Seam (4 January 2024), the curriculum row's first source: https://martinfowler.com/bliki/LegacySeam.html (
fowler-2024-legacy-seam.txt). The source of steps 8–12; it quotes Feathers's definition ("a seam is a place where you can alter behavior in your program without editing in that place") and shows the module seam of step 10. - Wikipedia, Characterization test (as of 2026-10-02), the row's second source: https://en.wikipedia.org/wiki/Characterization_test (
wikipedia-characterization-test.txt). The source of steps 2, 5, 6 and 7 ("Volatile and non-deterministic values need to be masked / removed, both from the Golden Master as well as from the result of the process."; "Characterization tests are, essentially, change detectors."). - G. K.
- Martin Fowler, Strangler Fig (22 August 2024): https://martinfowler.com/bliki/StranglerFigApplication.html (
fowler-2024-strangler-fig.txt). Teaching only (step 12), since the row names no strangler page: "Like the fig, it begins with small additions, often new features, that are built on top of, yet separate to the legacy code base." It keys no item. - MDN, import (as of 2026-10-02): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import (
mdn-import.txt). Teaching only (step 10): "The imported bindings are called live bindings because they are updated by the module that exported the binding, but cannot be re-assigned by the importing module."
Related lessons
Checked against the tools and docs as of 2026-10-02.