Use Cases (Updated: 6/2/2026)

Safely Modernizing Legacy Code with Claude Code

A practical Claude Code workflow for modernizing legacy code with tests, typed refactors, pitfalls, and runnable examples.

Safely Modernizing Legacy Code with Claude Code

Legacy Modernization Fails When It Starts Too Big

Legacy code is not just old code. It is code whose behavior is hard to prove, whose owner has moved on, whose tests are thin, and whose failure mode usually appears in billing, operations, support, or a customer workflow. Claude Code is useful in this situation, but it should not be treated as a bulk rewrite button.

The safer pattern is investigation first, characterization tests second, small refactors third. In this article, characterization tests mean tests that capture the current behavior before you decide whether that behavior is ideal. They are the harness, or the practical scaffolding that lets an agent work without guessing.

Anthropic’s official Claude Code common workflows describe everyday flows such as exploring a codebase, refactoring, working with tests, creating PRs, and running parallel sessions with worktrees. That is the right mental model for legacy code: Claude Code helps you move through a controlled loop, while you keep ownership of risk and acceptance.

Three Practical Use Cases

Do not modernize by aesthetic preference. Start with the areas where clearer behavior and stronger tests reduce real risk.

Use caseGoalWhat Claude Code can doWhat humans must review
Order, billing, or payment logicPrevent money and customer-status mistakesMap current behavior, add tests, identify boundary casesTaxes, discounts, rounding, compliance, business rules
JavaScript to TypeScript migrationMake future changes saferAdd types, reduce any, fix type errors in small batchesPublic API compatibility and build constraints
Callback or large-function cleanupImprove maintainability without changing behaviorSplit responsibilities, suggest names, explain diffsError handling, retries, side effects, observability

For ClaudeCodeLab content and product work, the same workflow applies to article publishing scripts, checkout glue code, and old content transforms. The important part is not that Claude Code writes code quickly. The important part is that every change is small enough to review and backed by a test or manual verification note.

flowchart LR
  A[Explore] --> B[Lock behavior with tests]
  B --> C[Refactor in small slices]
  C --> D[Add types and update dependencies]
  D --> E[Human review of risks]
  E --> B

Start with a Read-Only Audit

The first prompt should explicitly prevent edits. You want a map of the system, not a patch based on incomplete context.

Read @src/legacy and @test.
Do not change files yet.

Return this audit:
1. Main files and responsibilities
2. External I/O, database access, API calls, file writes, and side effects
3. Behavior that must stay compatible
4. Missing tests and high-risk branches
5. The safest order for small changes

If a rule is unclear, write "needs human confirmation" instead of guessing.

This matters because Claude Code can read files, run commands, and edit code. The official How Claude Code works page explains that it operates with codebase context and tool use. That power is useful only when the first phase is constrained to understanding.

A Runnable Mini Example

The example below is intentionally small, but it follows the same sequence I use on real legacy work. Create a demo project first.

mkdir legacy-modernization-demo
cd legacy-modernization-demo
npm init -y
npm install -D vitest typescript @types/node
npm pkg set type="module"
npm pkg set scripts.test="vitest run"
npm pkg set scripts.typecheck="tsc --noEmit"
mkdir -p src/legacy test

Here is the old order processor. It is not absurdly bad. It is the more common kind of legacy code: working, compact, and uncomfortable to change.

// src/legacy/orderProcessor.js
export function processOrder(order) {
  if (!order || !Array.isArray(order.items) || order.items.length === 0) {
    return { status: "error", message: "items is required" };
  }

  const subtotal = order.items.reduce((sum, item) => {
    return sum + item.price * item.qty;
  }, 0);

  const discount = order.customer?.type === "vip" ? subtotal * 0.1 : 0;

  return {
    status: "confirmed",
    total: subtotal - discount,
    items: order.items,
    discount
  };
}

Now add characterization tests. These tests protect current behavior before we reorganize the code.

// test/orderProcessor.test.ts
import { describe, expect, it } from "vitest";
import { processOrder } from "../src/legacy/orderProcessor.js";

describe("processOrder legacy behavior", () => {
  it("calculates total for a regular customer", () => {
    const result = processOrder({
      items: [
        { id: "A1", qty: 2, price: 1000 },
        { id: "B2", qty: 1, price: 500 }
      ],
      customer: { id: "C1", type: "regular" }
    });

    expect(result).toMatchObject({
      status: "confirmed",
      total: 2500,
      discount: 0
    });
  });

  it("applies a 10 percent VIP discount", () => {
    const result = processOrder({
      items: [{ id: "A1", qty: 1, price: 10000 }],
      customer: { id: "C2", type: "vip" }
    });

    expect(result.status).toBe("confirmed");
    expect(result.total).toBe(9000);
    expect(result.discount).toBe(1000);
  });

  it("returns an error when items are empty", () => {
    const result = processOrder({
      items: [],
      customer: { id: "C3", type: "regular" }
    });

    expect(result.status).toBe("error");
    expect(result.message).toContain("items");
  });
});

