You inherit a function that works but is cryptic. It has no tests, its documentation is a lie, and it’s buried in a tangle of global state and hardcoded dependencies. Refactoring it feels reckless—you have no safety net. How do you change it without breaking production?

Michael Feathers’ “Working Effectively with Legacy Code” frames the answer: characterization tests document what the code actually does (not what it should do), and seams let you isolate and control the code’s dependencies. Together, they give you a path from untested spaghetti to clean, safe code.

This article walks through the vocabulary and a concrete example: taking a tangled TypeScript function, writing a characterization test to lock in its behavior, identifying seams to inject test doubles, and refactoring piece by piece.

The Problem: No Tests, No Safety

Consider this function, discovered in a legacy codebase:

export function processOrder(orderId: string) {
  const order = global.db.getOrder(orderId);
  const customer = global.db.getCustomer(order.customerId);
  
  if (order.total > 100 && customer.memberSince < Date.now() - 365 * 24 * 60 * 60 * 1000) {
    const discount = order.total * 0.1;
    order.total = order.total - discount;
    global.db.updateOrder(order);
    global.logger.log(`Discount applied to order ${orderId}`);
  }
  
  global.email.send(customer.email, `Order ${orderId} processed for $${order.total}`);
  return order.total;
}

It works in production (somehow). But what is it doing? Applying a discount to high-value orders from long-time customers? Logging? Sending email? All three? If you change a single line, you might silently break the discount logic, the email, or the log—and you won’t know until a customer complains.

Without tests, every refactor is a guess.

Characterization Tests: Document Behavior, Not Intent

A characterization test doesn’t assert “this is correct”; it asserts “this is what the code does right now.” It’s a snapshot of behavior.

The first step is to isolate the function so you can call it in a test. That requires seams.

Seams: The Point of Control

A seam (Feathers’ term) is a place where you can change behavior without modifying the function itself. There are three main types:

1. Parameter seams: Change behavior by passing in a different argument.

2. Object seams: Inject a mock or stub object instead of relying on global state.

3. Link seams: Replace a function at import or module level.

Extracting Object Seams

Our function depends on global.db, global.logger, and global.email. These are seams—we can inject them. First, refactor to accept them as parameters:

interface Dependencies {
  db: {
    getOrder(id: string): any;
    getCustomer(id: string): any;
    updateOrder(order: any): void;
  };
  logger: {
    log(msg: string): void;
  };
  email: {
    send(to: string, subject: string): void;
  };
}

export function processOrder(
  orderId: string,
  deps: Dependencies
) {
  const order = deps.db.getOrder(orderId);
  const customer = deps.db.getCustomer(order.customerId);
  
  if (order.total > 100 && customer.memberSince < Date.now() - 365 * 24 * 60 * 60 * 1000) {
    const discount = order.total * 0.1;
    order.total = order.total - discount;
    deps.db.updateOrder(order);
    deps.logger.log(`Discount applied to order ${orderId}`);
  }
  
  deps.email.send(customer.email, `Order ${orderId} processed for $${order.total}`);
  return order.total;
}

// Production caller: pass the real globals
processOrder(orderId, {
  db: global.db,
  logger: global.logger,
  email: global.email
});

Now you can write a characterization test:

import { test, expect } from "vitest";

test("processOrder applies 10% discount to high-value old-customer orders", () => {
  const mockDb = {
    getOrder: () => ({
      id: "ord1",
      customerId: "cust1",
      total: 200
    }),
    getCustomer: () => ({
      id: "cust1",
      email: "[email protected]",
      memberSince: Date.now() - 366 * 24 * 60 * 60 * 1000 // 366 days ago
    }),
    updateOrder: (order) => {
      // Capture the updated order
      this.updatedOrder = order;
    }
  };

  let loggedMessage = "";
  const mockLogger = {
    log: (msg) => { loggedMessage = msg; }
  };

  let emailSent = "";
  const mockEmail = {
    send: (to, subject) => { emailSent = subject; }
  };

  const finalTotal = processOrder("ord1", {
    db: mockDb,
    logger: mockLogger,
    email: mockEmail
  });

  // Characterize: this is what it does
  expect(finalTotal).toBe(180); // 200 - 20 (10% discount)
  expect(mockDb.updatedOrder.total).toBe(180);
  expect(loggedMessage).toContain("Discount applied");
  expect(emailSent).toContain("Order ord1 processed for $180");
});

This test doesn’t judge the business logic; it locks it in. If you accidentally break the discount calculation, the test fails. You now have a safety net.

Sprout Method: Extract and Test New Code

Once you understand the code via characterization tests, you refactor. A common pattern: extract a piece of logic into a small, testable function.

The discount logic is complex—magic numbers, time calculations. Extract it:

function shouldApplyDiscount(order: any, customer: any): boolean {
  const oneYearAgo = Date.now() - 365 * 24 * 60 * 60 * 1000;
  return order.total > 100 && customer.memberSince < oneYearAgo;
}

function calculateDiscount(orderTotal: number): number {
  return orderTotal * 0.1;
}

export function processOrder(
  orderId: string,
  deps: Dependencies
) {
  const order = deps.db.getOrder(orderId);
  const customer = deps.db.getCustomer(order.customerId);
  
  if (shouldApplyDiscount(order, customer)) {
    const discount = calculateDiscount(order.total);
    order.total = order.total - discount;
    deps.db.updateOrder(order);
    deps.logger.log(`Discount applied to order ${orderId}`);
  }
  
  deps.email.send(customer.email, `Order ${orderId} processed for $${order.total}`);
  return order.total;
}

Now test the extracted functions in isolation:

