Skip to content

Refactoring Legacy Code Without Breaking Everything

Systematic refactoring strategies for legacy codebases: characterization tests, the strangler pattern and incremental extraction, without regressions.

4 min read
Before and after diagram showing legacy code being incrementally extracted into clean modules with a test safety net

Legacy code isn't old code. It's code without tests, code nobody understands, or code everyone is afraid to touch. The instinct is to rewrite from scratch, but rewrites fail more often than they succeed because they throw away years of encoded business logic—bug fixes, edge cases, and undocumented requirements baked into conditional branches nobody remembers writing.

The safer path is incremental refactoring: wrap the unknown in tests, extract the understandable parts, and shrink the legacy footprint over time.

Characterization Tests: Understanding What the Code Actually Does

Before changing legacy code, you need to know what it currently does—not what it's supposed to do. Characterization tests capture existing behavior, including bugs.

tstypescript
// ❌ Writing tests based on what you think the code should do
test("calculateDiscount returns 10% for premium users", () => {
  expect(calculateDiscount("premium", 100)).toBe(90);
});
// This test might fail because the actual code has a bug
// that gives premium users 15%. The bug might be a feature
// that sales promised to customers.
tstypescript
// ✅ Characterization tests: capture what the code actually does
function characterize(
  fn: (...args: unknown[]) => unknown,
  inputs: unknown[][]
): void {
  for (const args of inputs) {
    const result = fn(...args);
    console.log(
      `${fn.name}(${args.map(a => JSON.stringify(a)).join(", ")}) => ${JSON.stringify(result)}`
    );
    // Copy these outputs into test assertions
  }
}
 
// Step 1: Run with various inputs and record actual outputs
characterize(calculateDiscount, [
  ["premium", 100],
  ["premium", 0],
  ["standard", 100],
  ["standard", 50],
  ["", 100],
  [null, 100],
  ["premium", -10],
]);
 
// Step 2: Turn recorded outputs into tests
describe("calculateDiscount (characterization)", () => {
  test("premium 100 → 85", () => {
    expect(calculateDiscount("premium", 100)).toBe(85);
  });
  test("premium 0 → 0", () => {
    expect(calculateDiscount("premium", 0)).toBe(0);
  });
  test("standard 100 → 95", () => {
    expect(calculateDiscount("standard", 100)).toBe(95);
  });
  test("null tier → 100 (no discount)", () => {
    expect(calculateDiscount(null, 100)).toBe(100);
  });
  test("negative amount → -10 (no guard)", () => {
    expect(calculateDiscount("premium", -10)).toBe(-10);
  });
});
// These tests document reality, not intent

Characterization tests serve as a safety net. If any refactoring changes existing behavior, a test will fail—alerting you to investigate whether the behavior change is intentional before it reaches production.

The Seam Technique: Finding Safe Refactoring Points

A seam is a place where you can alter behavior without editing the code itself. Michael Feathers coined this term in "Working Effectively with Legacy Code." Seams are your entry points for inserting tests and extracting logic.

tstypescript
// Legacy function with embedded dependencies
function processOrder(orderId: string): void {
  // Direct database call — hard to test
  const order = db.query(
    `SELECT * FROM orders WHERE id = '${orderId}'`
  );
 
  // Business logic buried in the middle
  let total = 0;
  for (const item of order.items) {
    let price = item.price;
    if (item.category === "electronics" && order.memberTier === "gold") {
      price = price * 0.9;
    }
    if (item.quantity > 10) {
      price = price * 0.95;
    }
    total += price * item.quantity;
  }
 
  // Direct email service call
  emailService.send(order.email, `Your total is $${total}`);
 
  // Direct database update
  db.query(
    `UPDATE orders SET total = ${total} WHERE id = '${orderId}'`
  );
}
tstypescript
// Step 1: Extract parameters to create seams
function processOrder(
  order: Order,
  notify: (email: string, message: string) => void,
  save: (orderId: string, total: number) => void
): number {
  let total = 0;
  for (const item of order.items) {
    let price = item.price;
    if (item.category === "electronics" && order.memberTier === "gold") {
      price = price * 0.9;
    }
    if (item.quantity > 10) {
      price = price * 0.95;
    }
    total += price * item.quantity;
  }
 
  notify(order.email, `Your total is $${total}`);
  save(order.id, total);
  return total;
}
 
// Step 2: Now you can test the business logic
test("gold member gets 10% off electronics", () => {
  const order: Order = {
    id: "1",
    email: "test@example.com",
    memberTier: "gold",
    items: [
      { category: "electronics", price: 100, quantity: 1 },
    ],
  };
 
  const total = processOrder(
    order,
    () => {}, // stub notification
    () => {}  // stub persistence
  );
 
  expect(total).toBe(90);
});

Extract-Wrap-Delegate Pattern

For large legacy functions, extract business logic into a new clean module, wrap the old code to delegate to the new module, and verify behavior matches.

