Guide

Legacy methods often construct collaborators with new deep inside the body. That wiring makes tests impossible: you cannot swap Stripe, the clock, or the tax table. A seam (Feathers) is a place you can alter behavior without editing that hard spot — an injected interface, a subclass override, a link seam.

The first refactor is usually not a better algorithm. It is dependency breaking: introduce an interface, inject a fake in tests, keep the production constructor as the default.

Concept

A characterization test pins today's output so a seam change cannot drift. Write those pins first, then open the seam. See characterization tests.

The big idea

Think of a sealed engine with no dipstick. You cannot measure oil without cutting metal. A seam is the dipstick hole: a small opening that lets you observe or replace one part. The analogy breaks when the "part" is a global or a static — those have no hole until you cut one.

Walking through it

TypeScript
// Before: hard-wired, untestable
function charge(order: Order) {
  const stripe = new StripeClient(process.env.STRIPE_KEY!)
  return stripe.charge(order.totalCents)
}

// After: seam via injection
function charge(order: Order, payments: Payments = defaultPayments()) {
  return payments.charge(order.totalCents)
}

Production still works with no extra ceremony. Tests pass a fake Payments. You have not rewritten charging. You have a place to stand.

The same move unlocked the 2,400-line PricingEngine: extract a pure computeLineItems and inject the clock and tax table. After that, tiny refactors and a strangler become possible.

When you estimate this work, quote a range with the hill named: "two days if seams exist, two weeks if we must introduce seams and fixtures first." Getting to testable is often the real project. Put seam work on the board as its own deliverable — tests passing on a previously untestable module — so it is visible progress.

Common mistakes

  • Rewriting the algorithm in the same change that opens the seam. Two reasons to fail, one diff.
  • Injecting everything. One collaborator that blocks tests is enough for the first cut.
  • Removing the production default too early. Callers should keep compiling while you add the seam.
  • Calling the module testable because you mocked the world. A seam should let a fake or real double obey the same interface.

Check yourself

  1. charge constructs StripeClient internally. What is the smallest seam? Inject a Payments collaborator, with the real Stripe client as the default.

  2. Why budget seam work as its own deliverable? Getting to testable is often the project. The feature you came for gets cheap after the hill.

Key takeaways

  • A seam lets you change or test behavior without editing the hard spot.
  • Break dependencies first: interface, inject, keep the production default.
  • Do not rewrite the algorithm until you have a place to stand.
  • Estimate the hill of making the code testable, not only the feature after it.

Further learning

Next, change structure in tiny always-green steps.

Working Effectively with Legacy Code (Michael Feathers)In this book, Michael Feathers offers start-to-finish strategies for working more effectively with large, untested legacy code bases. This book draws on material Michael created for his renowned Object Mentor seminars…informit.com