Library · Summary & review

Working Effectively
with Legacy Code

By Michael Feathers. The book that redefined what "legacy code" means — and gave every developer a method to tame it, one test at a time.

FR EN
Working Effectively with Legacy Code book cover

Working Effectively with Legacy Code

Working Effectively with Legacy Code

7 /10

« The book that redefined "legacy code": not old code, but code without tests — and twenty techniques to tame it without breaking everything. »

  • AuthorMichael Feathers
  • OriginalPrentice Hall PTR, 2004 · 440 pages
  • EditionSingle edition (2004), English only
  • This page~9 min read
Book rating across 5 dimensionsIdeas9/10Practical7/10Readability6/10Aged well7/10Examples6/10

Legacy code = code without tests: the definition that stings, and the techniques to get out without a full rewrite.

Why this book

At an Extreme Programming conference, Michael Feathers ran into a colleague who'd been visiting a team. Feathers asked how they were doing. The colleague said: "They're writing legacy code, man." That sentence hit him in the gut. Not because the team was lazy or incompetent — they were trying hard — but because they had no tests around their work, so every change was a gamble. That moment is where this book starts.

Feathers spent years helping teams with large, tangled code bases. He kept solving the same problems over and over: how to get a class into a test harness when the constructor calls a database, how to add a feature without accidentally breaking three others. He wrote the techniques down so teams wouldn't have to rediscover them on their own.

The ideas that stick

1Legacy code is not old code. It's code without tests.

Most developers use "legacy code" as a polite word for someone else's mess. Feathers redefines it: "To me, legacy code is simply code without tests." (Preface). The implication is uncomfortable. Code you wrote yourself last week, without tests, is already legacy. A beautifully named, perfectly refactored codebase with no test suite is legacy. Age and authorship are irrelevant.

Why does the test criterion matter? "Code without tests is bad code. It doesn't matter how well written it is; it doesn't matter how pretty or object-oriented or well-encapsulated it is. With tests, we can change the behavior of our code quickly and verifiably. Without them, we really don't know if our code is getting better or worse." (Preface). That sentence is the book's thesis in two sentences. Everything else is technique.

2The chicken-and-egg trap — and how to escape it

The cruel logic of legacy code: to change it safely you need tests; to add tests you often need to change the code first. Feathers names this "The Legacy Code Dilemma" and refuses to pretend it doesn't exist. Instead he offers a five-step algorithm as the daily rhythm of legacy work: (1) identify change points, (2) find test points, (3) break dependencies, (4) write tests, (5) make changes and refactor (ch. 2).

The day-to-day goal is not bug-free code. It's to extend the area covered by tests, a little more with each change, until tested islands grow into tested continents. "Over time, the islands become large landmasses. Eventually, you'll be able to work in continents of test-covered code." (ch. 2). It's an incremental land-grab, not a revolution.

3Seams: swapping behavior without editing the class

Feathers introduces his most original concept: a seam is "a place where you can alter behavior in your program without editing in that place." (ch. 4). When a class directly instantiates its collaborators, there are no seams. When it receives them through a constructor or an interface, there are seams: you can swap the behavior without touching the calling code.

Every seam has an enabling point: the exact place where you decide which behavior to use. Feathers lists three kinds — C macros, Java classpath settings, and object seams — but for a PHP or JavaScript developer, only one matters: the object seam. That's the constructor. You pass the dependency when creating the object, and you can pass anything without touching the class.

The seam model is not about mocks or frameworks. It's about reading your code and seeing its joints.

Seams: code without vs with a seam ✗ No seam MyClass new MyService() MyService hard coupling not replaceable in tests ✓ With a seam MyClass injection «interface» IService enabling point — where you choose MyService production FakeService tests The seam: the injected interface. The enabling point: the constructor, the one place to choose MyService (prod) or FakeService (tests).
Without injection, new MyService() is baked into the class: no way to replace it in tests. The injected interface creates the seam. The constructor is the enabling point.

4Sensing and Separation: why classes resist testing

When a class is hard to test, the cause is almost always one of two things. Sensing: the class computes values you cannot observe from outside (they go directly to a database or a display). Separation: the class cannot be instantiated in a test without dragging half the system with it.

The solution to both is fake objects — objects that impersonate real collaborators during a test. The book's example: a Sale class that writes to a cash-register display. You extract a Display interface, implement it with ArtR56Display for production and FakeDisplay for tests. Sale does not change at all. The FakeDisplay records what was shown so a test can assert on it.

This is exactly what Mockery in PHPUnit or jest.mock() in JavaScript do — Feathers explains the mechanism behind the tool.

Sensing and Separation: Sale, Display interface, ArtR56Display and FakeDisplay Sale uses «interface» Display ArtR56Display production FakeDisplay records output — tests FakeDisplay doesn't display: it records what was shown. The test reads that record and asserts. Sale doesn't change at all.
Sale receives an interface, not an implementation. In tests, pass FakeDisplay to the constructor: it records what would have been shown, without touching a real display.

5Sprout and Wrap: adding tested code without touching untested code

The classic situation: you need to add a feature inside a 50-line method with no tests. Editing those 50 lines risks breaking something. Touching nothing means never improving. Feathers offers two escape hatches.

Sprout Method: write the new logic in a separate, testable method, then call it from the old method with a single added line. The old code gains one line — nothing else changes.

function pay($amount) {
    // ...50 lines of legacy code, untouched...
    $this->sendConfirmation($amount); // ← the only added line
}
private function sendConfirmation($amount) { // ← new, testable on its own
    // your new logic here
}

