Skip to content

Refactoring patterns in Claude Code are named, behaviour-preserving moves (Extract Function, Replace Conditional with Polymorphism, splitting a class behind a facade, callbacks to async/await) run one per turn against tests the agent is not allowed to edit. Each move is accepted on evidence: an unchanged test diff, a green suite, type and lint gates, and a commit per move.

You open src/services/checkout.service.ts to add one discount rule and find processOrder: 340 lines of inline validation, three nested if branches for payment type, a pricing calculation, an inventory write, and an email send, all sharing local variables. The Vitest suite is green, but nobody touches the function, because any change might break a path that only fires in production.

This guide is for developers who want Claude Code to do that restructuring inside one module or a handful of files, and who want to accept the result without re-reading every moved line. For renames and pattern migrations across hundreds of files, use large-scale refactoring from the terminal instead. The same loop in the other tools is covered in refactoring with Codex and refactoring strategies in Cursor.

  • A seven-step loop that keeps the suite green and the tests untouched at every step
  • Copy-paste prompts for pinning behaviour, Extract Function, the Strategy pattern, a service split, and callbacks to async/await
  • A session setup that denies test edits, plus a one-line gate that proves the deny rule held
  • A reviewer prompt that hunts for behaviour changes, so you review a findings table instead of the diff
  • The failure modes specific to agent refactoring, and how to recover from each

How does the test-locked refactoring loop work?

Section titled “How does the test-locked refactoring loop work?”

A refactor is correct when observable behaviour does not change. The tests are the only cheap proof of that, so the loop protects the tests first and moves code second. Caring about the structure of the code and its tests while the agent does the typing is augmented coding.

  1. Pin the behaviour. Have Claude write characterization tests for the code as it is today, then commit them. The full technique is in characterization tests.

  2. Prove the tests can fail. Run a mutation test on the file you are about to move. Surviving mutants mark behaviour your tests do not pin yet.

  3. Tag the baseline and lock the tests. Run git tag refactor-base, then start a fresh session that cannot edit test files.

  4. Plan the moves. In plan mode (/plan, or Shift+Tab to cycle modes), ask for an ordered list of named moves, smallest risk first.

  5. Make one named move per turn. Extract a function, introduce a parameter object, or replace a conditional. One move is never a rewrite.

  6. Run the gate. Test files are unchanged since refactor-base, the suite passes, and the type checker and linter pass.

  7. Commit, then repeat. One move per commit, so a bad step is one git revert. Undo an uncommitted bad step with /rewind (or Esc twice on an empty prompt). Checkpoints cover Claude’s file edits, not shell commands; use git checkout -- . or git stash for those.

Steps 2 and 3 are the ones teams skip, and they are what let you trust the result without reading it. Step 5 matters because a small move keeps each diff small enough for the gate and the reviewer to judge.

Pin current behaviour before Claude moves any code

Section titled “Pin current behaviour before Claude moves any code”

Ask Claude to read the real file first, so it tests the code that exists rather than the code it assumes exists. Characterization tests pin what the code does, including bugs, so tell Claude to record suspected bugs rather than fix them.

Commit the tests on their own, then check they have teeth. A test that passes whatever the code does proves nothing about a refactor. StrykerJS mutates the production file and reports which mutants your tests fail to kill. Run it in your terminal:

Terminal window
npm install --save-dev @stryker-mutator/core @stryker-mutator/vitest-runner
npx stryker run --testRunner vitest --mutate src/services/checkout.service.ts

Paste the surviving mutants back to Claude and ask for tests that kill them, then re-run. Record the mutation score: it is part of the evidence you attach to the pull request. How strong is your oracle? covers mutation testing for Python and other stacks.

The most common failure in agent refactoring is a test edited until it passes. Remove the option instead of asking nicely. Tag the baseline and start the refactoring session with test edits denied:

Terminal window
git tag refactor-base
claude --disallowedTools "Edit(**/*.test.ts)"

