LegacyGENERALLANGUAGE-SPECIFICCONTESTED

Seams

A seam is a place where you can change behaviour, or substitute a dependency, without editing the code at that place. Finding one is what makes untestable code testable.

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.

The question

This code reaches out to the network, the clock and the database from inside a nested loop. Where can I get a grip on it without rewriting it?

The requirement

A shipping-cost function must be brought under test before a new carrier is added. It calls a carrier HTTP client directly, reads new Date() twice, and writes an audit row.

The obvious build

Just mock it. Reach for a library that patches the module at runtime — monkeypatch the HTTP client, freeze the global clock — and test the function exactly as it is, without touching production code at all.

Why it breaks

It works, and it is genuinely the right first move often enough to be worth knowing. But the test now depends on the module's internal import graph: it knows which client the function imports and from where.

How it breaks as requirements change
  • It works, and it is genuinely the right first move often enough to be worth knowing. But the test now depends on the module's internal import graph: it knows which client the function imports and from where.
  • The first restructuring you do — the one the tests were written to protect — moves that import, and every patched test fails for reasons that have nothing to do with behaviour. The net dissolves precisely when you start using it.
  • As requirements arrive, the patches multiply. A test file that patches six modules is asserting a great deal about implementation and very little about behaviour (Mocking).
  • And nothing about the production code improved: the dependency is still invisible in the signature, so the next engineer discovers the network call by watching a test suite time out.
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • The function is called from eleven places, so its signature is expensive to change; anything that touches all eleven callers is a bigger change than the one we are trying to make safe.
  • The language is TypeScript with no dependency-injection container, and introducing one is out of scope.
  • The team has agreed that no behaviour changes in this step (What Refactoring Actually Is).
  • There is no test suite yet, so every edit made to create a seam is itself unprotected.
Invariants
  • Creating a seam must not change production behaviour: the default path through the new seam must be exactly the old path.
  • A seam must have an *enabling point* — somewhere outside the code under test where the substitution is chosen. A substitution you can only make by editing the code is not a seam.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • The seam owns one thing: making a substitution possible. It does not own choosing the substitute — that belongs to the enabling point.
  • The caller, or a composition root, owns supplying the real dependency in production (Wiring and the Composition Root).
  • The code under test stays responsible for the behaviour, and after the seam is introduced it should be responsible for strictly less: not for constructing its own dependencies.
Boundaries
  • A seam falls at a point of *variation you want*, not at every dependency. Every seam is an extra parameter, an extra indirection, and a small permanent tax on readers (What an Abstraction Costs).
  • The best seams sit at the edges of the deterministic core: time, randomness, network, storage, and nothing else (A Deterministic Core).
  • A seam introduced inside a nested loop for testing convenience is usually a sign the loop wanted extracting instead (Extract Function).

The cheapest seam that survives what you are about to do

The four seam types are not a quality ranking. They differ in what they cost to introduce, what they cost to keep, and how much restructuring they survive — and the right answer changes as the work progresses.

A useful pattern is to start with the cheapest seam that gets a characterization net in place, then upgrade the ones that turn out to be load-bearing once the net makes upgrading safe.

You need to substitute the carrier HTTP client. Which seam?

How much restructuring must this seam survive, and how much can you afford to edit right now?

Parameter seam — defaulted argument

when You can edit the signature, the dependency is small, and you want the net today.

cost One extra parameter visible to every reader forever, and a default that can be taken accidentally in production. Cheapest to introduce, weakest structurally.

Extract-and-override

when You cannot change the signature, the language has inheritance, and the call site is buried inside a method.

cost An inheritance relationship that exists only for tests. Fast, effective, and something you will want to delete once the refactor is done.

Object seam — inject an interface

when The substitution will outlive the refactor, or a second real implementation is arriving.

cost Touches construction sites, forces you to name the role, and adds an abstraction that must be justified by more than testing (Premature Abstraction).

Link or module seam

when Any edit to the file is frightening, or the dependency is a whole library rather than a call.

cost Invisible in the source and coupled to build configuration. Excellent for the first afternoon, painful as a permanent arrangement.

No seam — hoist the I/O to the caller

when The function is small enough to make pure, and you already have a net around the caller.

cost A larger change now, and it is the one option that is not available at the start. It is where the others are heading (Side Effects).

