Software Engineering · Refactoring

Seams: The Places Where Behavior Can Be Substituted

A seam is a point where an edit can change behavior without editing at that point. Link seams, object seams, and preprocessor seams cost different amounts and fail differently.

A test needs to control the environment a piece of code runs in: the clock, the network, the filesystem, the current user. Code that reaches for those directly cannot be tested, and the standard advice is to “inject the dependency.” That advice names a solution without naming the problem. The problem is that the code has no point at which substitution is possible, and the fix is to create one.

The unit of that creation is the seam: a place where behavior can be altered without editing the code at that place.

Two properties a seam must have

The definition has two halves, and both are load-bearing. There must be a point where a substitution can be made, and making it must not require editing the code being tested. The second half is what distinguishes a seam from a parameter.

A function that takes a clock as an argument has a parameter, not a seam, if the caller passes the real clock. The seam exists at the call site: the test can pass a fake clock there without touching the function. Where the caller is itself untestable, the seam has moved the problem rather than solved it, and the next seam must be found further out.

Feathers classifies seams by the mechanism used to substitute at them, and the classification is useful because the mechanisms have different costs and different failure modes.

A link seam replaces a symbol at link time. The code under test calls wall_clock_now, declared in a header and defined in one object file; the test binary links a different definition of the same symbol and never links the production one. Nothing in the source of the code under test changes.

/* test_stub.c: provides the definition used by the test link. The
   production object file holding the real wall_clock_now is omitted. */
#include <stdint.h>
#include "clock.h"

static int64_t fake_now = 1700000000;

int64_t wall_clock_now(void) { return fake_now; }

void test_set_time(int64_t t) { fake_now = t; }

The mechanism is the linker’s symbol resolution: exactly one definition of wall_clock_now is present in the test link, and the reference in the code under test binds to it. It works for C and for anything with a C-compatible linkage model, which is most languages’ foreign function boundary.

The cost is that the substitution is invisible in the source. A reader of the production code cannot tell that the function is replaced under test, and a reader of the test cannot tell which symbols are replaced without inspecting the build. Both directions of comprehension suffer, and the build configuration becomes load-bearing in a way that is easy to break silently.

Link seams also interact badly with inlining and with whole-program optimization. If the compiler inlines the call or resolves it at compile time, the link-time substitution has nothing to replace. The same substitution that works in an unoptimized test build can vanish in a release build, which means the seam’s presence depends on compiler flags.

Object seams: substitution through a reference

An object seam substitutes an implementation behind a reference the code already holds. The code calls clock.now() on an interface; the test passes an object whose now() returns a fixed instant. No linker involvement, no build configuration, and the substitution is visible in the test’s construction of the object.

from typing import Protocol

class Clock(Protocol):
    def now_epoch(self) -> int: ...

class SystemClock:
    def now_epoch(self) -> int:
        import time
        return int(time.time())

def is_expired(token, clock: Clock) -> bool:
    return clock.now_epoch() > token.expires_at

The seam is the clock parameter. It exists because the function receives a reference rather than reaching for a global. Creating it in code that does not have it is the refactoring: introduce a parameter, move the construction of the real implementation to the caller, and let the default at the outermost layer supply SystemClock.

The cost is a change in every caller, propagating outward until a boundary is reached where the real implementation can be constructed once. In a deep call chain this is the entire difficulty: the parameter must be threaded through frames that do not use it, which is the problem that dependency-injection frameworks exist to solve by holding the graph in one place.

Object seams have a failure mode of their own: a substituted object may not honor the contract the code assumes. A fake clock that returns a constant is fine. A fake repository that returns an object with uninitialized fields may satisfy the type signature and violate an invariant the real implementation guarantees, and the test then exercises a code path that cannot occur.

Interface width

The narrower the interface at the seam, the cheaper the fake. A seam over a full database client requires faking query construction, transactions, and error mapping. A seam over a function that returns the three values the code actually needs requires faking three values.

This is why the extraction in Extract Function and the Preconditions the Compiler Checks so often precedes testability work: the extracted function is a narrow interface, and the narrow interface is a cheap seam. Widening an interface to make it injectable is the wrong direction; narrowing what the code needs from the dependency is the right one.

Preprocessor seams and their limits

In C and C++, the preprocessor provides a seam that costs nothing at runtime: a macro or conditional-compilation branch can substitute a definition for the test build.

#ifdef TESTING
#define NOW() (fake_now())
#else
#define NOW() (time(NULL))
#endif

The advantage is that no signature changes and no callers move. The disadvantage is that the substituted code is a different program. The test build and the release build compile different source, so a defect in the TESTING branch is invisible in the release build and vice versa. Any seam whose mechanism is conditional compilation doubles the surface that must be correct, and the halves are not exercised together.

The same objection applies to substitution through build tags or configuration flags that select different implementations. The substitution is real and the seam is real; what is lost is the guarantee that the tested configuration is the shipped one.

Choosing among them

The three mechanisms are not equivalent, and the choice is a trade in three dimensions.

MechanismSource visibilityCall-site churnSurvives optimization
Link seamNone; substitution lives in the buildNoneNo, if the call is inlined
Object seamExplicit at the parameterEvery caller to the construction boundaryYes
Preprocessor seamVisible but conditionalNoneYes, but tests a different program

The object seam is the default choice in code that can absorb the call-site churn, because it keeps the substitution legible. The link seam is the fallback where call sites cannot be touched, typically third-party code or a very wide existing API. The preprocessor seam is a last resort, useful mainly where the language has no other mechanism.

There is a fourth option that is often better than all three: move the decision out of the code under test entirely. If the function does not need to know the current time, it should receive the value that was computed from the current time. That converts a dependency into data, and data needs no seam at all.

Seams are not free

Every seam is an indirection that exists to serve testability, and each one adds a layer that a reader must traverse to understand what actually happens at runtime. A codebase with a seam in front of every dependency is a codebase where tracing a call requires knowing the wiring, and where the wiring is the thing most likely to be wrong.

The discipline that keeps this bounded is to place seams at the points where behavior genuinely varies, and to use real implementations everywhere else. A seam that only ever has one implementation in the deployed system and one in tests is a seam that exists for tests, which is acceptable but should be counted, because the count is a direct measure of how much of the design is shaped by its verification rather than by its use.

Seams also determine what a characterization suite can observe. A test can only capture behavior at a point it can reach, and the reachable points are exactly the seams. That connection is why seam creation usually precedes the recording described in Characterization Tests as a Refactoring Safety Net, and why a refactoring that removes seams makes subsequent changes harder even when it simplifies the code.

References

  1. Working Effectively with Legacy Code — Michael C. Feathers, Prentice Hall, 2004
  2. Design Patterns: Elements of Reusable Object-Oriented Software — Gamma, Helm, Johnson, Vlissides, Addison-Wesley, 1994
  3. The Art of the Metaobject Protocol — Kiczales, des Rivières, Bobrow, MIT Press, 1991