Skip to content
Back to Blog
Agentic AI7 min read

Claude Code for Refactoring: Modernizing Legacy Codebases at Scale

Strategies for using Claude Code to refactor legacy code — from targeted function rewrites to large-scale migrations, with patterns for safe incremental modernization.

The Legacy Code Challenge

Every software team accumulates technical debt. A module written in haste three years ago now handles 10x the traffic it was designed for. A Python 2 codebase needs to run on Python 3. Express.js callback patterns need to become async/await. jQuery-powered frontends need to become React applications.

Refactoring legacy code is tedious, risky, and time-consuming — exactly the kind of work where Claude Code adds the most value. It can read the entire existing codebase, understand patterns, and systematically apply changes while maintaining behavior.

Strategy 1: The Strangler Fig Pattern

The strangler fig pattern replaces legacy code incrementally. Instead of rewriting everything at once, you wrap old code in new interfaces and replace it piece by piece. Claude Code excels at this pattern because it can understand both the old and new code simultaneously.

You: We have a legacy UserService class with 1,200 lines. I want to refactor it
using the strangler fig pattern. Start by extracting the authentication-related
methods into a new AuthenticationService, keeping the old methods as thin wrappers
that delegate to the new service.

Claude Code will:

  1. Read the entire UserService
  2. Identify all authentication-related methods
  3. Create the new AuthenticationService with the extracted logic
  4. Replace the old methods with delegation calls
  5. Update all imports across the codebase
  6. Run tests to verify nothing broke
# Before: Monolithic UserService (1,200 lines)
class UserService:
    def login(self, email, password): ...
    def logout(self, session_id): ...
    def reset_password(self, email): ...
    def verify_token(self, token): ...
    def create_user(self, data): ...
    def update_profile(self, user_id, data): ...
    # ... 40 more methods

# After: UserService delegates to AuthenticationService
class AuthenticationService:
    """Extracted from UserService — handles all auth concerns."""
    def login(self, email: str, password: str) -> AuthResult: ...
    def logout(self, session_id: str) -> None: ...
    def reset_password(self, email: str) -> None: ...
    def verify_token(self, token: str) -> TokenPayload: ...

class UserService:
    def __init__(self, auth_service: AuthenticationService):
        self._auth = auth_service

    def login(self, email, password):
        return self._auth.login(email, password)  # Thin wrapper

    def create_user(self, data): ...  # Remains in UserService
    def update_profile(self, user_id, data): ...  # Remains

Strategy 2: Pattern Replacement

Claude Code can systematically find and replace patterns across an entire codebase. This is ideal for:

  • Replacing callbacks with async/await
  • Converting class components to functional components
  • Replacing manual SQL with ORM queries
  • Updating deprecated API calls

Example: Callbacks to Async/Await

You: Convert all callback-style database queries in src/services/ to async/await.
The current pattern is:
  db.query(sql, params, (err, result) => { ... })
Replace with:
  const result = await db.query(sql, params)
Handle errors with try/catch. Process each file one at a time and run tests after each file.

Claude Code processes each file:

// Before
function getUser(id, callback) {
  db.query("SELECT * FROM users WHERE id = ?", [id], (err, rows) => {
    if (err) return callback(err);
    if (rows.length === 0) return callback(new Error("Not found"));
    callback(null, rows[0]);
  });
}

// After (Claude Code's refactored version)
async function getUser(id) {
  const rows = await db.query("SELECT * FROM users WHERE id = ?", [id]);
  if (rows.length === 0) {
    throw new Error("Not found");
  }
  return rows[0];
}

Strategy 3: Type Migration

Adding types to an untyped codebase is one of Claude Code's strongest refactoring use cases. It can analyze function usage, infer types, and add annotations incrementally.

JavaScript to TypeScript

You: Convert src/utils/validators.js to TypeScript. Add full type annotations
to all functions. Infer types from usage and return values. Keep all existing
logic exactly the same.
// Before: validators.js
function validateEmail(email) {
  const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return regex.test(email);
}

function validateAge(age) {
  return typeof age === "number" && age >= 0 && age <= 150;
}

function validateUser(data) {
  const errors = [];
  if (!data.name || data.name.length < 2) errors.push("Name too short");
  if (!validateEmail(data.email)) errors.push("Invalid email");
  if (data.age !== undefined && !validateAge(data.age)) errors.push("Invalid age");
  return { valid: errors.length === 0, errors };
}

// After: validators.ts (Claude Code's conversion)
interface ValidationResult {
  valid: boolean;
  errors: string[];
}