What a parameter seam actually looks like

The mechanics are unglamorous, which is why the technique is underused: it looks too small to be a technique. The discipline is in the default — production must take exactly the path it took before, and that must be obvious by inspection rather than by argument.

Notice what the seam does beyond enabling the test: it makes two hidden dependencies visible in the signature. Anyone reading the function now knows it talks to a clock and a carrier, which they previously had to discover by reading the body.

Before, and the smallest edit that creates a grip
1// before — two hidden dependencies, no enabling point
2export function shippingCost(order: Order): Money {
3 const quote = carrierClient.quote(order.weight, order.zone) // network
4 const surcharge = new Date().getUTCDay() === 0 ? 500 : 0 // clock
5 return quote.amount + surcharge
6}
7
8// after — same behaviour, two parameter seams, eleven callers untouched
9export function shippingCost(
10 order: Order,
11 quoter: (w: number, z: Zone) => Quote = carrierClient.quote,
12 now: () => Date = () => new Date(),
13): Money {
14 const quote = quoter(order.weight, order.zone)
15 const surcharge = now().getUTCDay() === 0 ? 500 : 0
16 return quote.amount + surcharge
17}

The defaults are the invariant: omit both arguments and this is the original function, byte for byte. That is what lets the edit be made against code with no tests — the change is verifiable by reading it, which is the only verification available before the net exists.

Seams point at the module that was already there

After a few seams, a pattern usually appears: the same cluster of dependencies keeps needing substitution together, and always at the same boundary. That cluster is not an accident of testing. It is the edge of the deterministic part of the system, and it was there before anyone wrote a test.

This is the sense in which testability is design feedback rather than a tax. The difficulty of getting a grip on a function is a measurement of how much of the outside world it dragged into itself (Testing as Design Feedback).

  • Two seams, both at the boundary between a decision and an effect. That is where seams usually want to be (Effect Boundaries).
  • The enabling point is outside the function. Without it there is no seam, only a differently shaped dependency.
  • If a third seam is needed inside the decision itself, the decision is doing too much and wants splitting rather than another parameter (Single Responsibility, Carefully).
Seams surface the boundary between decision and effect
orderproduction defaultproduction defaultenabling pointenabling pointCaller (11 sites)Test supplies bothshippingCost — pure decisionquoter seamclock seamCarrier HTTPSystem clock
UserLLMAgentToolDataDecisionHumanGuardrail

How to build it

Most important first.

  • Parameter seam. Add a defaulted parameter for the dependency: now = () => new Date(). Zero callers change, production behaviour is identical, and a test passes its own. This is the cheapest seam that exists and should be tried first.
  • Extract-and-override. Extract the awkward call into a protected or overridable method, then subclass in the test and override it. Ugly, effective, and available in languages where you cannot change signatures freely; it costs you an inheritance relationship you will want to remove later (When Inheritance Fits).
  • Object seam. Introduce an interface for the dependency and inject it at construction. The most durable of the three and the most invasive: it changes construction sites and forces you to name the role the dependency plays (Constructor Injection).
  • Link or module seam. Substitute at the build or module-resolution level — a test double module, a different linked implementation, a container image with a stub service. Requires no production code change at all, which is its whole appeal in code where any edit is frightening.
  • Choose the *cheapest seam that survives the refactoring you are about to do*. That is the whole selection criterion, and it is why link seams are good for the first hour and bad for the sixth month.
  • Introduce the seam, run the code, and confirm production behaviour is byte-identical before writing a single assertion. The seam edit is the riskiest thing in this lesson because nothing is protecting it yet.

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • Introducing the first seam costs an hour and unlocks the characterization suite, which is what actually makes the next change cheap (Characterization Tests).
  • After: adding the new carrier costs one implementation of the injected interface plus one test, with no edits to the shipping-cost logic at all — that is the change the seam was bought for.
  • What stays expensive: any change that needs a *different* axis of substitution. A seam at the HTTP client does nothing for a change that needs to substitute the persistence, and adding a second seam is another hour and another parameter.
  • The permanent cost is per-reader: everyone who reads the function afterwards sees an extra parameter whose only purpose, on the production path, is to be defaulted. Multiply by every reader for the life of the code.
