Seams
A seam is a place where you can change a unit's behaviour without editing it there — by injecting a clock, repository, or parameter — so stubborn legacy code becomes testable through a small local change instead of a risky rewrite.
You need to put a test around a five-year-old billing function before you touch it. You open it and the floor falls away: it calls new Date() halfway down, reads a global config singleton, and writes to the database directly. There is no way to ask “what does this return on the last day of February?” without actually waiting for that day, mutating shared state, or standing up a database. The function works in production — but it is welded to the real world, and you can’t get a grip on it.
The instinct is to rewrite it: extract a service layer, introduce interfaces everywhere, restructure the module. That is often the wrong move and a real risk — you’d be changing code you don’t yet have a test for. Michael Feathers’ answer is smaller and surgical: find a seam — a place where you can change behaviour without editing in that place — and use it to slip the code under test first. Then refactor with a net.
After this lesson you can define a seam as a point where behaviour can be swapped without editing there, plus its enabling point; recognise the three classic seam types (object, parameter, and language/link seams) and pick the least invasive one; convert a hard-wired new Date(), global, or direct DB call into a seam so the unit becomes testable; and explain why spotting seams — not just doing dependency injection — is the core skill of taming legacy code, and when a seam is the wrong tool.
A seam is a place where you can alter behaviour without editing in that place. That definition (from Feathers’ Working Effectively with Legacy Code) is precise and worth holding exactly. The point of a seam is not “good design” in the abstract — it is leverage: a spot where you, the tester, can substitute a different behaviour at runtime without touching the lines that do the work. Every seam has an enabling point — the place where you decide which behaviour is active (a constructor argument, a parameter, a build flag).
Hard-wired code has no seams. When a function literally contains new Date() or Database.query(...), there is no place to stand outside it and say “use this instead.” The whole game of getting legacy code under test is: find or create a seam, then exploit it.
The most common seam is the object seam: inject the collaborator instead of constructing it. Take a unit hard-wired to the clock:
// no seam — behaviour is welded to the real clock
function isTrialExpired(user: User): boolean {
const now = new Date(); // ← can't be substituted from a test
return now.getTime() > user.trialEndsAt.getTime();
}You cannot test “expired” vs “active” deterministically: the answer depends on the wall clock. Introduce an object seam by passing the dependency in:
interface Clock { now(): Date }
const systemClock: Clock = { now: () => new Date() };
function isTrialExpired(user: User, clock: Clock = systemClock): boolean {
return clock.now().getTime() > user.trialEndsAt.getTime();
}The seam is the clock parameter; the enabling point is the call site (production passes nothing and gets systemClock; the test passes { now: () => new Date('2026-01-01') }). Note the default keeps every existing caller working untouched — the change is additive, not a rewrite. This is exactly dependency injection, but DI is just one kind of seam.
Seams come in kinds, and the cheapest one wins. Feathers classifies them by where the enabling point lives. Knowing the menu lets you pick the smallest cut:
- Object seam — swap a collaborator (the
Clock/Repositoryabove). Enabling point: constructor or parameter. Best default in TS. - Parameter seam — pass the varying value itself rather than a whole object:
isTrialExpired(user, now: Date). Even smaller than injecting aClockwhen you only need one reading. - Subclass-and-override seam — wrap the hard-wired call in a
protectedmethod, then override it in a test subclass. Useful when you can’t change the signature (it’s a framework hook, or too many callers). - Link/language seam — substitute at module-resolution or build time (a test double for an imported module, e.g. mocking
./dbin the test runner). The enabling point is outside the source entirely.
The senior move is to reach for the least invasive seam that unblocks the test, not the most architecturally pure one. A parameter seam you add in thirty seconds beats an interface hierarchy you spend a day on.
The same trick tames globals and direct I/O — the seam is wherever the unit reaches outside itself. A global config singleton and a direct db.query are the same problem as new Date(): the unit names a concrete, real-world thing instead of accepting an abstraction.
// no seam — reads a global and hits the real DB
async function chargeOverdue(): Promise<number> {
const limit = GlobalConfig.get('overdueLimit'); // global
const rows = await db.query('SELECT * FROM invoices WHERE overdue = true'); // real DB
return rows.filter((r) => r.amount > limit).length;
}// seams: config and repository are passed in
interface InvoiceRepo { listOverdue(): Promise<Invoice[]> }
async function chargeOverdue(repo: InvoiceRepo, limit: number): Promise<number> {
const rows = await repo.listOverdue();
return rows.filter((r) => r.amount > limit).length;
}Now a test passes a repo returning three fixed invoices and a limit, and asserts on the count — no database, no global. The function’s logic (the filter and count) was always the interesting part; the seams just let you reach it. Notice we did not “design a persistence layer” — we extracted exactly the two dependencies the test needed, and stopped.
Unblock a stubborn unit with the smallest possible seam. A legacy function decides whether to send a “renew now” nudge. It is welded to the clock and to an email gateway:
// before — untestable: real clock, real network
function maybeSendRenewalNudge(sub: Subscription): void {
const daysLeft =
(sub.expiresAt.getTime() - Date.now()) / 86_400_000;
if (daysLeft <= 7 && daysLeft > 0) {
EmailGateway.send(sub.email, 'Renew now'); // real send
}
}You can’t assert “sends at 7 days, silent at 8” without time-travelling and without firing a real email. The tempting overreaction is to introduce a NotificationService interface, a ClockProvider, a DI container, and rewire the module. None of that is needed to get the test. Add exactly two seams — a parameter seam for the time and an object seam for the gateway — both with defaults so callers don’t break:
// after — two small, additive seams
interface Mailer { send(to: string, subject: string): void }
const liveMailer: Mailer = { send: EmailGateway.send };
function maybeSendRenewalNudge(
sub: Subscription,
mailer: Mailer = liveMailer,
now: number = Date.now(), // parameter seam: smaller than a Clock object
): void {
const daysLeft = (sub.expiresAt.getTime() - now) / 86_400_000;
if (daysLeft <= 7 && daysLeft > 0) {
mailer.send(sub.email, 'Renew now');
}
}Now the test is trivial and deterministic:
test('nudges inside the 7-day window, stays silent outside it', () => {
const sent: string[] = [];
const spy: Mailer = { send: (to) => sent.push(to) };
const now = new Date('2026-06-01T00:00:00Z').getTime();
const inWindow = { email: 'a@x.com', expiresAt: new Date('2026-06-05T00:00:00Z') };
const outWindow = { email: 'b@x.com', expiresAt: new Date('2026-06-20T00:00:00Z') };
maybeSendRenewalNudge(inWindow, spy, now);
maybeSendRenewalNudge(outWindow, spy, now);
expect(sent).toEqual(['a@x.com']); // exactly one, the in-window one
});Production call sites are unchanged: maybeSendRenewalNudge(sub) still uses the live mailer and the real Date.now(). The whole edit is local to one function signature. With the test green, you’ve earned the right to refactor the body freely — the net is in place. That is the seam workflow: seam → characterization test → refactor, never refactor → hope.
▸Why this works
Why is “spotting seams,” not “do dependency injection,” the actual skill? Because DI names only the object-seam case, and it implies you get to redesign construction — which on real legacy code you often can’t. A seam is the broader idea: any lever that lets you vary behaviour without editing the work. Sometimes that lever is a parameter default, sometimes a protected method you override in a test subclass, sometimes a module-level mock at the link seam — no constructor or container in sight. Senior engineers taming legacy code scan a stubborn unit and ask “where is the cheapest place I can already substitute behaviour, or open one with a tiny change?” The answer is frequently not the textbook DI rewrite, and recognising that is what keeps a one-hour task from becoming a one-week one.
▸Common mistake
The classic failure mode: reaching for a large, invasive rewrite to add testability when a small local seam would have unblocked the test. You see new Date() and db.query and start designing a ClockProvider interface, a repository layer, an adapter per table, and a DI container — restructuring code that has no tests yet, which is exactly the change you have no safety net for. You can spend a day, introduce new bugs, and still not have the one test you actually came for. Invert it: add the minimum seam (a defaulted parameter, an injected collaborator on one function), write the characterization test, get to green, then decide whether the bigger refactor is even worth it. Often, once the unit is testable, the grand redesign turns out to be unnecessary — the seam was the whole win.
A legacy function calls new Date() and a global config singleton, and it has no tests. You need to unit-test its branching logic. What is the senior first move?
A seam is a place where you can change a unit’s behaviour without editing in that place, controlled at an enabling point — and finding seams is the core skill of getting stubborn legacy code under test. Hard-wired calls like new Date(), a global singleton, or a direct db.query have no seam, so they can’t be substituted from a test; you introduce one by injecting a collaborator (object seam), passing the value (parameter seam), overriding a protected method (subclass-and-override), or replacing a module at link time. The senior judgment is to pick the least invasive seam — usually a defaulted parameter or one injected dependency, kept additive so existing callers don’t break — and the failure mode to avoid is launching a large architectural rewrite to add testability before you have a single test. Workflow: seam → characterization test → refactor, in that order, so every later change is made with a net.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.