interface UserData {
  name: string;
  email: string;
  age?: number;
}

function validateEmail(email: string): boolean {
  const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return regex.test(email);
}

function validateAge(age: number): boolean {
  return typeof age === "number" && age >= 0 && age <= 150;
}

function validateUser(data: UserData): ValidationResult {
  const errors: string[] = [];
  if (!data.name || data.name.length < 2) errors.push("Name too short");
  if (!validateEmail(data.email)) errors.push("Invalid email");
  if (data.age !== undefined && !validateAge(data.age)) errors.push("Invalid age");
  return { valid: errors.length === 0, errors };
}

Adding Python Type Hints

You: Add complete type annotations to all functions in app/services/.
Use modern Python typing (3.10+ syntax with | instead of Union).
Run mypy after each file to verify correctness.

Strategy 4: Framework Migration

Migrating between frameworks (Express to Fastify, Flask to FastAPI, React class to hooks) requires understanding both the old and new framework patterns.

Example: Flask to FastAPI

You: Migrate app/routes/users.py from Flask to FastAPI. Maintain the same
URL paths and response formats. Replace Flask-specific patterns with
FastAPI equivalents:
- @app.route -> @router.get/post/put/delete
- request.json -> Pydantic models
- jsonify() -> direct dict return
- abort() -> HTTPException
# Before: Flask
@app.route("/api/users", methods=["GET"])
def list_users():
    page = request.args.get("page", 1, type=int)
    limit = request.args.get("limit", 20, type=int)
    users = User.query.paginate(page=page, per_page=limit)
    return jsonify({"users": [u.to_dict() for u in users.items], "total": users.total})

@app.route("/api/users", methods=["POST"])
def create_user():
    data = request.json
    if not data.get("email"):
        abort(400, "Email is required")
    user = User(**data)
    db.session.add(user)
    db.session.commit()
    return jsonify(user.to_dict()), 201

# After: FastAPI (Claude Code's migration)
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, EmailStr

router = APIRouter(prefix="/api/users")

class CreateUserRequest(BaseModel):
    email: EmailStr
    name: str
    age: int | None = None

class UserResponse(BaseModel):
    id: str
    email: str
    name: str
    age: int | None

class UserListResponse(BaseModel):
    users: list[UserResponse]
    total: int

@router.get("", response_model=UserListResponse)
async def list_users(
    page: int = Query(1, ge=1),
    limit: int = Query(20, ge=1, le=100),
    db: AsyncSession = Depends(get_db),
):
    offset = (page - 1) * limit
    result = await db.execute(select(User).offset(offset).limit(limit))
    total = await db.scalar(select(func.count()).select_from(User))
    users = result.scalars().all()
    return {"users": users, "total": total}

@router.post("", response_model=UserResponse, status_code=201)
async def create_user(
    request: CreateUserRequest,
    db: AsyncSession = Depends(get_db),
):
    user = User(**request.model_dump())
    db.add(user)
    await db.commit()
    await db.refresh(user)
    return user

Safe Refactoring Practices with Claude Code

1. Always Have Tests First

Before refactoring, write tests for the current behavior of UserService.login().
Test all branches: successful login, wrong password, nonexistent user, locked account.

2. Refactor in Small Steps

Refactor one file at a time. After each file, run the test suite.
Do not proceed to the next file until all tests pass.

3. Use /compact Between Files

Long refactoring sessions generate a lot of context. Compact after every 3-5 files to stay within the context window.

4. Git Commit After Each Step

After each successful refactoring step, commit with a descriptive message.
This gives us rollback points if something goes wrong.

5. Verify with Tests, Not Assumptions

After refactoring, run the full test suite: npm test
If any test fails, fix the failure before moving on.

Measuring Refactoring Progress

Ask Claude Code to track refactoring metrics:

Analyze the codebase and report:
1. Number of files still using callback patterns
2. Number of untyped function parameters in src/services/
3. Lines of code in the largest files (identify files > 500 lines)
4. Cyclomatic complexity of the top 10 most complex functions

Conclusion

Claude Code is exceptionally well-suited for refactoring because it can hold both the old pattern and the new pattern in context simultaneously, applying changes systematically across many files. The key is working incrementally — one file or one pattern at a time — with tests as your safety net. Combined with git commits at each step, Claude Code transforms refactoring from a dreaded multi-sprint project into a methodical, low-risk process.

Share this article
N

NYC News

Expert insights on AI voice agents and customer communication automation.

Try CallSphere AI Voice Agents

See how AI voice agents work for your industry. Live demo available -- no signup required.