
Refactoring legacy code means improving its structure without changing what it does, in code that has no tests to prove you haven't. So the work starts with tests: pin today's behavior, break the dependencies that stop you testing, then make small changes and run the tests after each one.
This is the hands-on view. For the management view, how to find the time and what to pay down first, see how to reduce technical debt.
- Refactoring changes a program's structure without changing its behavior. Legacy code usually has no tests to prove the behavior stayed the same.
- So write tests first. Characterization tests pin what the code does today, including behavior that looks wrong.
- Seams let you test code without editing it. Sprout and wrap let you add behavior without touching code you can't test yet.
- Stop refactoring and replace a part when testing it costs more than it's worth, or the platform under it has no future.
What is refactoring code?
Refactoring is changing how code is organized without changing what it does. Martin Fowler's refactoring.com defines it as "a disciplined technique for restructuring an existing body of code, altering its internal structure without changing its external behavior," and adds: "Its heart is a series of small behavior preserving transformations."
Refactoring isn't rewriting, or fixing bugs on the way through. Each step is small enough to check, and the program works after every one.
Why legacy code is code without tests
That definition has a catch: to know behavior didn't change, you need something that tells you when it does, which in practice means tests. Michael Feathers, author of Working Effectively with Legacy Code, said in a 2024 interview that the definition he's known for is "that legacy code is code without tests." The book's first chapter, which the publisher offers as a sample, puts the method in one sentence: "The idea behind refactoring is that we can make software more maintainable without changing behavior if we write tests to make sure that existing behavior doesn't change and take small steps to verify that all along the process."
Untested code traps you in a loop: you can't refactor safely without tests, and often can't add tests without changing the code. The techniques below break that loop with the smallest, safest change possible.
How to refactor legacy code safely
The order matters more than any single technique.
- Pin down the change point. Decide exactly which code your change touches, and keep the work there. Refactoring the whole module while you're in it is how a one-day change becomes a month.
- Find where you can see the behavior. A return value, a row in a table, a file written, a message sent. That's where your tests will look.
- Break the dependencies that stop the code running in a test. Database calls, the system clock, network services and global state. Use a seam rather than rewriting the code around them.
- Write characterization tests. Run the code with realistic inputs and assert whatever it produces today.
- Change in small steps. One refactoring, one test run, one commit. When a test fails, undo rather than debug forward.
Feathers' chapter titles read like the problems you'll meet. The publisher's table of contents includes "I Need To Make a Change but I Don't Know What Tests To Write," "I Can't Get This Class into a Test Harness" and "Dependencies on Libraries Are Killing Me."
Refactoring legacy code: techniques and when each fits
| Technique | What it is | When it fits | Watch for |
|---|---|---|---|
| Characterization test | A test that records what the code does today, right or wrong | Before any change to code without tests | Pinning a bug as if it were intended. Label suspected bugs and ask the business owner |
| Seam | "A place where you can alter behavior in your program without editing in that place" (Feathers) | A database call, clock or remote service stops the code running under test | Seams added only for testing can make code harder to follow. Keep them few |
| Sprout method or sprout class | Write the new behavior as a new method or class, tested on its own, and call it from the old code | Adding a feature to code you can't get under test yet | The old code stays untested. Record it as debt and come back |
| Wrap method | Rename the old method, then add one with the original name that calls it plus the new behavior | New behavior that runs before or after the old, such as logging or an extra check | Only works for additions at the edges, not changes in the middle |
| Extract method | Move a block into its own well-named method; Fowler's catalog lists it as Extract Function, also called Extract Method | Long methods, and as a first step to naming what a block does | Even this can change behavior when the block shares variables. Run the tests |
Characterization tests are Feathers' term. His seam definition comes from chapter 4, published as a sample chapter.
Characterization tests in practice
Take inputs from real use: a typical case, edge cases from production data, and the inputs behind past incidents. Capture the output, whatever it is, and assert it; for a large report or export, compare with a saved copy. You aren't judging the behavior yet, only recording it.
Start with code that looks like the familiar technical debt examples: the same rule in three places, a hard-coded value, an old temporary workaround.
When to stop refactoring and replace a part instead
Refactoring improves code you're keeping. Sometimes the better move is to stop and replace a piece:
- the platform under it has no supported future, so every refactoring polishes code that has to move anyway;
- getting it under test would cost more than rebuilding it, and its behavior is small and well understood;
- the design itself is the problem, so every change still lands in several places after the cleanup;
- nobody uses it, in which case delete it rather than refactor it.
Replace one part at a time behind a stable interface, and use the characterization tests you've written as the specification for the replacement. Before replacing anything larger, read before you rewrite a legacy system.
Refactoring legacy code with AI
AI assistants are quick at the mechanical parts: proposing an extract method, drafting characterization tests, finding every caller of a function. They don't know whether a behavior is intended. A generated test asserts what the model thinks the code does, so run it against the current code and read why it passes before you trust it. Ask for one refactoring at a time, review the diff, and rerun the full suite. Strengths and failure modes are in AI for legacy code, and reading the code before you start is covered in how to understand legacy code.
Before a large refactoring, find out where the risk is
Refactoring goes best when you know where the risk sits before you start, and the 30-day assessment shows you. It's fixed in scope, at a price agreed before we start. Over 30 days we read the codebase and the architecture around it, map how the system fits together, find the hidden architectural debt, including the untested modules everything else depends on, and flag the security and compliance gaps.
The report says in plain language what is broken and what it would take to fix it, ranked, so you know which module to put under test first. Your own team can act on it, or we can do the work as a separate project with its own scope and price. The report stands on its own either way.
Book a scoping call and tell us which part of the system nobody wants to touch.