On this page
Legacy code refactoring is how you change an inherited codebase without breaking what already works. Legacy code is code you must change but cannot change with confidence: there are few tests, the people who wrote it have gone and nobody is sure what depends on what.
The safe method is the same in most stacks. Find where the code changes most, pin its current behaviour with tests, cut seams where you can swap pieces, then change it in small steps you can undo. If a developer or agency has left and every fix now breaks something else, our software project rescue service stabilizes the codebase first.
What legacy code refactoring means
Legacy code refactoring means improving the structure of old code while its behaviour stays exactly the same. In his book Refactoring (2nd edition, Addison-Wesley, 2018), Martin Fowler describes refactoring as a disciplined series of small changes that preserve behaviour and reshape the code's internal structure.
That last part is the hard part with legacy code. Michael Feathers, in Working Effectively with Legacy Code (Pearson, 2004), sorts every change into four kinds: adding a feature, fixing a bug, improving the design and optimizing resource usage. In his chapter on changing software, improving the design and optimizing are meant to leave functionality as it is, while features and bug fixes change it on purpose. Each kind risks changing more than you meant to.
So the work rests on three questions Feathers asks before any risky change. What changes do we have to make? How will we know we made them correctly? How will we know we have not broken anything? In a codebase with no tests, nobody can answer the last one, and in practice that is what makes code legacy.
Refactor, rewrite or leave it alone
Refactor in place when you change the code often and its behaviour is mostly right. Rewrite only when the system is small or nothing in it can be reused. Code that works, rarely changes and blocks no plan can be left alone until a dependency forces a decision.
A full rewrite looks cleaner on paper. Fowler's article on the strangler fig pattern notes that existing behaviour is often hard to specify, and that much of it is not even wanted, so rebuilding it is a waste. AWS adds that while a large system is being rewritten in one go, adding new features becomes extremely hard or even impossible.
| Option | Choose it when | Main risk |
|---|---|---|
| Leave it alone | It works, rarely changes and blocks no plan | A dependency reaches end of life and forces a rushed change |
| Refactor in place | You change it often and its behaviour is mostly right | Changes without tests let regressions through |
| Strangler fig | A large part must be replaced while the system keeps running | The routing layer becomes a bottleneck or the migration stalls halfway |
| Full rewrite | The system is small, or almost nothing can be reused | You rebuild unwanted behaviour while new features wait |
Many inherited codebases land in the second row, because the product works and the team must keep changing it. If yours shows the warning signs of debt, such as slower releases and more rework, our guide on how to reduce technical debt covers how to measure it before you choose.
Map the legacy codebase before you change it
Legacy code analysis starts with history, not with reading every file. The files that change most often are where bugs and delays come from, so refactoring them pays back first. Code that nobody touches can wait, however ugly it looks.
Git can list those hotspots in one line. The options used here, --since and --name-only, are documented in the official git log reference:
git log --since="12 months ago" --format= --name-only | sort | uniq -c | sort -rn | head -20
The output is a count of commits per file over the last year, highest first. Cross that list with three more reads of the legacy codebase:
- Entry points: the routes, jobs and scheduled tasks that real users or systems trigger.
- Dependencies: what each hotspot imports, calls or writes to, including the database tables it shares.
- Test coverage: which hotspots have any automated tests at all, and whether those tests run in CI (continuous integration).
A hotspot with no tests and many dependencies is your first refactoring target. It is also the riskiest, so it gets the most safety work before any change.
Pin current behaviour with characterization tests
A characterization test records what the code does today, right or wrong, so you notice the moment that changes. You do not write it from a specification. You run the code, observe the output and assert exactly that output.
This feels odd the first time, because the test may lock in a bug. That is the point. Users and other systems may depend on that bug, so you fix it later as a deliberate, visible change, not as a side effect of a cleanup.
A minimal example, invented for this guide, in Python:
# We do not know the rule, so we pin what the code returns today.
def test_invoice_total_characterization():
assert invoice_total(items=[100, 250], region="EU") == 423.5
assert invoice_total(items=[], region="EU") == 0
assert invoice_total(items=[100], region="unknown") == 100
Write these tests around the hotspot you plan to change, not the whole system. Feathers gives this situation a chapter of its own, titled "I Need To Make a Change but I Don't Know What Tests To Write", in the book's table of contents.
Find seams and break dependencies
A seam is a place where you can swap behaviour without editing the code at that spot. Feathers defines it that way in his chapter on the seam model, and he adds that every seam has an enabling point where you choose which behaviour runs.
Seams matter because legacy code is hard to test for one main reason: it calls the database, the network, the clock or a payment service directly. Each of those calls makes a test slow, flaky or impossible. A seam lets the test supply a stand-in instead.
The common seams in modern code are a function or class passed in as a parameter, an interface with two implementations, and a configuration value read at start-up. Breaking a dependency usually means moving a hard-wired call behind one of these. Each move is small, and your characterization tests tell you whether it changed anything.
Techniques for refactoring legacy code safely
Pick the technique by the kind of change you need, not by taste. Each kind of change has a technique that keeps the old code mostly untouched and a safety net that tells you when something moved. The code goes from untested, to pinned by tests, to safe to change.
| Kind of change | Technique | Safety net |
|---|---|---|
| Add a feature | Sprout new code, test it alone | Unit tests on the new code; one call from the old code |
| Fix a bug | Pin behaviour, then change | Characterization tests, then one test that shows the fix |
| Improve the design | Small steps, one at a time | Tests run after every step; each step is its own commit |
| Optimize resources | Measure before and after | Characterization tests plus a repeatable benchmark |
| Swap a dependency | Branch by abstraction | The same tests run against the old and new implementation |
| Replace a module | Strangler fig facade | Traffic moves in steps and can be routed back |
To sprout new code means writing the new behaviour as a separate, tested function and calling it from one line in the old code. Wrapping is the mirror image: you rename the old function and put a new one around it that adds the behaviour before or after. Branch by abstraction puts an interface in front of the old dependency, builds the new one behind the same interface, then switches over and deletes the old one.
Whatever the technique, keep each change small enough to review in one sitting and to revert with one command. Run the full test suite in CI on every commit, and ship behind a feature flag when users will see the change.
Legacy code migration with the strangler fig pattern
The strangler fig pattern replaces a legacy system piece by piece while it keeps serving users. Microsoft's Strangler Fig pattern guidance describes a facade that routes each request to the old system or the new one, until the old system has nothing left to do and can be retired.
Martin Fowler named the pattern after strangler figs he saw in Queensland in 2001, vines that grow around a host tree until they stand on their own. For legacy code migration, it means you move one route, job or feature at a time, check it in production, then move the next.
The pattern has limits, and both cloud vendors spell them out. Microsoft says it may not suit you when requests to the old system cannot be intercepted, when the system is small and simple to replace, or when the original must be retired quickly. AWS Prescriptive Guidance warns that the routing layer can become a single point of failure or a performance bottleneck, and that a small application may be cheaper to rewrite.
During the migration both systems run side by side and call each other. Microsoft recommends an anti-corruption layer, an adapter that translates between the two, so the old system's data model does not leak into the new design.
When the legacy code was written by an AI tool
Code generated by AI coding tools becomes legacy code quickly, because nobody on the team wrote it line by line or knows why it works. The method above still applies, and the analysis step matters even more.
Generated code often repeats the same logic in several places with small differences. Hotspot analysis and characterization tests show which copy the users actually reach. An AI assistant can draft characterization tests fast, but a person should check every assertion against the observed output, because a test that asserts the wrong value pins nothing.
If you want a first read before you plan the work, the vibe coding risk check gives you a quick read on an AI-built app.
How we take over inherited codebases
When we take over a codebase, we follow this order: map the hotspots, pin behaviour on the code that must change first, then refactor in small, reversible steps behind CI. Urgent requests are triaged the same day. Teams that want only the assessment start with a code audit, and apps built with AI tools can start with an ai code audit.
Every engagement is under an NDA, and the IP is always yours. Each project is different, so we quote it after a short call.
Frequently asked questions
What is legacy code refactoring?
Legacy code refactoring is changing the internal structure of old code without changing what it does for users. Teams do it to make inherited code easier and safer to change. It usually starts by adding tests that pin the current behaviour, because most legacy code has too few tests to show when a change breaks something.
What is legacy code?
Legacy code is code your team must keep changing but cannot change with confidence. The usual reasons are missing tests, missing documentation and missing people: the developers or agency who wrote it have moved on. Age alone does not make code legacy, and new code generated without tests can become legacy quickly.
Is refactoring the same as rewriting?
No. Refactoring keeps the existing code and improves its structure in small steps, while behaviour stays the same. A rewrite throws the code away and rebuilds the system. Refactoring keeps the product shipping during the work, while a large rewrite tends to pause new features and can rebuild behaviour nobody needs.
How do you refactor legacy code without tests?
Write characterization tests first. Run the code, record what it returns today, and assert that output, even where it looks wrong. If the code is hard to test, find a seam, such as a parameter or an interface, to swap out the database or network calls. Then change the code in small steps.
When should you use the strangler fig pattern?
Use it when a large part of a system must be replaced while it keeps serving users, and you can route requests through a facade. Microsoft and AWS both advise against it for small systems that are simple to rewrite, or when requests to the old system cannot be intercepted.
Can AI tools refactor legacy code?
AI coding assistants can draft tests and propose small refactorings quickly, which helps on large inherited codebases. They do not know which behaviour your users rely on. Keep a person reviewing every change, run the characterization tests after each step, and never accept a large generated rewrite without that safety net.
Sources and update history
- Michael Feathers, Changing Software and Legacy Code (InformIT excerpt from Working Effectively with Legacy Code, Pearson, 2004)
- Michael Feathers, Testing Effectively With Legacy Code: Seams (InformIT excerpt from Working Effectively with Legacy Code)
- Working Effectively with Legacy Code, Michael Feathers (Pearson, published 22 September 2004): book page and contents
- Microsoft Azure Architecture Center, Strangler Fig pattern
- AWS Prescriptive Guidance, Strangler fig pattern
- Git documentation, git-log
Update history
- First published.
