Legacy code is often difficult to change for a reason that has little to do with the business rule you need to edit. The code may create its own database client, read the clock directly, call a remote service in the middle of a calculation, or depend on another component that is expensive or unreliable in tests.
You may understand the desired change and still be unable to test it in isolation.
A seam is a place where you can change which behaviour is used without rewriting the code that depends on it. In practice, creating a seam means introducing one small point of substitution around a dependency. That point can make a previously hard-wired part of the system controllable enough to test and change safely.
This article explains how to find useful seams, introduce them with minimal structural change, and avoid turning a local refactoring into an unnecessary redesign.
Start with the dependency that blocks the change
Consider a service that decides whether an order should receive a late-processing warning:
function needsWarning(order):
currentTime = SystemClock.now()
return currentTime > order.deadlineThe rule is simple. The testing problem is not.
A test cannot directly choose currentTime, so its result depends on when the test runs. You could arrange deadlines relative to the real clock, but that makes the test describe timing tricks instead of the business rule.
The important observation is that you do not need to redesign the whole service. You need control over one dependency: the source of the current time.
That is where a seam is useful.
Think of a seam as a controlled substitution point
The mental model is straightforward:
code under change -> seam -> dependencyProduction code sends the operation through the seam to the real dependency. A test can send the same operation through the seam to a controlled replacement.
For the clock example, the smallest useful refactoring might be:
function needsWarning(order, clock):
currentTime = clock.now()
return currentTime > order.deadlineProduction supplies the real clock:
needsWarning(order, systemClock)A test supplies a fixed clock:
clock.now() -> 2026-09-04 10:00
order.deadline = 2026-09-04 09:00
assert needsWarning(order, clock) == trueThe business rule did not change. What changed is the place from which the function obtains time. Because that choice is now outside the rule, the test can control it.
The parameter is the seam.
This is a simplified example. In a larger codebase, the seam might be a constructor parameter, interface, function argument, overridable method, callback, adapter, or another mechanism supported by the language. The mechanism matters less than the property it provides: the dependency can be selected without editing the business logic each time.
Preserve behaviour before improving design
When introducing a seam into unfamiliar code, separate two goals:
- make the dependency replaceable;
- improve the surrounding design.
Trying to do both at once increases the number of reasons a test might fail.
Suppose legacy code creates a payment client internally:
function confirmOrder(order):
client = new PaymentClient(configuration)
payment = client.capture(order.paymentId)
if payment.accepted:
order.markConfirmed()You want to test the confirmation rule without contacting the payment system. A narrow first change is to move creation of the client outside the function:
function confirmOrder(order, paymentClient):
payment = paymentClient.capture(order.paymentId)
if payment.accepted:
order.markConfirmed()The production caller still supplies a real PaymentClient. The test can supply a small replacement that returns a known result.
At this stage, resist the temptation to rename every method, reorganize the order model, introduce several new layers, and change error handling in the same patch. Those may be worthwhile changes later. They are not required to create the seam.
A small structural change is easier to review because its intended guarantee is narrow: the dependency selection changes, but externally observable behaviour should not.
Put the seam at the narrowest useful boundary
Not every replaceable component creates a useful design.
Imagine a report generator that performs these steps:
load customer
load invoices
calculate totals
format report
send emailIf the only problem is that sending email makes tests slow and unreliable, replacing the entire report generator in tests avoids too much real behaviour. The test would no longer exercise loading, calculation, or formatting.
A narrower seam around email delivery is more informative:
report = generateReport(customerId)
mailer.send(customer.email, report)Now tests can exercise report generation while replacing only the side effect they need to control.
This suggests a practical rule:
Place a seam around the dependency that prevents useful testing or safe change, not automatically around the largest component available.
A seam that is too broad can hide defects because tests replace meaningful behaviour. A seam that is too narrow can expose low-level implementation details and make tests fragile. Choose a boundary that matches the decision the caller actually needs to control.
Use existing boundaries before inventing new ones
A codebase may already contain a suitable change point.
Before adding an interface or wrapper, check whether the dependency already enters through:
- a constructor or function parameter;
- an existing factory;
- a callback;
- a module-level binding that the project’s test conventions safely replace;
- an adapter already responsible for external I/O.
Reusing an existing boundary usually produces a smaller change.
If no useful boundary exists, introduce the least powerful mechanism that solves the problem. A function parameter may be enough. A new hierarchy of interfaces and implementations is unnecessary when there is only one simple operation to substitute.
The goal is controllability, not abstraction for its own sake.
Keep dependency creation near the outside
Seams become easier to reason about when object creation and environment-specific choices happen near the edge of an operation rather than deep inside business logic.
Compare these two shapes:
business rule
-> constructs client
-> calls networkand:
application setup
-> constructs client
-> passes client to business operationIn the second shape, the business operation receives a capability it needs instead of deciding how that capability is constructed. Tests can provide a controlled implementation, while production setup provides the real one.
This does not mean every value in a program should be injected. Plain data objects and cheap deterministic helpers often need no substitution at all. Introduce a seam when controlling a dependency materially improves testing, change safety, or separation of responsibilities.
A seam does not prove the replacement is realistic
Creating a substitution point solves a structural problem. It does not guarantee that a test replacement behaves like the real dependency.
For example, a fake payment client might accept every payment immediately while the real client can time out, reject requests, or return incomplete responses. Tests using the fake are useful for business decisions, but they cannot establish that the production integration is configured correctly.
Different tests answer different questions:
controlled replacement -> does our decision logic handle known outcomes?
real integration -> do we communicate with the dependency correctly?A seam makes the first kind of test possible. It does not eliminate the need for integration testing where the interaction itself matters.
This distinction prevents a common mistake: treating easy substitution as evidence that the external system has been tested.
Watch for seams that leak implementation details
A poor seam can make tests depend on details that should remain private.
Suppose a pricing service internally performs three lookup calls. Exposing three callbacks only so a test can assert the exact call sequence may lock the test to the current implementation. A later optimization that performs one batch lookup could break the test even when pricing behaviour remains correct.
Prefer seams around meaningful collaborators or capabilities:
priceCatalog.findPrices(productIds)rather than around incidental steps such as individual map accesses or private helper calls.
Ask what must be replaceable for the test to control an important condition. If the answer describes a business capability or external effect, the seam is more likely to remain useful as the implementation evolves.
Avoid turning every dependency into an interface
Once seams make one difficult area easier to test, it is tempting to apply the technique everywhere.
That creates its own cost. Every additional abstraction introduces names, navigation, construction logic, and decisions that future maintainers must understand. If an object is deterministic, cheap, local, and unlikely to require substitution, adding an interface may provide little value.
For example, a pure function that converts metres to kilometres usually does not need an injectable UnitConverter. Calling the function directly is simpler and equally testable.
A seam earns its place when there is a concrete reason to vary or control behaviour. Common reasons include external I/O, time, randomness, expensive computation, environment state, or a legacy dependency that prevents focused testing.
Create seams incrementally
A practical legacy-code workflow is:
- identify the specific change you need to make;
- find the dependency that prevents a focused test;
- locate an existing substitution point or introduce the smallest one;
- verify that production behaviour remains unchanged;
- write the focused test using a controlled replacement;
- make the intended behavioural change;
- consider broader design improvements separately.
The order matters. The seam is not the destination. It is an enabling change that gives you enough control to make the real change with evidence.
If introducing the seam itself is risky, use whatever evidence the system already provides: existing tests, characterization tests, logs, or a narrowly scoped manual check. The less you know about the code, the more valuable it is to keep the structural edit small.
When a seam is the wrong tool
Do not introduce a seam when direct testing is already simple. A deterministic calculation with explicit inputs needs no replaceable dependency.
A seam is also not a substitute for fixing a badly chosen system boundary. If two modules are deeply entangled because responsibilities are genuinely mixed, adding many tiny interfaces may only decorate the coupling. A larger refactoring may be justified once you have enough tests and understanding to perform it safely.
Finally, avoid adding substitution solely because a mocking framework can intercept something. The engineering question is not “Can this call be mocked?” It is “Which dependency must be controllable for this code to be changed and tested with confidence?”
Conclusion
A seam is a small but powerful refactoring tool for code whose dependencies are hard-wired. It gives you a place to substitute behaviour without rewriting the logic you are trying to understand or change.
Start from the concrete obstacle. Identify the dependency that blocks a useful test, introduce the narrowest practical substitution point, and keep the first refactoring behaviour-preserving. Then use that control to test the real decision you need to change.
Good seams do not make every component abstract. They make the dependencies that genuinely need control explicit, while leaving simple code simple.