
An invoice export includes canceled charges. Fixing it appears to require one condition, but the method queries a database, reads today's date and sends a file to a provider. Observing the result requires starting the entire application. The risk lies along the path to that condition and in behavior nobody can fully describe.
Working Effectively with Legacy Code addresses this kind of maintenance. I recommend it when you must change existing software and cannot yet gather enough evidence about the change. Its value appears before elegant refactoring: finding a small place to observe what actually happens.
This review concerns Michael Feathers' first edition. Pearson's listing records publication on September 22, 2004, and a 2005 copyright. Examples use Java, C++ and C#. Publication and copyright explain the two years commonly found in bibliographic references.
The organization supports looking up a particular problem. Its contents include classes that resist testing, library dependencies and large methods. Start with the difficulty blocking your next task, then return to the concepts behind the technique. That makes the book useful when a delivery deadline leaves little time for reading it straight through.
Feathers uses code without tests as an operational definition of legacy code. Age stops being the decisive criterion. A recently created service can demand as much personal trust as an old program when nobody can automatically check its behavior.
That definition helps direct an intervention, but it makes a narrow diagnosis of technical debt overall. An application with tests may still rely on an unsupported runtime, contain vulnerabilities or depend on one person's knowledge. Use the book to improve your ability to change code without reducing every maintenance problem to missing tests.
For the invoice routine, the opening question becomes concrete: what result must we observe to change charge selection? Examining the generated file provides an initial boundary. Testing every class involved would be a larger goal, with a different cost and scope.
In Characterization Testing, Feathers separates investigating existing behavior from checking desired behavior. Understanding that distinction prevents a useful technique from becoming a collection of permanently frozen bugs.
In our original, hypothetical example, the first observation reveals a canceled charge in the CSV. Recording that result detects when it changes. We still need the process owner to decide whether it represents a defect, necessary compatibility or an incomplete rule. The test cannot make that decision.
Once the defect is confirmed, write a new expectation that excludes the canceled charge. The old implementation should fail it. After the fix, update the test documenting the old behavior and explain the decision. Keep valid-charge cases protected. Combining discovery, judgment and correction in one unexplained expectation makes review harder.
A seam allows behavior to be replaced without editing the point where it is used. Its enabling point selects the implementation. Feathers explains this distinction in Seam Types. In our example, the routine's assembly can select an in-memory query and a destination that only captures the file.
Finding that option requires following actual dependencies. A constructor might open a connection before the rule runs. An import might load configuration. A seemingly pure function might read global state. Replacing delivery alone achieves little if database access happens first.
I find this vocabulary more useful than generic advice to add mocks. It forces the questions of where substitution occurs and how a test selects it. Designing an interface without investigating those questions can increase the file count while leaving the flow impossible to execute independently.
Free and paid products to turn ideas into a foundation you can actually ship.
13 products availableContinue exploring similar topics

Learn from Kent Beck how to turn requirements into small feedback steps without equating passing tests with the right product.

A review of Refactoring's second edition: small steps, tests and contracts, with a runnable example, criticism and a practical reading plan.

