ES · Leer en español Explore for free

Recipe · Intermediate · 20 min

Refactor legacy code with Claude Code without breaking anything

A 5-step method to refactor legacy code with Claude Code: plan mode, characterization tests, small changes with a commit each, and what /rewind undoes.

Cover: Refactor legacy code with Claude Code without breaking anything

Tested with Claude Code 2.1.316 · reviewed

See the files on GitHub Free · MIT license

The problem

This code is 12 years old and nobody wants to touch it. It has no tests, nobody remembers why it does what it does, and other code depends on its quirks. Refactoring legacy code without tests is the fastest way to break production, with or without AI, and with an agent that edits quickly the risk is higher if there is no safety net. This recipe applies Michael Feathers' classic method with Claude Code: understand before touching, pin the current behavior with characterization tests, change in small verified steps, and always have a way back with git and /rewind, knowing what /rewind does not undo.

The solution

The example is real: a billing function from 2014. All the files (the legacy code, sample characterization tests, the prompts and the CLAUDE.md block) are in the free repository ctrlia-refactorizar-codigo-legado (English version in its en/ folder).

# invoices.py: written in 2014, nobody touches it
def total(lines, customer):
    t = 0
    for l in lines:
        if l[2] == None:
            l[2] = 1
        t = t + l[1] * l[2]
    if customer == "VIP":
        t = t - t * 10 / 100
    if t > 1000:
        t = t - 50
    return round(t, 2)

(total takes invoice lines [item, price, quantity] and a customer.)

1. Explore without touching: plan mode

Before changing anything, ask Claude to understand the code and explain it to you. Press Shift+Tab until you see "plan mode on" in the status bar: in plan mode Claude reads and proposes, but does not edit. Ask for something specific:

Explain what invoices.total does, who calls it (search the whole repo), what data it receives and what side effects it has. Don't propose changes yet.

If the code is large, ask it to use a subagent to search for callers: the result arrives summarized and your conversation does not fill up with grep output (we explain it in the subagents recipe). What you are after in this step is the map: inputs, outputs, side effects (writes to disk, modifies arguments, calls the network) and who depends on what. Write down what you discover in a REFACTOR.md file; it will help if the session gets compacted or you pick it up another day. Anthropic's official video sums up the pattern as "Explore → Plan → Code → Commit", and the common workflows guide recommends refactoring in small, testable increments.

2. Characterization tests: pin down what it does today

A characterization test does not check what the code should do, but what it does today, quirks included: other code may depend on them. Ask for the tests and forbid touching the code:

git add -A && git commit -m "before the refactor"

claude --disallowedTools "Edit(invoices.py)"
# > Write CHARACTERIZATION tests in tests/test_invoices.py (unittest) for invoices.total:
#   they must pin the CURRENT behavior, even if it looks like a bug, not the behavior it should have.
#   Cover: quantity None, VIP customer, the 50 discount above 1000, the order
#   VIP-then-50, empty list and whether the function modifies its input list.
#   Don't touch invoices.py. Run the tests (they must all pass) and at the end list
#   in 3 bullets the quirks you found.

python3 -m unittest discover -s tests -t .
git add tests && git commit -m "test: characterization of invoices.total"

List, as in the prompt, the cases that worry you (null values, discounts, edges, empty lists, side effects) and ask it to list the quirks it found at the end. In our test, on this 12-line function, Claude wrote 10 passing tests and found three things nobody had documented (the session was in Spanish; translated here):

- It mutates its input: it replaces a None quantity with 1 directly in the lists it receives, so the caller sees the change.
- The return type changes: with an empty list it returns int (0) without VIP and float (0.0) with VIP. Also, the 50 discount is decided on the total already reduced by VIP, so VIP can cancel that discount.
- Minimal validation: the comparison with "VIP" is case-sensitive, a quantity of 0 stays 0 and a None price raises TypeError. I read these three points in the code and did not pin them in tests.

Note that last bullet: it also flagged what it read in the code but did not pin in tests, such as lowercase "vip" getting no discount. Review the list and decide which quirks are bugs and which are contract; commit the tests. The repository has a sample en/example/tests/test_invoices.py with 12 tests that pin all those quirks, including the three Claude did not pin, so you can compare with yours.

3. Change in small steps, one per commit

Now you can refactor, but one change at a time. First the ones that do not change behavior: renaming l and t, extracting the VIP discount into a function, replacing == None with is None. After each change, Claude runs the characterization tests and, if they pass, you commit. If they fail, that change is discarded and you do not go on. A good prompt for each step:

