Software Architecture · Legacy Systems

Finding Seams in Legacy Code That Was Never Built to Have Them

How object, link, and preprocessor seams let behaviour be substituted without editing the code under test, and what each kind of indirection costs at runtime and in maintenance.

A seam, in the sense the term is used in work on legacy code, is a place where behaviour can be changed without editing the code at that place. The definition sounds circular until it is applied: the question is not where the code is badly written, but where a test or a replacement implementation can be attached from outside. A four-hundred-line function that reads a clock, opens a socket, and queries a database has no such place. Every dependency is constructed inline, so the only way to observe or alter its behaviour is to edit the function body, which is exactly what a test must avoid doing.

This is why legacy systems feel untestable. The code is not merely unfamiliar; it is closed against substitution. Finding seams is the first mechanical step in opening it, and it comes before characterization tests, before routing traffic, and before any replacement is designed. Without a seam there is no place to put a probe.

Three kinds of seam and what they substitute

The taxonomy that matters in practice is by mechanism: object seams, link seams, and preprocessor seams.

An object seam substitutes through a polymorphic call. A reference the code holds, whether a field, a parameter, or a factory result, points at an object whose methods can be overridden or whose interface can be reimplemented. In languages with virtual dispatch, the call site loads the function address from the object’s vtable at run time, so replacing the object replaces the behaviour with no change to the caller. The cost is one or two extra memory loads per call and the loss of inlining across that boundary.

A link seam substitutes at link time. The production build links the real implementation of a symbol; the test build links a different one. This works whenever the call is resolved by the linker rather than inlined: a non-inline function in a separate translation unit, a symbol from a shared library, or a function pointer initialised from a configuration table. It requires no source change to the caller at all, which is why it is the usual technique for code that cannot be recompiled with modifications, including third-party libraries.

A preprocessor seam substitutes during preprocessing. A macro or an #ifdef selects one of two implementations before the compiler ever sees the code. It is the crudest option and the one that ages worst, because the two branches are separate programs that the compiler never checks against each other, but in C code from the 1980s and 1990s it is often already present and merely needs a second branch added.

The choice is constrained by what the build already supports. Adding an object seam requires touching the source and the type definitions; adding a link seam requires touching the build but not the caller; adding a preprocessor seam requires touching the source and, if the variant is selected by a build flag, the build as well, though not the type definitions or the call sites, and it doubles the number of configurations that must be understood.

Link seams deserve a note on mechanism, because they are the least intuitive. The linker resolves an undefined symbol to the first definition it finds in its search order, and with shared libraries the dynamic loader resolves it at run time through the lookup order recorded in the executable. Both facts can be exploited: a test shared library placed earlier in the search order can supply a definition that shadows the real one, and the production binary is unchanged. The technique works cleanly only for calls the compiler did not inline, which is why a function that is a candidate for a link seam should not be declared static or defined in a header.

Preprocessor seams fail in a specific way worth recognising: the inactive branch is not type-checked, so it drifts. A signature change made in one branch compiles and the other branch silently keeps the old one until someone builds the configuration that uses it. If a preprocessor seam is the only option, both branches should be built in continuous integration, even if one of them is only ever run by the test harness.

Extracting an object seam from inline construction

The common obstruction is a dependency constructed inside the function that uses it:

int post_settlement(const struct ledger_entry *e) {
    struct http_client *c = http_client_new("settlements.internal", 443);
    char body[512];
    int n = format_settlement(body, sizeof body, e);
    int rc = http_client_post(c, "/settle", body, (size_t)n);
    http_client_free(c);
    return rc == 200 ? 0 : -1;
}

There is no place to put a test double: the client is created and destroyed within the call, and the hostname is a literal. Introducing the seam means changing the ownership of the client, not the logic. The function takes the client as a parameter, and its callers obtain one from somewhere they control:

int post_settlement(struct http_client *c, const struct ledger_entry *e);

/* production caller: one client per worker process, reused across entries */
static struct http_client *worker_client;

int settle_batch(struct ledger_entry *entries, size_t count) {
    if (!worker_client)
        worker_client = http_client_new(SETTLEMENT_HOST, SETTLEMENT_PORT);
    for (size_t i = 0; i < count; i++)
        if (post_settlement(worker_client, &entries[i]) != 0)
            return -1;
    return 0;
}

The change is mechanical and behaviour-preserving except for one detail worth noticing: the original created a connection per entry and the new code reuses one. If the old behaviour included connection setup and teardown per call (visible to the server as a new TCP connection each time, and possibly required by a proxy that counts them), then the “refactoring” has altered semantics. Seam extraction is only safe when the substituted boundary is actually a boundary.

Seams that already exist but are not used

Most legacy code contains latent seams that nobody has exploited. A global variable that holds a function pointer is a seam. A module-level singleton with a setter is a seam, though a dangerous one. A configuration structure read at startup and passed down is a seam. A class with a protected method that can be subclassed in a test is a seam.

The search is textual before it is conceptual. Look for global state, for new or its equivalents, for direct calls to time and randomness, for file and network I/O, and for static methods on types with no interface. Each hit is a candidate. The useful question about each candidate is not “can this be replaced” but “is this the boundary I want to observe”. A seam around a clock lets a test control time; a seam around the function that reads the clock lets a test replace all time-dependent logic at once, which is usually too coarse to find the bug.

The cost of a seam

Every seam introduces indirection, and indirection is not free. Virtual dispatch prevents inlining, which matters in code called millions of times per second and is irrelevant in code called once per settlement. A function pointer table adds a load and defeats the compiler’s ability to see through the call, so it also inhibits constant propagation and dead-code elimination. A link seam makes the production and test builds differ in a way that the type system cannot check: if the real and substitute implementations disagree on a parameter convention, the failure appears at run time in the test build only.

There is also a maintenance cost that is easy to overlook. A seam created for a test becomes part of the production API. Callers must now supply the dependency, and the number of parameters grows. After a few years the code is a tree of injected collaborators, each of which was once a local variable, and the original logic is hard to find.

The usual compromise is to introduce the seam at the smallest boundary that allows the behaviour of interest to be controlled, and to make the production default a parameter with a sensible value rather than removing the direct construction entirely.

Where seam work fails

Three failure modes recur.

The first is substituting so much that the test no longer exercises the real path. If a test replaces the database, the HTTP client, and the serialiser, it tests the orchestration function and nothing else; the serialiser’s bugs, which are the ones that break the integration, are invisible. Seams should be placed around things that are slow, non-deterministic, or externally owned, not around everything.

The second is a seam over shared mutable state. A global registry that tests swap out is not isolated: tests running in parallel, or a test that fails before restoring the registry, corrupt the next test. The symptom is an order-dependent suite that passes alone and fails in a batch. Seams over static state need explicit save-and-restore in a teardown that runs even on failure.

The third is a seam that changes the concurrency structure. Replacing a per-call client with a shared one, as above, changes connection counts and can change failure behaviour under contention. Replacing a thread-local buffer with a shared one introduces races that the test build, running single-threaded, never sees. Any seam that alters the lifetime of a shared object must be evaluated for concurrency, not just for output equality.

Once a seam exists, the next step is to record what the code currently does through it, which is the subject of characterization tests for untested code. Where the seam is at a process boundary rather than a function call, the observation has to be made from outside the process, and runtime dependency mapping describes how to collect that.

References

  1. Working Effectively with Legacy Code — Michael Feathers, Prentice Hall, 2004
  2. Branch by Abstraction — Martin Fowler
  3. Refactoring: Improving the Design of Existing Code — Martin Fowler, Addison-Wesley