tstypescript
// Step 1: Extract the pricing logic into a clean module
interface PricingRule {
  applies: (item: OrderItem, order: Order) => boolean;
  calculate: (price: number) => number;
}
 
const pricingRules: PricingRule[] = [
  {
    applies: (item, order) =>
      item.category === "electronics" && order.memberTier === "gold",
    calculate: (price) => price * 0.9,
  },
  {
    applies: (item) => item.quantity > 10,
    calculate: (price) => price * 0.95,
  },
];
 
function calculateOrderTotal(
  order: Order,
  rules: PricingRule[]
): number {
  let total = 0;
 
  for (const item of order.items) {
    let price = item.price;
 
    for (const rule of rules) {
      if (rule.applies(item, order)) {
        price = rule.calculate(price);
      }
    }
 
    total += price * item.quantity;
  }
 
  return total;
}
tstypescript
// Step 2: Verify new module matches old behavior
function verifyEquivalence(testCases: Order[]): void {
  for (const order of testCases) {
    const oldResult = legacyCalculateTotal(order);
    const newResult = calculateOrderTotal(order, pricingRules);
 
    if (oldResult !== newResult) {
      console.error(
        `Mismatch for order ${order.id}: ` +
        `legacy=${oldResult}, new=${newResult}`
      );
    }
  }
}
 
// Step 3: Deploy behind a feature flag
function getOrderTotal(order: Order): number {
  if (featureFlags.isEnabled("new-pricing-engine")) {
    return calculateOrderTotal(order, pricingRules);
  }
  return legacyCalculateTotal(order);
}

Incremental Strangling of Legacy Modules

For larger refactoring efforts, the strangler fig pattern gradually replaces legacy modules by routing new traffic through new code while legacy handles existing paths.

tstypescript
interface MigrationTracker {
  module: string;
  totalEndpoints: number;
  migratedEndpoints: number;
  legacyEndpoints: string[];
  migratedOn: Map<string, Date>;
}
 
class StranglerRouter {
  private migrated: Set<string> = new Set();
  private tracker: MigrationTracker;
 
  constructor(module: string, totalEndpoints: number) {
    this.tracker = {
      module,
      totalEndpoints,
      migratedEndpoints: 0,
      legacyEndpoints: [],
      migratedOn: new Map(),
    };
  }
 
  markMigrated(endpoint: string): void {
    this.migrated.add(endpoint);
    this.tracker.migratedEndpoints++;
    this.tracker.migratedOn.set(endpoint, new Date());
  }
 
  route(
    endpoint: string,
    legacyHandler: () => unknown,
    newHandler: () => unknown
  ): unknown {
    if (this.migrated.has(endpoint)) {
      return newHandler();
    }
    return legacyHandler();
  }
 
  getProgress(): { percentage: number; remaining: string[] } {
    return {
      percentage:
        (this.tracker.migratedEndpoints / this.tracker.totalEndpoints) * 100,
      remaining: this.tracker.legacyEndpoints.filter(
        e => !this.migrated.has(e)
      ),
    };
  }
}

Safe Refactoring Checklist

tstypescript
interface RefactoringStep {
  step: string;
  verification: string;
  rollbackPlan: string;
}
 
const safeRefactoringProcess: RefactoringStep[] = [
  {
    step: "Write characterization tests for existing behavior",
    verification: "All tests pass against current code",
    rollbackPlan: "N/A — no code changes yet",
  },
  {
    step: "Extract testable interfaces (seams)",
    verification: "Characterization tests still pass",
    rollbackPlan: "Revert extraction commit",
  },
  {
    step: "Write unit tests for extracted logic",
    verification: "Unit tests match characterization test behavior",
    rollbackPlan: "Delete new tests, keep old code",
  },
  {
    step: "Implement new module alongside legacy",
    verification: "Run both, compare outputs for N days",
    rollbackPlan: "Feature flag to legacy path",
  },
  {
    step: "Route traffic to new module",
    verification: "Monitor error rates, latencies, business metrics",
    rollbackPlan: "Feature flag back to legacy",
  },
  {
    step: "Remove legacy code",
    verification: "All tests pass, monitoring stable for 2 weeks",
    rollbackPlan: "Git revert — legacy code still in history",
  },
];

Key Takeaways

Refactoring legacy code safely is about building confidence through testing before making changes. Start with characterization tests that document actual behavior, not intended behavior. Find seams where you can inject test doubles and extract logic. Use the extract-wrap-delegate pattern to build clean replacements alongside legacy code, comparing outputs in production before switching over. Feature flags give you instant rollback when something doesn't match. The goal isn't to make the code perfect—it's to make it slightly better with each change while never breaking existing functionality. Teams that refactor successfully are the ones that resist the rewrite temptation and instead shrink the legacy surface area steadily, one extracted module at a time.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX