skip to content

Working with Legacy Code

Feathers defines legacy code as code without tests, and the trap is that you need tests to change it safely and changes to make it testable. You will learn characterization tests, finding seams, and dependency-breaking moves like sprout method and wrap method.

part ofSoftware design & architectureoverview, primer and where to startread it →
on this pageshow

questions

6

In Michael Feathers' book "Working Effectively with Legacy Code", how is "legacy code" defined, and what is the "Legacy Code Dilemma" that follows from that definition?

level: juniorimportance: must knowfreq 60%

answer

  1. Legacy = code without tests
  2. Age/author/language irrelevant
  3. Dilemma: tests need change, change needs tests
  4. Break the cycle with signature-preserving edits
  5. Fear -> copy-paste -> more rot

basics

~20 s

Feathers defines legacy code as code without tests, regardless of its age or quality. The dilemma: to change code safely you want tests first, but to make the code testable you usually have to change it first.

solid answer

~50 s

Feathers deliberately redefines legacy code as code without tests. Age, language, formatting or original author are irrelevant: code written yesterday with no tests is legacy; a well-tested twenty-year-old system is not. The definition is useful because it names the real problem: without tests you have no fast, reliable feedback that a change preserved behaviour, so every edit is a gamble, and fear makes the code rot further (people bolt on copies rather than refactor). That yields the Legacy Code Dilemma: when we change code we should have tests, but to put tests in place we often must change the code. Feathers resolves it by making the first, test-enabling change as small and mechanically safe as possible (preserve signatures, lean on automated refactorings, exploit an existing seam), then writing characterization tests, then refactoring freely under that net.

go deeper

for a junior

State the definition (code without tests), note that age is irrelevant, and state the dilemma in one sentence.

for a middle

Add why it matters (no feedback loop breeds fear and copy-paste growth) and name the first-step tactics: signature-preserving edits and automated refactorings before hand edits.

for a senior

Connect it to the workflow: identify change points, break dependencies minimally, write characterization tests, then refactor. Mention that slow or flaky tests leave code effectively legacy.

for a principal

Frame it as a risk and economics question: coverage accretes where change happens, and the organisational goal is shrinking the fear-driven-change spiral. Discuss how to fund this incrementally rather than as a rewrite.

## The definition Michael Feathers, in *Working Effectively with Legacy Code* (2004), proposes a deliberately provocative definition: > **Legacy code is code without tests.** This is not the colloquial meaning. Colloquially "legacy" means old, inherited, written in an unfashionable language, or written by someone who has left. Feathers discards all of that because none of it determines whether you can change the code with confidence. What determines that is whether you have a **fast, deterministic, automated check** that tells you, within seconds, that behaviour you did not intend to change is still intact. Terms used above: - **Test** here means an automated unit-level test: it runs without a database, network, filesystem, container, or human, and it fails loudly when behaviour changes. - **Behaviour** is what the code does that someone can observe: return values, exceptions thrown, calls made to collaborators, state mutated. ## Why the definition is useful It converts a vague complaint ("this codebase is horrible") into an actionable engineering condition ("this code has no feedback loop"). Two consequences follow: 1. **Editing becomes fear-driven.** Without tests, the cheapest way to add a feature is to *not touch* the existing code: copy a method and tweak the copy, add another `if`, add another flag. Each such act increases duplication and coupling. Feathers calls this the downward spiral of legacy code: fear causes changes that make future change scarier. 2. **It sets a clear success criterion for improvement work.** You are done making a piece of code non-legacy when you can exercise it in a test harness quickly and independently. ## The Legacy Code Dilemma Feathers states it directly: > **When we change code, we should have tests in place. To put tests in place, we often have to change code.** The circularity is real. Typical blockers you hit when you try to instantiate a class in a test: - The constructor reaches out and creates a database connection, opens a socket, or reads a config file. - The class calls a static/global singleton (`Registry.getInstance()`, `DateTime.now()`, `System.getEnv()`). - The method you care about is buried in a 900-line method with no return value, whose only effect is a side effect on a global. - Constructing the object requires constructing a large graph of other objects ("irritating parameter" / "construction blob" problems). ## How the dilemma is resolved The resolution is not "give up and test through the UI". It is: make the **first** change one whose risk you can control by other means, so you spend risk only once. - **Prefer mechanically safe edits.** IDE-verified automated refactorings (Extract Method, Introduce Parameter, Rename) are far safer than hand editing, because a tool performs them. Feathers' rule: while you have no tests, only perform edits you can argue are behaviour preserving by construction. - **Preserve signatures.** When breaking a dependency, move whole blocks of code without retyping them and keep parameter lists identical, so you cannot silently reorder or drop an argument. - **Exploit an existing seam** (a place where you can change behaviour without editing in that place) if one already exists, e.g. the class already takes a collaborator as a constructor argument. - **Sensing vs separation.** Sometimes you do not need to isolate the class fully; you only need to *sense* what it did (capture a value it would have written out). That is often a smaller change than full decoupling. - **Then write characterization tests** that pin down current behaviour (bugs included), and only then refactor or add features. ## Common edge cases - **Code with tests that are slow or non-deterministic** is, for practical purposes, still legacy: a suite that takes 40 minutes or fails randomly gives no usable feedback, so people stop trusting it. - **Code with tests that assert nothing** ("it ran without throwing") is legacy too. - **Generated code** is usually exempt: you test the generator, not the output. - **Code you are about to delete** should not be tested; confirm it is dead and remove it. ## The pragmatic scoping rule You do not test an entire legacy system before touching it. You test **around the change point**, in what Feathers' Legacy Code Change Algorithm calls the test-covering step. Coverage grows where the work happens; untouched regions stay untested until someone needs to touch them.

  • Does a codebase with 90% line coverage automatically stop being legacy under this definition?
    No. Coverage measures execution, not assertion. Tests that execute code but assert little, or that are slow/flaky enough that people ignore red builds, provide no feedback loop, so the code still behaves like legacy code. What matters is whether a behaviour change reliably turns a fast test red.
  • If the definition is 'no tests', is the fix simply 'write tests for everything first'?
    No, and Feathers explicitly argues against it. Retro-fitting full coverage on a large system is a project no one funds and it delays value. You cover the change point and its dependencies, then let coverage accrete change by change.

