Refactoring Legacy Code Safely
Michael Feathers gave the field its most useful definition: legacy code is code without tests. Not code that is old, not code written in an unfashionable language, not code by a developer who has left — those are incidental. The defining property is that you cannot change it with confidence, because nothing tells you when you have broken it. A pristine, idiomatic, beautifully formatted module with no test coverage is legacy code; a crusty twenty-year-old C function wrapped in a thousand assertions is not. This reframing matters because it tells you exactly what to do first: the path out of fear is not to rewrite, and not to “clean up,” but to establish a safety net so that every subsequent change is verifiable.
The instinct that destroys teams is the big-bang rewrite — the decision that the old system is hopeless and a fresh one, built clean, will replace it. It almost never works, because the old system encodes a decade of bug fixes, edge cases, and business rules that nobody wrote down and that live only in the code itself. The rewrite reproduces the obvious behavior, ships, and then spends a year rediscovering the non-obvious behavior one production incident at a time, all while the old system kept running and the team maintained two systems instead of one. Safe refactoring is the disciplined alternative: change the code you have, in small reversible steps, behind a net of tests, until it is no longer something you fear.
The golden rule: tests before changes
Refactoring has a precise technical meaning that everyday usage has blurred. To refactor is to change the structure of code without changing its behavior — same inputs, same outputs, same side effects, different internals. The instant you change behavior, you are not refactoring; you are modifying, and the two must never happen in the same step, because if you do both at once and something breaks you cannot tell which change caused it. Refactoring and behavior change are separate commits, separate mental modes, ideally separate sittings.
This is why tests come first and why their absence is the whole problem. A test that captures current behavior is the oracle that lets you change structure freely: as long as it stays green, you have not changed behavior, by definition. Without it, “I just cleaned it up, it should be equivalent” is a prayer, not an engineering claim.
|
|
When there are no tests: characterization tests
Here is the catch-22 that paralyzes people: you need tests to refactor safely, but the code is too tangled to test, and to make it testable you need to refactor it. The way out is the characterization test — a test that documents what the code currently does, including its bugs, without any claim that the behavior is correct. You are not specifying desired behavior; you are taking a snapshot, a fixed point you can refactor against.
The technique is almost mechanical. Call the function with some input, assert something obviously wrong, run it, and let the failure tell you the real output. Then paste the real value into the assertion. You have just pinned reality.
|
|
This feels strange the first time — you are asserting behavior you do not understand and might even know is buggy. That is correct and intentional. The characterization test’s job is to scream the moment your refactoring changes anything, so you can ask “did I mean to change that?” If the pinned behavior is actually a bug, you fix it later, deliberately, as a separate behavior-changing commit with its own test — not silently in the middle of restructuring. Aim characterization tests at the seams where you are about to cut, and at the public behavior the rest of the system depends on, rather than trying to pin every internal detail. Pairing this with a real testing strategy — knowing which tests to write at which level — is what turns a one-off snapshot into durable coverage.
Seams: breaking dependencies so code can be tested
The reason legacy code resists testing is almost always dependencies: the function you want to test reaches out and touches the network, the clock, the filesystem, a global singleton, a database. You cannot exercise it in isolation because instantiating it drags in half the system. A seam is a place where you can change behavior without editing the code at that spot — a point where you can substitute a test double for the real collaborator. Finding and creating seams is the core mechanical skill of working with legacy code.
The most common seam is a parameter. Code that calls datetime.now() or requests.get() directly has hidden, untestable dependencies welded in. Introduce a seam by passing the dependency in — at first preserving the old default so nothing else changes:
|
|
Other seams: wrap a free function call behind a method you can override in a subclass; extract an interface in front of a database client and pass a fake; use the language’s seam of choice (dependency injection, a link-time substitution in C, monkeypatching in dynamic languages) to swap the real collaborator for a controllable one. The point of every seam is the same: shrink the unit under test until you can pin its behavior, then refactor inside that boundary with confidence. Designs that lean on injectable collaborators — the same instinct behind twelve-factor configuration of backing services — are precisely the designs that stay testable as they age.
Small steps: the cadence that makes it safe
Large refactors fail and small ones succeed, and the reason is not discipline for its own sake — it is the size of the debugging problem when something goes wrong. If you change three lines and the tests go red, the cause is in those three lines. If you change three thousand lines across fifty files and the tests go red, you have a search problem on top of a refactoring problem, and you will be tempted to revert the entire day’s work. Small steps keep the “what just broke?” question trivially answerable, which is the same logic that makes git bisect powerful for debugging: a clean sequence of small changes is a sequence of cheap rollback points.
|
|
The named patterns are the vocabulary of these steps, each one small enough to do and verify in minutes:
| Pattern | What it does | Why it is safe |
|---|---|---|
| Extract Method | Pull a coherent block into a named function | Pure structure move; tests pin the unchanged behavior |
| Rename | Give a variable/function a name that tells the truth | The compiler/IDE finds every reference; behavior identical |
| Replace Magic Number | if age >= 21 → if age >= LEGAL_DRINKING_AGE |
Same value, now self-documenting and grep-able |
| Introduce Parameter | Turn a hidden dependency into an argument | Creates a seam; default preserves old behavior |
| Inline | Collapse a needless indirection | Reduces layers when an abstraction earns nothing |
Run the tests after every one of these. The whole bet of small-step refactoring is that a green test suite a few seconds away is cheaper than an hour of bisecting your own afternoon. Commit each green step; a tidy local history of micro-refactors is also a gift to whoever reviews the change, which is its own argument for keeping the diff reviewable.
The strangler fig: replacing systems without a rewrite
Sometimes a subsystem genuinely needs replacing, not just tidying — a payment module built on an abandoned framework, a monolith that has to become services. The strangler-fig pattern (named by Martin Fowler after the vine that grows around a tree, gradually replacing it, until the original rots away and the vine stands on its own) is how you do that without the big-bang risk. You build the new implementation alongside the old, route a trickle of traffic to it, verify, widen the trickle, and only retire the old path once the new one carries everything.
The mechanism is a routing point — a facade, a proxy, or a feature flag — that can send each request to either implementation. Crucially, the decision is data, not a deploy, so you can dial traffic up and back down instantly when something looks wrong:
|
|
ALL TRAFFIC ROUTING POINT (flag / proxy)
| |
[ legacy system ] --- phase 1 ---> 1% -> [ new ] 99% -> [ legacy ]
| --- phase 2 ---> 25% -> [ new ] 75% -> [ legacy ]
| --- phase 3 ---> 100% -> [ new ] 0% -> [ legacy ]
| |
+------------------ phase 4: delete legacy, remove flag
The advanced version runs both paths and compares — sending the request to the new service for real while also running it through the old one and logging any divergence (GitHub’s scientist library popularized this “verify in production by shadowing” approach). That turns “we think the new code is equivalent” into “we have ten million requests of evidence that it produces identical output.” When you are carving a subsystem out of a monolith this way, the seam you route across often becomes an event-driven boundary — the old and new sides communicating through events rather than shared internals, which is what lets you delete the old tree without the new one collapsing.
When to refactor, and when to leave it alone
Refactoring is not a virtue you practice for its own sake; it is an investment that pays off only when you are about to spend time in the code anyway. The disciplined trigger is the “campsite” rule scoped to the work in front of you: when you are about to add a feature, refactor first to make room for it cleanly (a separate green commit), then add the feature; when you have just fixed a bug, refactor to make that class of bug structurally harder; when you finally understand a gnarly module, capture that understanding in better names and smaller functions while it is fresh.
The times to not refactor are just as important, because misjudged refactoring is how you turn a working system into a broken one under deadline:
- Under deadline pressure. Refactoring trades present time for future ease. If you have no present time, you cannot make the trade; ship, note the debt, come back.
- When you do not understand what the code does. Restructuring code you do not comprehend is how you silently delete a load-bearing edge case. Write characterization tests until you understand it, then refactor.
- Purely because it is ugly. Ugliness is not a cost; risk and friction are. Code that is ugly but stable and rarely touched can stay ugly. Spend your refactoring budget where the code is both messy and frequently changed, because that intersection is where the friction actually accrues.
There is also a real cost to refactoring that advocates underplay: it generates large diffs that are hard to review, it can introduce subtle behavior changes precisely because “equivalent” is harder than it looks, and it churns code that other branches are also editing, creating merge conflicts. These costs are exactly why the safety net and the small steps are non-negotiable — they are what keep the expected value positive.
Measuring progress without fooling yourself
Refactoring is a marathon, and marathons need pace markers or they become aimless. The trap is optimizing a number instead of the thing the number was supposed to proxy. Cyclomatic complexity, test coverage, and file size are useful trends and terrible targets: chase 100% coverage and you get tests that assert nothing; chase low complexity and you get logic smeared across a dozen tiny indirections that is harder to follow than the branchy original.
The honest signals are about the team’s experience of the code over time, not a single snapshot:
| Signal | What improvement looks like | Why it is trustworthy |
|---|---|---|
| Test coverage on changed code | Rising in the modules you actually touch | Coverage where work happens, not vanity total |
| Time-to-fix for bugs in a module | Trending down | The safety net and clearer structure are paying off |
| Feature lead time in a module | Trending down | Less archaeology per change |
| Change-failure rate | Fewer changes cause incidents | Refactoring made the code safer to modify |
| “Can a newcomer change this?” | Yes, without a guide | The real test of readability |
Track these as direction, not as a dashboard to game. The deepest measure is unquantifiable and the one that matters: has the code stopped being something the team is afraid of? When a module moves from “only Priya can touch that, and she sighs first” to “anyone can change it and the tests will catch mistakes,” you have converted legacy code into ordinary code. That conversion — fear into confidence — is the entire point, and every characterization test, seam, and small green commit is in service of it.
Verdict
Safe refactoring is a method for changing code you are afraid of, and the fear is the real target. You start by redefining the problem: legacy code is code without tests, so the first move is never to rewrite and never to “clean up” — it is to pin current behavior with characterization tests that snapshot reality, bugs and all. With that net in place you find seams to break the dependencies that made the code untestable, then change structure in small reversible green-to-green steps, never mixing a refactor with a behavior change in the same commit. When a whole subsystem must go, the strangler fig replaces it incrementally behind a routing flag, with traffic you can dial back the instant something looks wrong, rather than betting the company on a big-bang rewrite that will spend a year rediscovering forgotten edge cases.
The unifying idea: every technique here exists to make change reversible and verifiable. Characterization tests make “did I change behavior?” answerable. Seams make “can I test this in isolation?” answerable. Small steps make “what just broke?” trivial. The strangler pattern makes “can we undo this?” a flag flip instead of a rollback deploy. Refactoring is not about making code beautiful; it is about steadily converting code the team fears into code the team can change with confidence, one small, tested, reversible step at a time.
Sources
- Michael Feathers, Working Effectively with Legacy Code, Prentice Hall — https://www.oreilly.com/library/view/working-effectively-with/0131177052/
- Martin Fowler, Refactoring: Improving the Design of Existing Code, 2nd ed. — https://martinfowler.com/books/refactoring.html
- Martin Fowler, “StranglerFigApplication” — https://martinfowler.com/bliki/StranglerFigApplication.html
- Martin Fowler, “CharacterizationTest” — https://martinfowler.com/bliki/CharacterizationTest.html
- GitHub Engineering, “Scientist: measure twice, cut over once” — https://github.blog/2016-02-03-scientist/
- Refactoring Guru, “Refactoring Techniques” catalog — https://refactoring.guru/refactoring/techniques
Comments