What "Legacy" Actually Means
Legacy code is code that is risky to change because its behaviour and assumptions are poorly understood or weakly protected. Age is a correlate, not the definition.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
Which code in this repository is legacy, and what property am I actually measuring when I decide?
Leadership has approved a quarter of "legacy modernisation". The list of candidate systems was assembled by sorting services by the date of their first commit.
Legacy means old. Sort by age, start at the top, and modernise the oldest thing first — it has had the longest to accumulate problems, and everyone already agrees it is bad.
The oldest service turns out to be a currency-rounding library from 2013 with ninety tests, one owner and no open bugs. Modernising it consumes a month and makes no change cheaper, because no change was ever expensive.
- The oldest service turns out to be a currency-rounding library from 2013 with ninety tests, one owner and no open bugs. Modernising it consumes a month and makes no change cheaper, because no change was ever expensive.
- Meanwhile the six-week-old notification service — written fast, by a contractor, with no tests and behaviour encoded entirely in the shape of a switch statement — is the thing every ticket is stuck behind, and it is not on the list at all.
- As requirements arrive, the age heuristic keeps mispredicting. Age tells you when code was written; it tells you nothing about whether anyone can currently say what it does.
- Worse, "legacy" used as a synonym for "old" is a licence: it lets any code past a certain birthday be described as needing replacement, without anyone having to state what the risk is or how the replacement would be verified.
- The word then does political work instead of engineering work. "It is legacy" ends conversations that "we cannot predict what changing this will do" would have started.
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The quarter is fixed and will not be extended, so whatever is chosen has to be finishable or at least safely abandonable halfway.
- Every candidate system is in production and earning money; none can be paused.
- Two of the original authors are still at the company; the rest of the intent has to be recovered from the code and from the database.
- The list has already been socialised, so replacing it requires an argument, not a preference.
- Whatever is modernised, observable behaviour that customers depend on must survive the work, including the behaviour that is arguably wrong.
- The system must remain deployable throughout — a modernisation that requires a freeze has stopped being modernisation and become a rewrite (The Risk in a Rewrite).
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- Someone must own the actual claim: *what specifically is unknown or unprotected about this code, and what would it cost us if it broke?*
- The test suite — or its absence — owns whether a change can be verified. That is the property being measured, and it belongs to the team, not to the code's birthday.
- Whoever proposes modernisation owns naming the change it makes cheaper. Without a named change this is not modernisation, it is redecoration (The Cost of Change).
- The line between legacy and not-legacy is drawn at *verifiability*, not at a date: can you change this and know what else moved?
- It is drawn per module, not per repository. Most codebases are a mixture, and treating a whole service as uniformly legacy hides which parts are actually dangerous.
- The boundary moves as the team moves. Code whose only expert leaves becomes legacy that afternoon, without a line changing (Bus Factor).
Age and risk are different axes
Put the two variables on separate axes and the definition stops being an argument about words. Age is on one; whether anyone can currently state and verify the behaviour is on the other. Only one of them predicts what a change will cost.
The two cells on the diagonal are the interesting ones, and they are the reason the word matters. Old-and-protected code is routinely rewritten for nothing. New-and-unprotected code is routinely shipped without anyone noticing that it arrived already dangerous.
- The column decides the cost of change. The row decides how bad you feel about it, which is not the same thing.
- The bottom-right cell is the one age-based ranking cannot see, and it is where most teams are actually stuck.
- The top-left cell is where modernisation budgets most often go, because that code looks worst in a code review and is easiest to get approval to replace.
| Behaviour stated and verified | Behaviour is folklore, no tests | |
|---|---|---|
| Written in 2013 | Not legacy. Boring, unfashionable, and cheap to change. The rounding library nobody has touched in four years because nothing has asked it to. | Legacy, and the archetype. Original authors gone, the only spec is the code, and the retry path may or may not double-charge. |
| Written six weeks ago | Not legacy. The normal state of code you want more of. | Legacy on the day it merged. A contractor's notification service with behaviour encoded in a switch statement and no test that would fail if it changed. |
The smell is not in the code, it is in the estimates
You can usually find legacy code without reading any of it, by looking at how the team behaves around it. Estimates inflate, ownership becomes vague, and changes get bundled — three people wait for the same release because nobody wants to touch that module twice.
The tell is asymmetry: a one-line change costs the same as a ten-line change, because the expensive part is neither of them. It is establishing what else moved.
looks like Any ticket touching billing/ comes back as "two to three weeks, maybe". Changes to it are batched into a single quarterly release. The pull requests are reviewed by whoever is available, because nobody claims to understand it, and review comments are about style rather than behaviour.
suggests Behaviour is unknown and unprotected, so the whole cost of the change is discovery and verification. The estimate is not padding — it is an honest price for reading the module, guessing at its assumptions, and hoping production agrees.
fix Do not restructure first. Write characterization tests for the paths the pending change touches, which converts "two to three weeks, maybe" into an estimate someone can defend, and does it without altering behaviour (Characterization Tests).
Pricing the same change in both directions
The definition earns its keep when the two heuristics disagree, so price a real requirement against both candidates. Here the "old" system is the safe one and the "new" system is not, which is the outcome the age heuristic cannot produce.
Note what the after-state costs. A characterization suite over a decade-old module is not free to own: it is large, it asserts behaviour that is arguably wrong, and it will resist exactly the change you eventually want to make.
Invoices must be issued in the customer's local currency, with the exchange rate fixed at the moment of issue and recorded on the invoice.
Three days of the estimate is reading the code to work out whether the retry worker re-renders the invoice or reuses the rendered one, because the answer decides whether the rate can move between attempts. Nobody can answer it from the repository.
The change is two edits. The suite answers "what else moved?" in ninety seconds, including the four places where rounding behaviour is subtly non-obvious and deliberately preserved.
How to build it
Most important first.
- Define legacy as risk of change, and make the definition operational: for a given module, can we state its behaviour, and would a test fail if that behaviour changed? If both answers are no, it is legacy.
- Rank candidates by risk times change rate, not by age. Code that is unprotected and never touched is a low priority; code that is unprotected and touched weekly is where the quarter should go (Change Amplification).
- Say what each unknown is. "Nobody knows whether the retry path double-charges" is a modernisation target. "It is old" is not.
- Start with the cheapest instrument that converts unknown behaviour into known behaviour: characterization tests, before any structural change (Characterization Tests).
- Record what you learn where the code is, so the knowledge does not evaporate again with the next reorganisation (Docs Close to Code).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Under the age heuristic, the next change costs whatever it costs — the ranking does not affect it, because effort has been spent on code that was not blocking anything.
- Under the risk heuristic, the next change to a modernised module costs one edit plus a test run, because the whole point of the work was to make "what else moved?" answerable.
- The cost that does not fall: a change to a module you correctly *deprioritised* is still expensive, and will stay expensive. The heuristic buys you the right expensive modules, not the absence of expensive modules.
- And the cost of assessment itself is real: honestly answering "can we state this module's behaviour?" for forty modules takes about a week, which is a week not spent changing anything.
- Risk-based ranking is harder to sell than age-based ranking, because age is objective and risk is a judgement. Some of the resistance is legitimate: a judgement can be gamed by whoever wants their favourite rewrite funded.
- It also makes some genuinely awful old code a low priority, and living with code you find embarrassing is a real morale cost that this framing does not price.
- Assessment takes time that produces no shipped change, and if the quarter is short that overhead can be a substantial fraction of it.
What can go wrong
- The definition is adopted but the ranking is not, so teams keep modernising the oldest thing while saying the new words.
- "Risky to change" is assessed by the people most familiar with the code, who systematically underrate the risk because the knowledge is in their heads and not in the repository.
- Every module gets labelled legacy, at which point the label stops discriminating and the quarter has no plan.
- The opposite failure: a module with excellent tests of the *wrong* behaviour is declared safe, and the tests turn out to assert an implementation rather than a contract (Mocking).
- The definition depends on the team, not only on the code: the same module is legacy for a team that has lost its author and not legacy for one that has not.
- It depends on the test suite, which is why testability and legacy are the same conversation viewed from two ends (Testing as Design Feedback).
- It depends on the observability of production behaviour: a module with rich, queryable logs is less unknown than an identical module without them (Debuggability by Design).
- "So old code is fine." Not the claim. Old code is *more likely* to be legacy — authors leave, assumptions rot, the language moves — but the correlation is weak enough that using age as the decision variable produces bad decisions.
- "So we just need more tests." Tests make behaviour verifiable, which is most of it, but code can also be risky because its assumptions are undocumented, its dependencies are global, or its only expert left. Coverage is a proxy, not the property.
- "Legacy means bad code." A module can be well written, well factored and still legacy, if nothing verifies it and nobody left understands what it is for.
- "This is just technical debt." Debt is a deliberate or accidental choice that raises future cost; legacy is a *state of knowledge* about existing behaviour. They overlap, but a system can carry heavy debt with excellent tests, and carry none with none (What Technical Debt Actually Is).
- "Then we should rewrite it." That conclusion needs its own argument, and the argument is usually worse than it looks (The Risk in a Rewrite).
- god-object
- shotgun-surgery
Testing it, and how it ages
- The presence and quality of tests is the measurement instrument here, so audit them before trusting them: a suite that passes when behaviour changes is worse than no suite, because it produces confidence without protection.
- Test the claim, not the vibe: take a real recent change to the candidate module and ask what actually caught the regressions — the suite, review, or production.
- For each candidate, write one characterization test on the riskiest path. How hard that is *is* the assessment (Characterization Tests).
- Code drifts into legacy quietly, mostly through personnel change and requirement drift rather than through edits. The status should be re-assessed roughly whenever ownership changes (Code Ownership).
- A module can also drift *out* of legacy without being rewritten — adding tests and an owner is usually cheaper than restructuring, and moves the same variable.
- The definition itself ages well because it is about the relationship between a team and a codebase, which is what actually determines cost.
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALThat change risk tracks verifiability rather than age follows from what a change actually requires — knowing current behaviour and detecting deviation — so it holds across languages, paradigms and company sizes.
- SCALE-SPECIFICAt three engineers, knowledge in heads is a workable substitute for tests and the definition rarely bites; at eighty, with quarterly team reshuffles, head-knowledge evaporates continuously and the same codebase becomes legacy without anyone editing it. The heuristic is far more valuable in the second case.
- CONTESTEDThe strongest opposing view: age is a real proxy and a cheap one. Old code accumulates dead branches, unsupported runtimes, unpatched dependencies and dead assumptions, and an age-sorted list can be produced in an afternoon by anyone, whereas a risk assessment costs a week of senior time and is arguable at every row. Practitioners who ship large modernisation programmes often use age precisely because it is unarguable. The counter is not that age is uninformative but that it is the wrong *decision* variable when the two disagree — and they disagree exactly at the modules that matter most.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — the measurement here is whether a suite would fail if behaviour changed, which is a mutation-testing and coverage-quality question this domain assumes rather than answers.