Operating without tests is surgery without vital-sign monitors: you might be doing everything right, but nothing tells you the moment you are not. The dilemma is that hooking up the monitors requires touching the patient, so you make that first touch as small and standard as possible.

saying these in an interview costs you the question

  • Saying legacy code just means old code, or code in an old language
  • Claiming you must achieve full test coverage of the system before making any change
  • Treating 'it compiles and the app still starts' as a substitute for tests
  • Assuming manual QA gives the same safety as automated tests (it is far slower and non-repeatable)
  • Believing well-written code without tests is not legacy — readability helps, but gives no change-detection

context

open as a page

What is a characterization test, how do you write one for code you do not understand, and what should you do when it reveals behaviour that looks like a bug?

level: middleimportance: must knowfreq 58%

basics

~20 s

A characterization test pins down what code actually does today, not what it should do. You call the code, assert a deliberately wrong value, read the failure message to learn the real value, and change the assertion to match. If the real behaviour looks like a bug, you still record it, then ask before changing it.

open as a page

In legacy-code refactoring, what is a "seam" and its "enabling point", and what are the main kinds of seam available (object, link, and preprocessing/build seams)?

level: seniorimportance: must knowfreq 50%

basics

~20 s

A seam is a place where you can change what code does without editing that code in that place. Its enabling point is where you make the choice (a constructor argument, a build configuration, a linker path). Seams let you swap real collaborators for fakes so untestable code becomes testable.

open as a page

Explain the Sprout Method, Sprout Class, Wrap Method and Wrap Class techniques for adding behaviour to untested legacy code, and when you would choose each.

level: middleimportance: should knowfreq 45%

basics

~20 s

All four let you add new, tested code without untangling the old code. Sprout puts the new logic in a new method or class and calls it from one place in the old code. Wrap renames the old method and puts a new method in its place that calls both, or puts the old object behind a decorator that adds behaviour.

open as a page

Walk through Michael Feathers' Legacy Code Change Algorithm and explain how effect sketching and pinch points help you decide where to put tests.

level: seniorimportance: should knowfreq 42%

basics

~20 s

The algorithm is: identify change points, find test points, break dependencies, write tests, then make your change and refactor. To find test points you sketch which values a change can affect, then look for a narrow place all those effects flow through — a pinch point — and test there.

open as a page

You must restructure code that has no tests and cannot cheaply get any. What disciplines make editing safe(r) without a safety net, and how do you sequence a large legacy restructuring across a team?

level: principalimportance: nice to knowfreq 30%

basics

~20 s

Only make edits a tool or the compiler can verify: automated refactorings, preserving method signatures, moving code without retyping, one goal per edit, small steps compiled and run often. For big restructurings, work in small always-shippable increments, route new work through a new implementation, and keep the old path live until traffic is moved.

open as a page