Safe Refactoring With Claude Code

2026-07-11 · 6 min

Ask a coding agent to "clean up this file" and you're rolling dice. Claude Code is fast and fluent enough to rewrite a 300-line module in one pass — new names, new structure, a fixed bug it noticed on the way, and a subtle behavior change you won't spot until production. The output looks better and reads better. Whether it does the same thing is anybody's guess.

This article is about refactoring with Claude Code the way a careful engineer does it by hand: pin the current behavior with tests first, change structure in small verified steps, keep the public interface stable, and never let a refactor and a behavior change ride in the same commit. Then it shows how to instruct Claude to work this way instead of defaulting to a risky rewrite. The examples are TypeScript for brevity; the discipline is language-agnostic.

What "refactor" actually means

A refactor changes the shape of code without changing its observable behavior. Same inputs, same outputs, same side effects — just a cleaner internal structure. That's the definition, and it's the whole safety property: if behavior is genuinely preserved, a refactor can't introduce a behavior bug, because nothing observable has changed.

The moment you also "improve" a rounding rule or "fix" an off-by-one while restructuring, you've left refactoring and entered rewriting. Rewriting is sometimes the right call — but it's a different, riskier activity, and it deserves its own review. The single most damaging habit with an AI agent is letting the two blur together, because when the tests go red you can't tell whether your restructuring broke something or your "fix" did.

Pin the behavior before you touch it

You cannot refactor safely if you don't know what the code currently does. And for anything gnarly enough to be worth refactoring, you probably don't know exactly — that's why it's gnarly. So you capture it, mechanically, with characterization tests: tests that assert the current behavior as-is, quirks included, before any change.

A characterization test isn't asking "what should this do?" It's asking "what does this do right now?" You feed the function representative inputs, run it, and lock in whatever comes out — even if some of it looks wrong. The goal is a net that goes red the instant behavior shifts, so the refactor has something to be measured against.

This is exactly the kind of task Claude Code is good at, if you frame it correctly:

Do not change any code yet. Read pricing.ts and write characterization tests for calculateShipping that capture its current behavior exactly — including any edge cases or quirks that look like bugs. Cover each branch and boundary you can find. If a current output looks wrong, still assert the actual current value and add a // characterization: looks wrong, preserved on purpose comment. Run the tests and confirm they pass against the code as it stands.

That last instruction matters. If the model "helpfully" writes tests asserting what the code should do, you get a red suite against unchanged code and no baseline at all. You want the tests green against today's behavior, warts and all. The warts get their own ticket, later, on purpose.

Change structure in small, verified steps

With a green characterization suite in place, the refactor becomes a sequence of tiny moves, each one verified before the next begins. The unit of work is one behavior-preserving transformation — extract a function, rename a variable, replace a nested conditional with a guard clause, dedupe a repeated block — followed immediately by running the tests.

The reason for small steps is debuggability. If you make ten structural changes and the suite goes red, you've got ten suspects and a bisect ahead of you. If you make one and it goes red, you know precisely what did it and you revert one thing. Small steps also keep the agent honest: a model told to "extract the tax calculation into its own function and stop" has far less room to smuggle in an unrequested change than one told to "refactor this file."

A prompt that enforces the cadence:

Refactor calculateShipping in small steps. For each step: make exactly one structural change that preserves behavior, then run the characterization tests and paste the result. Do not batch multiple changes. Do not fix any bug or change any output — if you spot a real bug, list it separately at the end; do not touch it. Stop after each step and wait for me to confirm before the next.

Keep the interface stable

The blast radius of a refactor is set by how far it reaches. Keep the changes inside the seam — the function body, the private methods, the module internals — and every caller keeps working untouched. Change the signature, the return shape, or the thrown-error type, and now you're editing every call site too, and the "small safe refactor" has become a sprawling diff nobody can review.

So hold the public interface fixed. Same function name, same parameters, same return type, same exceptions. If a cleaner interface is genuinely the goal, do it as a deliberate, separate change with its own review — not as a rider on an internal cleanup. Tell Claude the boundary explicitly: "the exported signature of calculateShipping must not change; refactor only its internals." A stated boundary is one the model can respect; an assumed one is one it will cross.

A concrete walkthrough

Here's a tangled function worth cleaning up:

export function calculateShipping(order: Order): number {
  let cost = 0;
  if (order.weight > 0) {
    if (order.weight <= 1) { cost = 5; }
    else if (order.weight <= 5) { cost = 5 + (order.weight - 1) * 2; }
    else { cost = 5 + 4 * 2 + (order.weight - 5) * 1.5; }
  }
  if (order.country !== "US") { cost = cost * 2; }
  if (order.subtotal > 100 && order.country === "US") { cost = 0; }
  return cost;
}

Step 0 — characterize. Claude writes tests for the current behavior: a 0.5kg US order is 5; a 3kg US order is 9; a 3kg international order is 18; a US order over $100 ships free; and — quirk preserved — an international order over $100 does not ship free, because the free-shipping check is country === "US". That last one might be a bug. It gets asserted as-is and noted, not fixed.

Step 1 — extract the weight tiers. Pull the nested weight logic into a weightCost(weight) helper. Run the tests: green. The extraction changed nothing observable.

Step 2 — replace the international multiplier with a named constant and a guard clause. Run the tests: green.

Step 3 — name the free-shipping condition as a qualifiesForFreeShipping(order) predicate. Run the tests: green — and now the international-free-shipping quirk is sitting in an obvious, named place, which makes the eventual bug-fix ticket easy to write.

At no point did the output for any input change. The function is now readable, and every step was proven behavior-preserving by a suite that existed before the first edit. The quirk from Step 0 gets fixed afterward, as its own change — with its characterization test updated deliberately, so the diff that alters behavior is small and reviewable on its own.

Make the agent do this by default

By default, Claude Code will often reach for the confident one-shot rewrite, because that's the most direct reading of "refactor this." You get the discipline by encoding it once, not by re-typing it every session.

A skill — a Markdown procedure with a description that Claude Code pulls in when a task matches it — is the natural home for this. Give a refactor-safely skill the rules above (characterize first, one behavior-preserving step at a time, verify after each, never touch the interface, never mix in a fix), and a plain "refactor this" request can pick up the careful version without you spelling it out again. Pair it with an independent review pass: a fresh subagent, not anchored to the reasoning that made the edits, is well-placed to check the one thing that matters most — did behavior actually stay put, or did a change sneak in? If you'd rather start from a working setup than build both from scratch, the claude-code-starter repo ships a ship skill and an adversarial-reviewer subagent you can adapt to exactly this verify-and-attack shape.

Conclusion

Safe refactoring isn't slower because it's timid — it's faster because you never spend an afternoon bisecting a rewrite to find where behavior drifted. The rules are boring and they work: pin the current behavior with characterization tests before you touch anything, change structure one small verified step at a time, hold the public interface fixed, and keep every real behavior change in its own separate commit. Claude Code will follow all four if you tell it to — and will happily skip all four if you don't. Encode them as a skill, put an independent reviewer between "refactored" and "committed," and the fast agent becomes a safe one.

Related guides

Set up Claude Code the right way

Start free with the open-source starter — a ship skill, an adversarial-reviewer subagent, a bug-hunt workflow, and a CLAUDE.md template. Want the full kit? The Pro Pack adds 6 skills, 3 subagents, 3 workflows, 4 templates and a handbook.

Free starter on GitHub Get the Pro Pack — $39