Make ONLY this change: <specific change, e.g. extract the VIP discount into an apply_vip function>.
Don't change the behavior or edit anything in tests/.
Run python3 -m unittest discover -s tests -t . and show me the output.
If any test fails, undo the change and tell me why it failed.

Ask it not to edit the tests in this phase: it is the same rule as in the TDD with Claude Code recipe, with Edit(tests/**) denied. Fixing the quirks you decided are bugs is a separate phase: first you change the characterization test so it describes the correct behavior (it turns red), you commit, and then you ask for the fix, as in TDD. That way every behavior change is explicit in the history, separate from the changes of form. If the module is large, start with the function that is used the most and understood the least.

4. Keep a way back: what /rewind undoes and what it does not

Claude Code saves a checkpoint before each prompt you send. /rewind (or Esc twice with an empty input) opens a menu with your prompts and lets you restore the code, the conversation or both to that point; the last 100 are kept and are still there if you resume the session. But the checkpointing documentation lists limits that matter a lot in a refactor:

The documentation itself says it is not a replacement for version control. So the order is: commit before each change, /rewind for the quick "oops" inside the session, and git restore or git revert for everything else.

5. Leave the ground ready for next time

When you finish, save what you learned where Claude will read it again. In the project's CLAUDE.md, add a short section about the module: its test command, the quirks that are contract ("total() returns int with an empty list: the monthly report uses it") and the ones still pending:

## invoices.py (legacy code)
- Tests: `python3 -m unittest tests.test_invoices` (characterization: they pin the real behavior).
- Contract that does NOT change without warning: total() returns an int for an empty list and no VIP.
- Pending: total() modifies its input list (decide whether that's a bug).
- One change per commit; behavior changes always with the test changed first.

The official best practices guide gives an example of this kind of note: a legacy folder that is still in production and whose patterns should not be copied into new code (more in a CLAUDE.md that works). Keep the characterization tests in the repository: they are the most reliable documentation of the real behavior and will warn you if someone changes it by accident. If the refactor was long, ask Claude to summarize in REFACTOR.md what changed, in which commits and what is left, and delete it once you merge. For large refactors in parallel (several modules at once), see the subagents recipe; and if you want to run the characterization tests every night, the claude -p in cron one.

Check it

With the repository's example:

cd en/example
python3 -m unittest discover -s tests -t .

Expected output:

............
----------------------------------------------------------------------
Ran 12 tests in 0.000s

OK

To see the tests protect you, make two changes. First a change of form: extract the discount into apply_vip, rename l to line and change == None to is None; all 12 tests stay OK. Then a behavior change: make the function stop modifying its input list; test_quirk_modifies_the_input_list fails. That is what you want: a behavior change never gets through without you seeing it.

What we checked with Claude Code 2.1.316 on Linux on 1 Nov 2026: claude -p --model haiku --disallowedTools "Edit(facturas.py)" (on the Spanish version of the example) wrote 10 characterization tests with unittest that passed, and listed the quirks above (an estimated 0.01 USD). We did not run /rewind, because it is interactive: its limits come from the official checkpointing documentation.

Why it works

Characterization tests turn the current behavior, which only existed in the code, into something executable: any change that alters it turns red at once. Locking invoices.py with --disallowedTools while they are written forces the tests to describe the code as it is, not as Claude thinks it should be. Small changes with one commit each mean that, if something fails, you know exactly what broke it and can undo it with git, which covers what /rewind does not (Bash, subagents and external changes). More details in checkpointing, common workflows, best practices, permission modes and permissions; and in our Claude Code permissions recipe.

Common mistakes

FAQ

Why pin bugs in the tests instead of fixing them right away?

Because other code may depend on them. First you pin them, then you decide one by one whether each is a bug or contract, and you fix them as an explicit change with its test.

Will /rewind save me if something goes wrong?

Only partly: it undoes Claude's edits made with its file tools, but not files changed only by Bash, and usually not subagent edits or external changes either. Commit before each step.

The module is huge. Where do I start?

With the function that is used the most and understood the least. Explore in plan mode, use a subagent to find callers and characterize only that function before moving on to the next.

What if the code has no tests and no easy way to run it?

First ask for the minimal harness: a script that calls the function with sample data and saves the output (golden master). Compare that output before and after each change.

Does it work with PHP, Java or COBOL?

The method does; the test framework changes. Put the exact command to run them, and how to run a single file, in CLAUDE.md.

The whole library