Wrap Method: rename the original method (pay() becomes legacyPay()), then create a new pay() that calls your new logic and delegates to legacyPay(). Every caller still calls pay() — nothing breaks — and the new code is testable on its own.

function pay($amount) {          // ← same public name, nothing breaks
    $this->logPayment($amount);  // new logic, testable on its own
    $this->legacyPay($amount);   // old code, just renamed
}
private function legacyPay($amount) { /* 50 lines, untouched */ }

These techniques do not clean up the existing code. They introduce seams into code that had none, so the next change can rely on tests.

6Characterization tests: documenting what the code actually does

The most counterintuitive chapter. In legacy code you usually do not know what the code is supposed to do — only what it does. Feathers's answer: write tests that capture the actual behavior, not the intended behavior.

A characterization test is written in four moves. Write an assertion you know will fail: say assertEquals("fred", generator.generate()). Run the test and read the error message: expected: <fred> but was: <>. Update the expected value to match what the code actually returned. The test now passes.

"They don't have any moral authority; they just sit there documenting what pieces of the system really do." (ch. 13). That framing is liberating.

You are not looking for bugs. You are installing a trip wire: if someone — including future you — changes the behavior accidentally, the test will catch it. Characterization tests are the safety net you stretch before you start refactoring for real.

Characterization test cycle Write an assertion you know will fail Run the test — read the error message « expected: <fred> but was: <> » Update expected value to match real output Test passes ✓ — trip wire installed No moral authority: it photographs what the code actually does.
You are not looking for bugs — you are installing a trip wire. If someone changes this behavior later, the test goes red. That is enough.

7Scratch Refactoring: understanding code by throwing it away

Check out the code. Refactor aggressively: extract methods, rename variables, move things around, draw it out. Understand the real structure. Then throw the code away. Do not commit. The version-control system holds the original; you can always go back. The refactoring was never the point — understanding was.

Feathers calls this Scratch Refactoring, and the first time he described it to a colleague the colleague thought it was wasteful. After a thirty-minute session of moving things around, the colleague was sold.

The technique pairs with Listing Markup: print the code, use colored markers to group related lines, trace block boundaries inside out, circle sections you might want to extract.

It looks like you are not working. You are working harder than anyone at the keyboard banging out a feature without understanding the ground beneath their feet.

A developer joyfully tosses a stack of code printouts into an overflowing trash can; the glowing terminal behind shows the original untouched; colored diagrams and markers on the desk
The refactoring was never the point — understanding was. The code on screen is untouched; version control holds it safe. Scratch it, learn from it, throw it away.

8Hyperaware Editing: one thing at a time

Every keystroke either changes the behavior of the software or it does not. Formatting a comment: no behavior change. Changing a string literal in live code: behavior change. Feathers calls the awareness of this distinction "hyperaware editing" — a flow state in which you know, at every moment, whether you are refactoring or adding behavior.

The condition for getting there is fast tests: when you can run your suite in under a second, each small change gets immediate feedback.

"Programming is the art of doing one thing at a time." (ch. 23). Single-Goal Editing is the practice: when a tangential idea surfaces (that other method could be cleaned up), write it on paper. Go back to what you were doing. Finish it. Run the tests. Only then open the next item.

Teams that skip this discipline do "thrashing" — five half-finished changes, all tangled, a broken build, and two hours of debugging to untangle. The note on paper is not procrastination; it is precision.

My honest opinion

The definition alone is worth the price: if you walked away remembering only "legacy code is code without tests", the book did its job. The seam model is the next most valuable concept — it teaches you to read code differently, looking for joints instead of logic. And characterization tests solve a problem no one talks about enough: how to start testing code when you have no idea what "correct" means for that code.

The honest downsides: the book was written in 2004 and every example is Java, C++, or C. In practice, PHPUnit's mock system or Jest's spy functions make a lot of the manual wiring unnecessary.

Part II (twenty chapters in FAQ format) gets repetitive — the technique is usually the same four moves dressed in a different class name. You will not read it cover to cover; you will read chapters when you hit the specific problem they describe.

One thing I find genuinely reassuring: Feathers is explicit that these techniques often produce uglier code in the short term. Breaking dependencies to get tests in place sometimes means adding a constructor parameter that serves no purpose in production, or making a private method public.

He acknowledges the scar and says it is temporary — once the tests are there, the real refactoring begins. That honesty makes the book trustworthy.

Odilon

Still relevant in 2026?

More than ever. AI tools can suggest tests, but they cannot run your class in a test harness for you — they do not know whether the constructor calls a live payment API, or whether the global config was set up three layers above. The seam model is the answer to that problem regardless of what year it is. And the characterization-test idea has become quietly mainstream: snapshot testing in Jest is the same concept, finally made frictionless by tooling.

The specific language syntax in the examples dates. The mental model does not.

For whom?

Read it if

  • You are maintaining a codebase with little or no test coverage and every change feels like defusing a bomb
  • You have tried to add tests to an existing class and hit a wall of dependencies
  • You lead a team working in legacy code and want a shared vocabulary for the work
  • You have read Clean Code but still feel stuck on the "how do I start" question

Skip it if

  • You are on a greenfield project with tests from day one — you will not need most of this
  • You already know Sprout/Wrap and characterization tests: the rest is reference material, not a read
  • The Java/C++ examples put you off: the concepts are sound, but reading around the syntax takes effort

Go further

The testing techniques in this book are practiced in my testing course. On the library side, Refactoring (Fowler) is the natural next step once you have tests in place — Feathers gets you to the starting line, Fowler shows you the race. Clean Code (Martin) covers the design principles that guide what "better" looks like after the tests exist.

Comments (0)

Browse the whole library

More book notes coming: one book at a time, the marrow only.