Refactoring legacy code is dangerous precisely because the risk is invisible until it is not — the code has no tests, the original author left years ago, and some of its behavior is a bug that three other systems now depend on. The safe process is not clever; it is disciplined: characterize the current behavior before touching it, change it in small steps you can verify independently, and ship behind a flag so you can cut over gradually instead of all at once. Skip any one of those three and a refactor turns into an incident.
How it works
Michael Feathers' definition of legacy code — code without tests — is the right starting point, because it reframes the problem correctly. The danger is not the code's age or style; it is that you have no fast, automatic way to know if a change broke something. Fix that first.
- Write characterization tests before changing anything. Capture what the code currently does, including its bugs, by asserting on real inputs and outputs. You are not testing that the behavior is correct — you are testing that it does not change out from under you.
- Identify a seam. Feathers' term for a place you can alter behavior without editing the code directly, such as a function boundary, an interface, or a dependency you can inject. Seams are where you attach tests without a full rewrite.
- Make the smallest change that is independently verifiable. Extract a function, rename something, remove dead code — one mechanical step at a time, running tests after each one.
- Ship behind a feature flag when the change affects behavior. Run the new path dark, computing it but not using the result, or route a small percentage of traffic to it, and compare outputs against the old path before fully cutting over.
- Delete the old path only after the new one has run safely in production for long enough to be confident, not immediately after the first green test run.
Refactor vs rewrite
| Factor |
Refactor in place |
Full rewrite |
| Preserves edge-case handling |
Yes, incrementally |
No, must rediscover every edge case |
| Time to first shipped improvement |
Days to weeks |
Months, often longer than estimated |
| Risk profile |
Small, contained steps |
Large, all-or-nothing cutover |
| Team morale |
Steady, visible progress |
High upfront enthusiasm, risk of stalling |
| When it is the right call |
Most of the time |
Only when the architecture itself is wrong |
Default to refactor. Reach for a rewrite only when the underlying architecture, not just the code style, cannot support what you need, and even then, do it incrementally with the strangler pattern rather than a big-bang replacement.
Common mistakes
Refactoring without characterization tests. Without a fast way to detect a behavior change, every refactor is a guess. Write the tests first, even if they only describe existing, possibly wrong, behavior.
Mixing refactoring with new features in one PR. If something breaks, you cannot tell whether the refactor or the feature caused it. Ship them as separate, sequential changes.
Refactoring the whole module at once. Large refactors accumulate risk linearly with size and are hard to review meaningfully. Break the work into steps small enough to revert individually.
Deleting the old code path too early. Give the new path real production time, ideally through a full business cycle or peak-load period, before removing the fallback.
FAQ
What counts as legacy code?
Michael Feathers' widely used definition: code without tests. It has nothing to do with age or the language it is written in — a codebase written last month with no test coverage is legacy code by this definition.
When should I rewrite instead of refactor?
Only when the architecture itself cannot support current requirements, not when the code is merely ugly or uses outdated patterns. Even then, prefer an incremental replacement over a big-bang rewrite.
How do I add tests to code with no seams?
Look for the smallest possible extraction point, such as a function you can pull out and inject a fake dependency into, rather than trying to test the whole system at once. Feathers' "Working Effectively with Legacy Code" catalogs specific techniques for this.
How long should a refactored path run behind a flag before removing the old one?
Long enough to see production traffic patterns that matter for correctness, often a full week to cover weekly cycles, longer if there is monthly or seasonal traffic variation.
Where to go next
See technical debt explained in 2026 for the framing behind why this code accumulated in the first place, and the strangler pattern for legacy migration in 2026 for replacing an entire system incrementally rather than refactoring in place. For the database side of legacy changes, see how to write a database migration in 2026.