What the recommended approach costs
  • Every seam is indirection paid for by every reader forever, in exchange for substitutability used by a handful of tests. That trade is good at the edges of the system and bad in the middle of it.
  • Cheap seams are cheap because they are structurally weak: a defaulted parameter is invisible to anyone reasoning about the dependency graph, and tooling cannot see it.
  • Introducing a seam is an edit to unprotected code — the one thing this module says to avoid — and there is no way around that. It is the bootstrap problem, and the answer is to keep the edit mechanical and small enough to review by eye.

What can go wrong

Failure modes
  • Seams are added everywhere "for testability" and the module acquires nine constructor parameters, at which point testing it requires more setup than it saves (Long Parameter List).
  • The defaulted parameter is used in production by accident — someone calls the function without the argument in a path where the real dependency was required — and the default silently does the wrong thing.
  • The link seam works locally and not in CI, because module resolution differs, and a day disappears into build configuration.
  • The mitigation fails on its own terms: extract-and-override leaves an inheritance hierarchy created purely for tests, which then constrains the design it was supposed to liberate.
Dependencies, and their direction
  • A parameter seam makes the dependency visible in the signature, which is the point: it converts a hidden dependency into a declared one (Hidden Global State).
  • An object seam adds a dependency on an abstraction and inverts the direction, which is only worth it where more than one implementation genuinely exists or will (Dependency Inversion).
  • A link seam adds a dependency on your build system's resolution rules, which is invisible in the source and therefore easy to get wrong at 2am.
Misreads
  • "A seam means an interface." Interfaces are one kind of seam and the most expensive. A defaulted parameter, a subclass override and a build-level substitution are all seams, and the first is right far more often than the domain's folklore suggests (Dependency Injection).
  • "So every dependency should be injected." Injecting everything produces constructors that are inventories, and makes the dependency graph harder to read rather than easier (Over-Decomposition).
  • "Monkeypatching is always wrong." It is a link seam with excellent ergonomics, and for a one-afternoon characterization pass it is frequently the correct tool. It becomes wrong when the tests it enables must survive the restructuring that follows.
  • "Finding a seam is the hard part." Finding one is usually easy. Choosing the cheapest one that survives what you are about to do is the judgement, and it is the thing this lesson is actually about.
Smells this explains
  • long-parameter-list

Testing it, and how it ages

What to test, and at which boundary
  • The first test through a new seam should be a characterization test at the coarsest level the seam permits — the seam exists to enable the net, not to be the net.
  • Assert that the production default is exercised when the parameter is omitted, or the seam becomes a way for tests to pass while production takes another path entirely.
  • Prefer a real, simple substitute — an in-memory implementation, a fixed clock — over an expectation-based mock, so tests do not encode the call sequence (Test Doubles, Precisely).
How this design ages
  • Cheap seams should be upgraded once they are load-bearing: a defaulted parameter that four tests and two production paths rely on has become an implicit interface and deserves an explicit one.
  • Extract-and-override seams should be removed after the refactor they enabled. They are ladders, and leaving them up creates an inheritance hierarchy the domain would not otherwise justify (Composition Over Inheritance).
  • Seams tend to reveal the module boundary that was always there. If the same three dependencies keep needing substitution together, that group is the seam, and it wants to be a module (Extract Module).

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • GENERALThat substitution requires an enabling point outside the code being substituted is structural, and true wherever code is composed at all.
  • LANGUAGE-SPECIFICThe cheap seam differs by language and this changes the advice materially: Python and Ruby make module-level patching trivial so link seams dominate; Java and C# make object seams idiomatic and patching awkward; C++ often leaves link-time substitution as the only option that does not require touching headers; Go's implicit interfaces make an object seam nearly free at the consumer side. A lesson that recommends one seam universally is describing its author's language.
  • CONTESTEDThe strongest opposing view: seams introduced for testing are production complexity paid for by a non-production concern, and a design where the awkward dependency was simply *not there* — a pure function handed data, with I/O at the caller — needs no seam at all. That position is right, and it is the destination; the disagreement is about the route, because you cannot get to a functional core from unprotected legacy code without a seam to hold onto on the way (Functional Core, Imperative Shell).

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • Programming Languages & Runtime Internals — which seams are cheap is decided by the language's linking and module resolution: dynamic dispatch, monkeypatching, link-time substitution and interface satisfaction are all runtime facilities before they are design techniques.