The deny rule stops Claude’s edit tool on test files. It does not stop a shell command such as sed -i, so the gate checks the result rather than trusting the rule. Auto mode, the starting permission mode for interactive terminal and VS Code sessions from v2.1.283, lets more shell commands run without asking you first, which is one more reason to check the files rather than the rule. Run this in your terminal after every move:

Terminal window
git diff --exit-code refactor-base -- '*.test.ts' \
&& test -z "$(git ls-files --others --exclude-standard -- '*.test.ts')" \
&& npx vitest run \
&& npm run type-check \
&& npm run lint

git diff --exit-code fails if any tracked test file differs from the baseline, committed or not. It ignores new untracked files, so the git ls-files line fails if a new test file has appeared. To run the same checks automatically after each edit, wire them into a hook as described in hooks automation. Protect the oracle extends the same idea to CI, so a pull request that changes both a test and the code it covers is flagged.

Name the move in the prompt. “Clean this up” invites a rewrite, while a named move has a known shape that Claude and the gate can both check.

SmellNamed moveExtra evidence beyond the gate
One function does five jobsExtract FunctionExtracted functions are pure where the original block was pure
Six parameters travel togetherIntroduce Parameter ObjectEvery call site compiles with no any added
A switch on a type grows every quarterReplace Conditional with Polymorphism (Strategy)Existing tests pass with zero edits; public signature unchanged
A class mixes unrelated responsibilitiesExtract Class behind a facadeExternal imports unchanged; facade methods only delegate
Callback or .then() chains in one moduleCallbacks to async/awaitEvery caller awaits; the error path has a test
A misleading name used in 40 filesRename Symbol through the language serverType checker clean; no string-literal matches left behind

Extract a function without changing behaviour

Section titled “Extract a function without changing behaviour”

Extract Function solves most “this function is too long” problems. Claude handles the tedious part: threading the shared local variables and updating the original to call the new functions. The prompt shape is the same in every language; only the idioms change.

In src/services/checkout.service.ts, processOrder does five jobs. Extract three
functions in the same file: validateOrder(input), calculatePricing(cart), and
reserveInventory(items). Leave the email send and the DB write in processOrder
as the orchestrator. Keep every existing type; do not widen any type to `any`.
Run `npx vitest run checkout` and `npm run type-check` and report both results.