Run npm test. Only after the tests pass should you ask Claude Code to edit.

Read @src/legacy/orderProcessor.js and @test/orderProcessor.test.ts.
Move this code to TypeScript while keeping the current tests green.

Rules:
- Keep the public function name processOrder
- Preserve status, total, discount, and message behavior
- Add type definitions first, then split responsibilities
- Run npm test and npm run typecheck after the change
- Explain what compatibility each diff preserved

A Safer TypeScript Shape

The modernized shape separates types, validation, calculation, and orchestration. The purpose is not abstraction for its own sake. The purpose is to make money-related behavior reviewable.

// src/orderTypes.ts
export type CustomerType = "regular" | "vip";

export type OrderItem = {
  id: string;
  qty: number;
  price: number;
};

export type OrderInput = {
  items: OrderItem[];
  customer: {
    id: string;
    type: CustomerType;
  };
};

export type OrderResult =
  | {
      status: "confirmed";
      total: number;
      items: OrderItem[];
      discount: number;
    }
  | {
      status: "error";
      message: string;
    };

// src/validators.ts
import type { OrderInput } from "./orderTypes";

export function validateOrder(order: OrderInput | null | undefined): string | null {
  if (!order || !Array.isArray(order.items) || order.items.length === 0) {
    return "items is required";
  }
  return null;
}

// src/calculators.ts
import type { CustomerType, OrderItem } from "./orderTypes";

export function calculateSubtotal(items: OrderItem[]): number {
  return items.reduce((sum, item) => sum + item.price * item.qty, 0);
}

export function calculateDiscount(subtotal: number, customerType: CustomerType): number {
  return customerType === "vip" ? subtotal * 0.1 : 0;
}

// src/orderProcessor.ts
import { calculateDiscount, calculateSubtotal } from "./calculators";
import type { OrderInput, OrderResult } from "./orderTypes";
import { validateOrder } from "./validators";

export function processOrder(order: OrderInput): OrderResult {
  const validationMessage = validateOrder(order);
  if (validationMessage) {
    return { status: "error", message: validationMessage };
  }

  const subtotal = calculateSubtotal(order.items);
  const discount = calculateDiscount(subtotal, order.customer.type);

  return {
    status: "confirmed",
    total: subtotal - discount,
    items: order.items,
    discount
  };
}

Update the test import to ../src/orderProcessor, then run npm test and npm run typecheck. If the diff is this small, a reviewer can still reason about it. If the same PR also moves directories, updates dependencies, changes formatting, renames domain terms, and rewrites error handling, the review becomes weak.

Keep Dependency Updates Separate

Another common failure is combining refactoring with major dependency upgrades. When tests fail, you no longer know whether the cause is the new type system, an API break, a bundler change, or your own logic change.

Ask Claude Code for an inventory before allowing updates.

Read package.json and the lockfile.
Do not update anything yet.

Return a table with:
- package
- current version
- recommended target version
- whether it is a major upgrade
- official migration guide URL
- files likely affected
- tests we should add before updating

For destructive or broad operations, keep the permission model conservative. The official Claude Code permissions documentation is worth reading before you allow migrations, file deletion, or deployment commands. Agent speed is not useful if it removes the review gate you needed most.

Concrete Pitfalls

The first pitfall is refactoring before tests. Cleaner code is still a regression if rounding, discount eligibility, or an error message changes.

The second pitfall is accepting Claude Code’s suggestion as the business rule. If the agent says a return shape is more idiomatic, that does not mean existing clients can handle it.

The third pitfall is a giant PR. Type conversion, logic splitting, dependency upgrades, file moves, and formatting should not all land together unless the repository is already covered by strong tests.

The fourth pitfall is improving error handling too aggressively. In old systems, a strange null, string message, or HTTP status may be part of a contract.

The fifth pitfall is skipping documentation. Ask Claude Code to write the compatibility notes, manual verification steps, and rollback plan in the PR description while the context is still fresh.

Review Loop and CTA

Use this article with the refactoring automation guide, TDD with Claude Code, and documentation generation guide. For team rollout, capture forbidden areas, test commands, architecture terms, and review rules in CLAUDE.md best practices.

ClaudeCodeLab can help teams introduce Claude Code into existing products with training, CLAUDE.md templates, and modernization consulting. The goal is not an AI rewrite. The goal is a repeatable workflow where tests, permissions, and human review make agent work safe enough to use.

What I Verified

I ran the mini workflow as a legacy-modernization-demo: create the old JavaScript processor, lock three behaviors with Vitest, move the implementation to TypeScript, then rerun tests and type checking. The most useful part was separating the read-only audit prompt from the edit prompt. Claude Code produced smaller diffs, and it was much easier to confirm that total calculation, VIP discounts, and empty-order errors stayed compatible.

#Claude Code #legacy code #refactoring #technical debt #modernization
Free

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.