Apply Michael Nygard's approach to timeouts, isolation and recovery through a controlled failure exercise.
A 47-point checklist to find bugs, security risks, and performance issues before launch.
Production-tested templates trusted by developers. Save weeks of setup on your next project.
Consider this design exercise, created for the review. A routine receives a customer ID, retrieves charges, calculates totals, formats CSV and sends it. We want to exclude canceled charges while preserving the order of other rows and the existing rounding rule. This case contains no production data or measured production results.
First map the flow and its effects. The query returns an identifier, status, amount and due date. The destination receives generated content. A reference date determines the reporting period. These are candidate controlled inputs; the transformation between them is the code we want to execute.
If the routine already accepts a query object, use that seam. If it constructs a client internally, consider adding an optional parameter whose default retains the existing client. Preserve the production path during this edit. Check positional callers, initialization and error handling before deciding that adding a parameter preserves behavior.
The query substitute returns a small, explicit data set. A test destination captures the CSV and records the number of delivery attempts. Fix the reference date. You can now exercise the transformation without a database, network access or credentials. Keep the actual selection and formatting implementation: substituting those too would remove the evidence you were seeking.
Use the following cases. Their outcomes are expectations for this exercise; a real project still needs domain validation.
| Input | Observation that matters |
|---|---|
| One valid charge | Amount and identifier remain in the file |
| A valid charge followed by a canceled one | The corrected case contains only the valid charge |
| Two valid charges with fractional amounts | Order and rounding rules remain unchanged |
| No eligible charges | Empty-file and delivery behavior become explicit |
| Query failure | The destination receives no partial file |
Make specific comparisons for important fields. When the contract requires exact bytes, also check separators, encoding and line endings. Avoid normalizing the CSV until the test ignores a difference that matters to its consumer. Removing a volatile timestamp from a comparison may require a separate check of its format.
For a concrete comparison, fix the date at 2026-10-01 and use three charges due on 2026-09-30, in this order: F01, active, 1,099 cents; F02, canceled, 250 cents; F03, active, 901 cents. Suppose the controlled run before the fix produces the CSV below, using ; and calling the destination once. This is a hypothetical baseline for the exercise; in a project, obtain it from the existing implementation.
invoice_id;amount_cents
F01;1099
F02;250
F03;901
After the fix, the expected output retains the header and rows F01;1099 and F03;901, in that order, with one destination call. The old baseline includes F02; the new expectation excludes it through an explicit business decision. Integer amounts avoid adding a rounding convention to this example. If the system calculates fractional amounts, add a separate case recording its actual rounding rule.
Then change the business rule. The canceled-charge case fails before the fix and passes afterward; other cases should retain their expectations. A reviewer can distinguish preparation for testing from the functional change. This separation helps locate regressions and revert the fix without discarding all the newly created visibility.
There is a circular difficulty here: changing code is necessary before testing it. The book is particularly useful for addressing this imperfect starting point. A practical response requires restraint. A small mechanical edit with careful inspection offers a smaller review surface than reorganizing the whole class before writing the first test.
In the exercise, exposing client construction while preserving its default differs from replacing the entire data layer with repository abstractions. The latter might become worthwhile later. Right now, it adds decisions about interfaces, failures and transactions to a diff whose purpose is discovering existing behavior.
Do not describe preparation as proven safe when tests do not exist yet. Record what was inspected, run available checks and request review from someone familiar with the affected path. If a controlled full-flow execution is available, compare its output before and after. That evidence still covers only the exercised cases.
An in-memory query test checks what the routine does with received records. It does not establish that SQL retrieves the right records, respects isolation or converts values correctly. Keep an adapter integration test with controlled data and a database compatible with the real environment.
A captured destination does not establish that the provider accepts the file. Format, authentication and error responses need their own checks. Retrying delivery after a timeout may duplicate effects even when every local test passes. That risk requires an idempotency contract or reconciliation, depending on the system.
Line coverage cannot measure this gap. I prefer a test that detects an incorrectly included charge to dozens of expectations about internal calls. Ask which incorrect edit would make the suite fail. If changing a charge's status never affects the result, investigate the assertion's usefulness before increasing coverage.
Examples and manual techniques reflect the edition's languages and tools. A TypeScript or Python developer must translate the intent of approaches based on compilation, linking or inheritance. Copying their form literally may create something more complicated than an explicit substitution already available in the language.
Temporary seams can also become permanent clutter. A parameter introduced for a test may accidentally become part of an API; a test subclass may depend excessively on internal structure. After establishing protection, consider consolidating the separation and removing mechanisms that have stopped serving a purpose. Testability must coexist with understandable production code.
The book offers techniques rather than an automatic risk estimate. A handful of inputs leaves plenty unknown. Readers seeking a complete approach to data migration, concurrency or replacing a distributed system will need other material. Passing some tests creates no obligation to preserve an unworkable design forever.
I recommend it to developers who know basic testing and maintain code that is difficult to instantiate or execute. Less experienced developers can benefit with review from someone who knows the system. Without a grounding in dependencies and external effects, first practice testing functions and small objects.
Within this series, Refactoring helps select structural moves once protection exists. TDD by Example develops new behavior through expectations. Clean Code supports discussions of readability. Feathers tackles an earlier question: how can this code run without dragging its surroundings into the test?
Postpone this book if you want a first introduction to programming. If your immediate challenge is a new rule in a well-tested module, TDD or Refactoring may offer a faster payoff. The reading investment makes sense when reaching the code with enough control to change it is the obstacle.
Choose a small correction from your backlog. Read about seams and locate the chapter matching your obstacle. Write down which behavior should change, which must remain and who can validate the distinction. Separate structural preparation from the functional fix during review.
The exercise is complete when someone else can run the cases without external services, explain each expectation and identify contracts still requiring integration checks. After delivery, try a second nearby change. It should reuse some protection; rebuilding everything suggests the entry point is too narrow or tightly coupled.
I strongly recommend this book for maintenance under uncertainty. Its practical reward is learning to produce evidence where previously there was only inspection and apprehension. Apply techniques to the code that needs changing, acknowledge the tests' reach and use the resulting confidence to improve the design in reviewable steps.