Large-Scale Refactoring from the Terminal
You have been putting off this refactoring for six months. The utils directory has 47 files. Half the codebase uses callbacks while the other half uses async/await. There are three different ways to handle errors. Your team lead finally said “we need to clean this up before the next feature push” and you drew the short straw.
Manual refactoring at scale is tedious and error-prone. You rename a function, miss one call site, and the bug report arrives at 3 PM on Friday. Claude Code changes this equation. It reads your entire codebase, understands the dependency graph, and applies changes systematically across hundreds of files. Combined with fan-out patterns for parallel execution and hooks for automatic verification, large-scale refactoring becomes a weekend task instead of a month-long slog.
What You’ll Walk Away With
Section titled “What You’ll Walk Away With”- A workflow for safe, large-scale refactoring with automatic test verification
- Fan-out patterns using headless mode for codebase-wide transformations
- Prompts for common refactoring tasks: renames, pattern migrations, module extraction
- The strangler fig approach for incremental replacement of legacy patterns
The Safe Refactoring Workflow
Section titled “The Safe Refactoring Workflow”Large refactoring fails when you change everything at once, break tests in ways you do not understand, and spend more time debugging the refactoring than you spent on the refactoring itself. The safe approach is incremental: one pattern at a time, verified at every step.
-
Establish the safety net
Before changing any code, make sure the tests pass and you have a clean git state.
Run the full test suite and confirm everything passes.Then run the linter and type checker. If anything is brokenalready, fix it before we start refactoring. I need a cleanbaseline to refactor against. -
Analyze what needs to change
-
Refactor one batch at a time
Start with batch 1: the utility functions in src/lib/.For each file:1. Make the change2. Update all callers of the changed code3. Run the tests for the affected modulesShow me the test output after each file. If a test fails,fix it before moving to the next file. -
Commit each batch independently
All tests pass for batch 1. Commit with message:"Migrate src/lib/ utilities from callbacks to async/await" -
Repeat for the next batch
Continue until all batches are complete. Each batch is committed independently, so you can bisect if something breaks later.
Fan-Out Refactoring with Headless Mode
Section titled “Fan-Out Refactoring with Headless Mode”For truly large-scale changes — renaming a type across 200 files, updating import paths after a directory restructure, or migrating a deprecated API — headless mode lets you parallelize the work.
# Find all files that use the old patterngrep -rl "oldFunctionName" src/ | while read file; do claude -p "In $file, rename all occurrences of oldFunctionName to newFunctionName. Update any related variable names and comments. Do not change the logic, only the names." &donewait
# Run tests to verifynpm testFor more complex transformations:
Using sub-agents for parallel refactoring
Section titled “Using sub-agents for parallel refactoring”Inside an interactive session, sub-agents can handle different parts of the refactoring simultaneously:
Use sub-agents to refactor these three areas in parallel:
1. Migrate all files in src/services/ from the old error handling pattern to the new AppError pattern
2. Migrate all files in src/routes/ from express callbacks to async route handlers
3. Update all files in src/middleware/ to use the new logger instead of console.log
Each sub-agent should run the relevant tests after makingchanges. Report back with the results.Common Refactoring Patterns
Section titled “Common Refactoring Patterns”Rename across the codebase
Section titled “Rename across the codebase”Rename the User model field "fname" to "firstName" everywhere:1. Update the database migration2. Update the Prisma/Drizzle schema3. Update all service files that reference the field4. Update all API response shapes5. Update all test files6. Update all frontend components that display the field
Search for both "fname" and "user.fname" and "user['fname']"to catch all access patterns. Run the full test suite afterall changes.Extract a module from a monolith
Section titled “Extract a module from a monolith”The authentication logic is scattered across six files. Extractit into a self-contained src/modules/auth/ directory:
1. First, identify every auth-related function, type, and constant across the codebase2. Create the new directory structure: - src/modules/auth/index.ts (public API) - src/modules/auth/service.ts - src/modules/auth/middleware.ts - src/modules/auth/types.ts - src/modules/auth/constants.ts3. Move the code, updating all imports across the codebase4. Ensure the public API of the module is explicit -- only export what external code actually uses
Run tests after every move to catch broken imports immediately.Convert JavaScript to TypeScript
Section titled “Convert JavaScript to TypeScript”Convert src/legacy/ from JavaScript to TypeScript incrementally.Start with the leaf files (no dependencies on other legacy files),then work inward.
For each file:1. Rename .js to .ts2. Add type annotations for all function parameters and returns3. Replace any with proper types4. Add interfaces for object shapes5. Fix any type errors the compiler finds
Do not change any logic. This is a type-only migration.Run tsc --noEmit after each file to catch type errors immediately.Migrate deprecated API usage
Section titled “Migrate deprecated API usage”Our dependency @acme/sdk just released v3 which deprecatesthe query() method in favor of execute(). The migration guidesays:- query(sql) becomes execute({ sql })- query(sql, params) becomes execute({ sql, params })- The return type changed from rows[] to { rows, metadata }
Find every file that imports from @acme/sdk and uses query().Apply the migration. Update the return type handling.Run tests after each file.The Strangler Fig Approach
Section titled “The Strangler Fig Approach”For legacy systems where you cannot refactor everything at once, use the strangler fig pattern: build the new version alongside the old one, gradually route traffic to it, and remove the old code once everything is migrated.
We need to replace our homegrown validation with Zod.Current validation is spread across 30+ files using customvalidate() functions.
Phase 1 (this PR):- Create Zod schemas that match the current validation rules- Add a compatibility wrapper that runs both old and new validation and logs discrepancies- Deploy and monitor for mismatches
Phase 2 (next PR):- Switch to Zod as the primary validator- Keep the old code as a fallback behind a feature flag
Phase 3 (final PR):- Remove the old validation code and the feature flag- Remove the compatibility wrapper
Start with Phase 1. Read three existing validate() functionsto understand the current rules, then create equivalent Zodschemas.Verifying Refactoring with Hooks
Section titled “Verifying Refactoring with Hooks”Set up hooks that run after every edit to catch problems immediately:
{ "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "command": "npx tsc --noEmit 2>&1 | head -20" } ] }}For refactoring that affects many files, a broader check after each edit prevents cascading errors:
After every file you change, run:1. npx tsc --noEmit (catch type errors immediately)2. npm test -- --run --reporter=dot (quick test run)
If either fails, stop and fix before moving to the next file.Do not accumulate broken files.Measuring Refactoring Impact
Section titled “Measuring Refactoring Impact”After completing a refactoring, quantify what improved:
Compare the codebase before and after this refactoring:
1. Run the linter and compare warning counts2. Run the type checker and compare error counts3. Run the test suite and compare pass/fail/duration4. Count the number of files, functions, and lines of code5. Measure cyclomatic complexity for the refactored modules
Show me a before/after summary so I can include it in the PRdescription.When This Breaks
Section titled “When This Breaks”Refactoring breaks a test you did not expect. The change had a hidden dependency. Roll back to the last commit, investigate the dependency, and update the plan to account for it. This is why committing after each batch matters — rollbacks are clean.
The rename missed some call sites. Claude’s search missed dynamic access patterns like obj[fieldName] or string interpolation. After a rename, run a broader search: “Search for both the old name and any string that contains the old name. Check configuration files, SQL queries, and template strings.”
The refactoring is too big for one session. Use headless mode for the mechanical parts and save the interactive session for the complex transformations. Or split the refactoring into multiple PRs: “Give me a plan that breaks this refactoring into three PRs, each independently deployable and reviewable.”
Type errors cascade after a change. You changed a type that is used everywhere. Start from the type definition and work outward: “Fix the type errors starting from the files closest to the changed type, then move to the files that depend on those files. Show me the error count after each file.”
The team is still merging code into the files you are refactoring. Coordinate with the team. For large refactorings, consider a short code freeze on the affected files, or use git worktrees so you can rebase frequently without losing progress.
What’s Next
Section titled “What’s Next”With clean, well-structured code and comprehensive tests, you are in a strong position to generate documentation that stays current with the codebase.