# Refactoring patterns in Claude Code

URL: https://developertoolkit.ai/en/claude-code/build/refactoring-patterns/
Description: Refactor one module with Claude Code under locked tests: Extract Function, polymorphism, service splits and async/await, each proven by gates, not by reading.

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](/en/claude-code/build/refactoring/) instead. The same loop in the other tools is covered in [refactoring with Codex](/en/codex/build/refactoring/) and [refactoring strategies in Cursor](/en/cursor-ide/build/refactoring-strategies/).

## 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?

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](/en/glossary/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](/en/workflows/test/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

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.

**Copy-paste prompt: pin behaviour before a refactor**

```text
Read src/services/checkout.service.ts in full, then its test file
src/services/__tests__/checkout.service.test.ts.

First, list every distinct code path through processOrder and the input that
triggers each one. Then write characterization tests that lock in the CURRENT
behaviour of every path on that list, including credit card vs PayPal, the
out-of-stock path, and the partial-refund path. Assert on return values,
thrown errors (message and type), and calls to the mocked email and inventory
clients (vi.mock, in the style the file already uses).

Do not change production code. If a behaviour looks like a bug, pin it anyway
and list it under "Suspected bugs" in your summary. Run
`npx vitest run checkout` and show that every new test passes against the
unchanged implementation.
```

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](https://github.com/stryker-mutator/stryker-js) mutates the production file and reports which mutants your tests fail to kill. Run it in your terminal:

```bash
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?](/en/workflows/test/oracle-strength/) covers mutation testing for Python and other stacks.

## 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:

```bash
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:

```bash
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](/en/claude-code/automate/hooks-automation/). [Protect the oracle](/en/workflows/test/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?

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

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.

**TypeScript**

```text
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.
```

**Python**

```text
In app/services/checkout.py, refactor process_order by extracting
validate_order(payload), calculate_pricing(cart), and reserve_inventory(items)
as module-level functions with type hints. Keep process_order as the
orchestrator. Do not edit anything under tests/. Run
`pytest tests/test_checkout.py -q` and `mypy app/services` and show the output.
```

**Elixir**

```text
In lib/shop/checkout.ex, process_order/1 does too much. Extract
validate_order/1, calculate_pricing/1, and reserve_inventory/1 as private
functions and pipe them together in process_order/1. Keep the existing
typespecs. 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/'`.

**Copy-paste prompt: one Extract Function move with a stop condition**

```text
Extract the validation block (the lines that check input.email, input.items,
and input.paymentMethod) from processOrder in
src/services/checkout.service.ts into a new exported function
validateOrder(input: OrderInput): void that throws the same errors as today.
Update processOrder to call it.

Constraints:
- Keep every error message, error class, and the order in which checks run.
- Add no dependencies and change no other function.

Then run `npx vitest run checkout`. If any test fails, undo your change and
tell me which assertion broke. Do not modify a test to make it pass. If
everything passes, commit with the message
"refactor(checkout): extract validateOrder".
```

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

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](/en/ecosystem/models/) explains that order.

**Copy-paste prompt: Strategy pattern refactor**

```text
Plan first, then wait for my approval before editing.

In src/payments/, replace the switch(paymentType) block in
PaymentService.charge() with the Strategy pattern:
- Define an interface PaymentStrategy with charge(amount: Money): Promise<Receipt>.
- Create CreditCardStrategy, PayPalStrategy, and CryptoStrategy, each holding
  the logic currently in its switch case, moved without changes.
- Add a paymentStrategyFor(type) factory. An unknown type must throw the same
  error the switch's default branch throws today.
- PaymentService.charge() resolves the strategy and delegates.

Keep the public signature of PaymentService.charge() unchanged. Implement one
strategy per commit, and after each commit run `npx vitest run payments` and
`npm run type-check`. Finish with a one-line summary of each new file.
```

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

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.

**Copy-paste prompt: split a service behind a facade**

```text
src/services/user.service.ts is about 900 lines and mixes authentication,
profile updates, and notification sending. Plan a split into AuthService,
ProfileService, and NotificationService. For each one, list the methods that
move, the private helpers they need, and the call sites that must change.

Keep UserService as a facade whose methods only delegate, so no import
outside src/services/ changes in this pull request. Implement only after I
approve the plan, one service per commit. After each commit run
`npm run type-check` and `npx vitest run services`, and report any method
that could not move without changing behaviour.
```

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

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](/en/claude-code/build/refactoring/).

**Copy-paste prompt: callbacks to async/await in one module**

```text
Read src/clients/api-client.ts and find every file that imports from it.
List the callback-style methods (those taking an (err, data) callback) and
every call site for each one before you edit anything.

Then convert those methods to return Promises and use async/await internally.
Update every call site you listed to await the result and handle errors with
try/catch, keeping the same error behaviour the callback path had. Do not
edit test files: if a test calls a converted method with a callback, stop and
list it for me. Run `npx vitest run clients` and `npm run lint`, then list
every file you touched.
```

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

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:

```bash
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](/en/ecosystem/plugins/top-plugins/) lists the other language plugins, and [code-intelligence MCP servers](/en/ecosystem/mcp/code-intelligence-mcp/) cover Serena, which offers symbol-level editing to Claude Code, Codex, and Cursor alike.

## 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](/en/claude-code/automate/custom-subagents/).

**Copy-paste prompt: behaviour-change review of a refactor**

```text
Use a subagent to review `git diff refactor-base..HEAD` as a refactoring
review. The only question is whether observable behaviour changed. Check for:
- changed error messages, error classes, or status codes
- a changed order of side effects (writes, emails, events, logging)
- a missing await, or a promise that is no longer returned
- a changed default value, a dropped default or switch branch, or a changed
  null/undefined handling
- any widened type, new `any`, or new dependency

Return a table: file, line, suspected change, and severity (blocker or note).
Do not fix anything. If there is nothing to report, say so in one line.
```

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](/en/workflows/ship/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](/en/ladder/evidence-not-diffs/) explains why that is the job now.

## 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`](/en/claude-code/build/memory-system/) 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.

## Where to go next with refactoring

- [Large-scale refactoring from the terminal](/en/claude-code/build/refactoring/) — Renames and pattern migrations across hundreds of files with fan-out and headless mode.
- [Characterization tests](/en/workflows/test/characterization-tests/) — Pin legacy behaviour at the seams before an agent touches the code.
- [Protect the oracle](/en/workflows/test/protect-the-oracle/) — Stop agents from weakening the tests that judge their work, locally and in CI.
- [Legacy modernization with agents](/en/workflows/build/legacy-modernization/) — When one module becomes a programme: slicing, parallel waves, and cut-over evidence.
- [Keeping an agent-written codebase healthy](/en/workflows/operate/codebase-health/) — Spot duplication and drift, and schedule refactoring loops before erosion sets in.