For a Python or Elixir project, change the deny rule and the gate’s pathspec to match your test layout, for example Edit(tests/**) and 'tests/'.

The stop condition in the last paragraph is the point of the prompt. A failing test after a refactor means behaviour changed, and the correct response is to back out and report, not to adjust the assertion.

A payment-type switch that gains a branch every quarter is a maintenance tax. The Strategy pattern moves each case behind a common interface. This move touches several new files and the order matters, so plan it first: enter plan mode, approve the plan, then let Claude implement. For a multi-file move like this, raise effort with /effort high before you consider another model; the models hub explains that order.

The acceptance test for a Strategy refactor is short: the existing payments suite passes with zero edits. The default branch is the part most often lost. A factory that returns undefined for an unknown type, where the switch used to throw, changes behaviour, and a characterization test for the unknown type catches it.

Split an overgrown service behind a facade

Section titled “Split an overgrown service behind a facade”

When a class has absorbed unrelated responsibilities, split it, but keep a thin facade so that no external import changes in the same pull request. The type checker, not your eyes, verifies the seams.

Open an issue to remove the facade in a later pull request. A facade that nobody tracks tends to become permanent.

Modernize one legacy module: callbacks to async/await

Section titled “Modernize one legacy module: callbacks to async/await”

The trap in modernization is updating the implementation while leaving call sites or error handling on the old shape. Make Claude find every caller before it changes anything. This prompt covers one module and its callers; for the same migration across a codebase, use the fan-out approach in large-scale refactoring.

A test that calls a method with a callback is part of the contract this move changes. Changing that test is your decision, in its own commit, not the agent’s.

Give Claude symbol-level navigation for renames

Section titled “Give Claude symbol-level navigation for renames”

Grep finds strings, not symbols, so a text-based rename can miss a re-export or rename an unrelated process in a comment. A language server resolves references precisely. Anthropic’s official marketplace ships LSP plugins; the language server binary is not bundled, so install it first:

Terminal window
npm install -g typescript-language-server typescript
claude plugin install typescript-lsp@claude-plugins-official

For Python the pair is pyright and pyright-lsp@claude-plugins-official. With the plugin installed, a prompt such as “Find every reference to OrderService.applyDiscount and rename it to applyPromotion” resolves through the language server instead of text search. Top plugins lists the other language plugins, and code-intelligence MCP servers cover Serena, which offers symbol-level editing to Claude Code, Codex, and Cursor alike.

How do you verify a refactor without reading every line?

Section titled “How do you verify a refactor without reading every line?”

You verify the evidence, not the diff. A refactor that passes the checks below has not changed any behaviour your tests and types can see. The mutation score tells you how much behaviour that covers, and the behaviour review covers what tests rarely pin.

CheckCommand or sourceWhat it proves
Tests untouchedgit diff --exit-code refactor-base -- '*.test.ts'The oracle is the same one that passed before the refactor
Suite greennpx vitest runPinned behaviour is preserved
Types and lint cleannpm run type-check and npm run lintNo broken seam, widened type, or dead import
Oracle strengthMutation score from step 2How much of the moved code the tests would catch breaking
Behaviour reviewThe reviewer prompt belowChanges tests rarely cover: error text, side-effect order, missing await

The fifth check is a second agent reading the diff with one question, so you read its findings table rather than the diff. A fresh subagent has not seen the reasoning that produced the change, which makes it a better reviewer than the session that wrote it. To make that reviewer reusable, save it as a custom subagent.

After the review is clean, the bundled /simplify skill can tidy the changed code. It is a quality pass, not a bug hunt, so run the gate again afterwards. For the pull request, attach the gate output, the mutation score, and the reviewer’s table; the evidence bundle gives that a standard shape. The developer who opens the pull request signs off on the evidence, and the human reviewer checks the evidence rather than every moved line. Evidence, not diffs explains why that is the job now.

When refactoring with Claude Code goes wrong

Section titled “When refactoring with Claude Code goes wrong”

A test was edited to make it pass. The gate’s git diff fails. Restore the test with git checkout refactor-base -- path/to/file.test.ts, discard the move with /rewind or git revert, and restart the move in a session started with --disallowedTools.

The refactor passed, but a production-only path broke. The characterization tests did not cover that path, and the mutation run should have shown survivors there. Revert the commit, add a test for the path that broke, confirm the test fails on the broken version, and redo the move.

One turn produced a big-bang diff. Claude rewrote the module instead of making one move. Undo it with /rewind before you commit, or git revert after. Re-prompt with one named move and “stop after this move and wait for me”.

The characterization tests pinned a bug. That is correct: a refactor preserves behaviour, bugs included. Fix the bug in a separate commit after the refactor, with the test change and the fix in the same commit, so the history shows a deliberate behaviour change.

Claude drifts from the codebase’s conventions. It invents a new error format or folder layout mid-refactor. Point it at an anchor (“match the error handling in src/api/routes/orders.ts exactly”), and record the convention in CLAUDE.md so later sessions start with it.

The session loses the thread after many moves. Long refactors fill the context window and early decisions fade. Because every move is committed, you can run /clear between moves and start the next one from the plan and git log, with no loss.

A performance “refactor” changes results. Replacing an O(n²) loop is a behaviour risk, not a pure move. Pin the output with a test first, swap the algorithm, confirm the test passes, and measure with your benchmark harness instead of trusting an asymptotic argument.