Refactoring patterns in Claude Code
PLRefactoring 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.
What you get from test-locked refactoring
Section titled “What you get from test-locked refactoring”- 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.
-
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.
-
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.
-
Tag the baseline and lock the tests. Run
git tag refactor-base, then start a fresh session that cannot edit test files. -
Plan the moves. In plan mode (
/plan, or Shift+Tab to cycle modes), ask for an ordered list of named moves, smallest risk first. -
Make one named move per turn. Extract a function, introduce a parameter object, or replace a conditional. One move is never a rewrite.
-
Run the gate. Test files are unchanged since
refactor-base, the suite passes, and the type checker and linter pass. -
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; usegit checkout -- .orgit stashfor 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:
npm install --save-dev @stryker-mutator/core @stryker-mutator/vitest-runnernpx stryker run --testRunner vitest --mutate src/services/checkout.service.tsPaste 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.
Lock the tests so Claude cannot edit them
Section titled “Lock the tests so Claude cannot edit them”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:
git tag refactor-baseclaude --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:
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 lintgit 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.
Which refactoring move fits the smell?
Section titled “Which refactoring move fits the smell?”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.
| Smell | Named move | Extra evidence beyond the gate |
|---|---|---|
| One function does five jobs | Extract Function | Extracted functions are pure where the original block was pure |
| Six parameters travel together | Introduce Parameter Object | Every call site compiles with no any added |
A switch on a type grows every quarter | Replace Conditional with Polymorphism (Strategy) | Existing tests pass with zero edits; public signature unchanged |
| A class mixes unrelated responsibilities | Extract Class behind a facade | External imports unchanged; facade methods only delegate |
Callback or .then() chains in one module | Callbacks to async/await | Every caller awaits; the error path has a test |
| A misleading name used in 40 files | Rename Symbol through the language server | Type 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 threefunctions in the same file: validateOrder(input), calculatePricing(cart), andreserveInventory(items). Leave the email send and the DB write in processOrderas 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.In app/services/checkout.py, refactor process_order by extractingvalidate_order(payload), calculate_pricing(cart), and reserve_inventory(items)as module-level functions with type hints. Keep process_order as theorchestrator. Do not edit anything under tests/. Run`pytest tests/test_checkout.py -q` and `mypy app/services` and show the output.In lib/shop/checkout.ex, process_order/1 does too much. Extractvalidate_order/1, calculate_pricing/1, and reserve_inventory/1 as privatefunctions and pipe them together in process_order/1. Keep the existingtypespecs. Do not edit anything under test/. Run`mix test test/shop/checkout_test.exs` and report the result.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.
Replace a type switch with polymorphism
Section titled “Replace a type switch with polymorphism”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:
npm install -g typescript-language-server typescriptclaude plugin install typescript-lsp@claude-plugins-officialFor 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.
| Check | Command or source | What it proves |
|---|---|---|
| Tests untouched | git diff --exit-code refactor-base -- '*.test.ts' | The oracle is the same one that passed before the refactor |
| Suite green | npx vitest run | Pinned behaviour is preserved |
| Types and lint clean | npm run type-check and npm run lint | No broken seam, widened type, or dead import |
| Oracle strength | Mutation score from step 2 | How much of the moved code the tests would catch breaking |
| Behaviour review | The reviewer prompt below | Changes 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.