test("shouldApplyDiscount for high-value old customers", () => {
  const order = { total: 200 };
  const customer = { memberSince: Date.now() - 366 * 24 * 60 * 60 * 1000 };
  expect(shouldApplyDiscount(order, customer)).toBe(true);
});

test("shouldApplyDiscount false for new customers", () => {
  const order = { total: 200 };
  const customer = { memberSince: Date.now() - 100 * 24 * 60 * 60 * 1000 };
  expect(shouldApplyDiscount(order, customer)).toBe(false);
});

test("calculateDiscount returns 10% of total", () => {
  expect(calculateDiscount(200)).toBe(20);
});

These new tests are unit tests, not characterization tests—they verify the extracted logic directly. The characterization test still covers the integration.

Wrap Method: Delegate Without Touching Internals

Sometimes you need to add behavior without understanding the existing code. Use the wrap method: create a new function that calls the old one and wraps its result.

Suppose you need to log all order processing:

export function processOrderWithLogging(
  orderId: string,
  deps: Dependencies
) {
  deps.logger.log(`Starting to process order ${orderId}`);
  const result = processOrder(orderId, deps);
  deps.logger.log(`Finished processing order ${orderId}, total: $${result}`);
  return result;
}

The original processOrder is untouched; the new logging wraps it. This is safe because you’re not changing the original behavior, only augmenting it.

A Larger Refactoring Walkthrough

Let’s refactor processOrder toward a cleaner design:

Step 1: Write characterization tests (done above).

Step 2: Extract business logic into pure functions (sprout method):

function calcOrderTotal(order: any, customer: any, deps: Dependencies): number {
  if (shouldApplyDiscount(order, customer)) {
    return order.total * 0.9; // 10% discount
  }
  return order.total;
}

Step 3: Separate concerns—fetch, calculate, update, notify:

export function processOrder(
  orderId: string,
  deps: Dependencies
): number {
  // Fetch
  const order = deps.db.getOrder(orderId);
  const customer = deps.db.getCustomer(order.customerId);

  // Calculate
  const finalTotal = calcOrderTotal(order, customer, deps);

  // Update
  if (finalTotal !== order.total) {
    order.total = finalTotal;
    deps.db.updateOrder(order);
    deps.logger.log(`Discount applied to order ${orderId}`);
  }

  // Notify
  deps.email.send(customer.email, `Order ${orderId} processed for $${finalTotal}`);

  return finalTotal;
}

Each phase is isolated and can be tested or replaced independently.

Step 4: Reconsider the dependencies object. It mixes data access, logging, and email. Split it:

interface OrderRepository {
  getOrder(id: string): any;
  getCustomer(id: string): any;
  updateOrder(order: any): void;
}

interface Notifier {
  log(msg: string): void;
  email(to: string, subject: string): void;
}

export function processOrder(
  orderId: string,
  repo: OrderRepository,
  notifier: Notifier
): number {
  const order = repo.getOrder(orderId);
  const customer = repo.getCustomer(order.customerId);

  const finalTotal = calcOrderTotal(order, customer);

  if (finalTotal !== order.total) {
    order.total = finalTotal;
    repo.updateOrder(order);
    notifier.log(`Discount applied to order ${orderId}`);
  }

  notifier.email(customer.email, `Order ${orderId} processed for $${finalTotal}`);
  return finalTotal;
}

Now the function has two seams: one for data, one for notifications. Tests are clearer, and responsibilities are separated.

Common Seam Patterns in the Wild

Global state seams. Many legacy systems reach for globals. Example: a logger initialized at startup.

// Before
console.log("Processing order...");

// After: inject the logger
function processOrder(orderId: string, logger: Logger) {
  logger.log("Processing order...");
}

// Test: pass a mock logger
const mockLogger = { log: (msg) => { this.logs.push(msg); } };
processOrder("123", mockLogger);
expect(mockLogger.logs).toContain("Processing order...");

Hardcoded dependencies. Functions that require() or import specific modules. Seam by extracting into a parameter.

// Before: hardcoded
const db = require("./database");

export function getUserWithPosts(userId) {
  const user = db.getUser(userId);
  const posts = db.getPosts(userId);
  return { user, posts };
}

// After: parameter seam
export function getUserWithPosts(userId, db) {
  // Same logic, now testable
}

Environment-specific behavior. Handling DEV vs PROD via environment variables.

// Seam it
function getDbUrl(env) {
  return env === "test" ? "sqlite:memory:" : process.env.DB_URL;
}

// Or inject directly
function initDb(dbUrl) {
  return new Database(dbUrl);
}

const db = initDb(process.env.NODE_ENV === "test" ? "sqlite:memory:" : process.env.DB_URL);

Why Characterization Tests Matter

A characterization test isn’t beautiful code. It’s not aspirational. It’s a snapshot: “the system does X, Y, Z when given this input.” But that snapshot is gold. It lets you refactor with confidence. If you break something, the test immediately catches it. Without it, you’re refactoring blind.

The tests also become documentation. Six months from now, when a junior developer asks “why does processOrder return an order with a 10% discount?”, you point to the test. The test answers the question by example.

Key Takeaways

  • Characterization tests lock in current behavior without judging correctness; they’re your safety net for refactoring untested code.
  • Seams are injection points—parameter, object, or link—that let you control behavior in tests without modifying the function itself.
  • Extract pure functions (sprout method) to test new logic in isolation before integrating it into the legacy code.
  • Wrap methods add behavior around untouched existing functions; use them when you can’t yet refactor the original.
  • Separate concerns as you refactor: fetch, calculate, update, notify. Each concern becomes its own seam.
  • Start small: write one characterization test, extract one pure function, refactor one seam at a time.
  • Don’t aim for perfection immediately. Legacy code refactoring is incremental; each safe change improves the design and your confidence.