Some code is difficult to change for reasons that have little to do with the change itself. A function may read the clock directly, create its own network client, access a global configuration object, or write to a file in the middle of business logic.
The requested change may be small, but verifying it safely becomes difficult because the code is tightly connected to things that are slow, unpredictable, or hard to reproduce.
A useful response is to create a seam: a place where behavior or a dependency can be replaced without rewriting the code that uses it. The seam gives you control over one boundary. That control makes tests easier to write and later refactoring easier to perform.
The important idea is modest: do not redesign the whole system before you can test it. First create one controlled point of substitution around the dependency that blocks safe change.
Start with the dependency you cannot control
Imagine an invoice service that decides whether an invoice is overdue by reading the current time directly:
function isOverdue(invoice):
return systemClock.now() > invoice.dueAtThe comparison is simple. The difficulty is that the test cannot choose what systemClock.now() returns.
A test written today may need a different fixture tomorrow. Boundary cases around the due time are awkward to reproduce. The problem is not the date comparison; it is the hidden dependency on the system clock.
A seam makes that dependency controllable:
function isOverdue(invoice, clock):
return clock.now() > invoice.dueAtProduction code can pass a clock that reads the real system time. Tests can pass a clock that returns a fixed value.
The business rule has not changed. Only the way it obtains time has changed.
That distinction matters. A good first seam is usually a small structural change that preserves observable behavior while making the next change easier to verify.
Think in terms of control, not mocking
Seams are often discussed together with test doubles, but the deeper purpose is control.
A dependency becomes troublesome when the code using it cannot choose or observe important conditions. Common examples include:
- the current time;
- random values;
- network calls;
- filesystem access;
- environment variables;
- process-wide configuration;
- external queues or services.
You do not need to replace every dependency in every test. You need a boundary where replacement is possible when a test or a migration requires it.
This is why adding a large mocking framework is not the same as improving the design. If a test must intercept constructors, global functions, and private implementation details, it may be working around missing boundaries rather than using intentional ones.
Put the seam at a meaningful boundary
The location of a seam affects how useful it becomes.
Suppose an order workflow sends a confirmation message after saving an order. One option is to expose every detail of the messaging library to the workflow. A more stable boundary is the capability the workflow actually needs:
interface OrderNotifier:
sendConfirmation(order)The workflow depends on OrderNotifier, not on connection pools, message formats, retry configuration, or a particular provider SDK.
That creates a seam around a meaningful responsibility. Production code supplies the real notifier. A test can supply a small recording implementation:
class RecordingNotifier:
sentOrders = []
function sendConfirmation(order):
sentOrders.append(order)Now the test can verify that the workflow requested a confirmation without operating a real messaging system.
The seam is valuable because it follows a domain-relevant boundary. It does not exist merely to expose internal details to tests.
Use the smallest change that creates control
Legacy code often tempts engineers into a large cleanup before the requested behavior can be changed. That increases risk because structural changes and behavior changes become mixed together.
A safer sequence is:
- Identify the dependency that prevents a reliable test.
- Create the smallest seam that makes that dependency controllable.
- Verify that existing behavior is still preserved.
- Add a test for the behavior you intend to change.
- Make the behavior change.
- Refactor further only when the new structure justifies it.
For example, a class that creates its own repository might initially look like this:
class AccountService:
function activate(accountId):
repository = SqlAccountRepository()
account = repository.find(accountId)
...A minimal step is to accept the repository from outside:
class AccountService:
function constructor(repository):
this.repository = repository
function activate(accountId):
account = this.repository.find(accountId)
...This is dependency injection in its simplest form. It does not require a dependency-injection container. The important result is that construction of the repository is no longer entangled with the activation rule.
Preserve behavior while introducing the seam
Creating a seam is safest when it is treated as a behavior-preserving refactoring.
If the original code always used a particular timeout, configuration value, or implementation, the first structural change should normally keep that behavior. Do not silently change defaults while also introducing the new boundary.
A useful technique is to move construction outward while leaving the dependency itself unchanged:
repository = SqlAccountRepository(existingConfiguration)
service = AccountService(repository)The application still uses the same repository. The service simply stops deciding how that repository is constructed.
Keeping these steps separate makes failures easier to diagnose. If a test fails immediately after the seam is introduced, the structural change is suspect. If it fails only after the business rule changes, the cause is narrower.
Avoid seams that mirror every implementation detail
More substitution points do not automatically produce better code.
Consider a calculation that uses three small private helper functions. Turning every helper into an interface may make tests more complicated without giving the application a meaningful new boundary.
A seam is most useful when at least one of these is true:
- the dependency crosses an external boundary;
- its behavior is nondeterministic;
- it is slow or expensive to use in tests;
- failures need to be simulated deliberately;
- an implementation is expected to change independently;
- construction concerns are obscuring business logic.
Pure calculations and stable value transformations usually do not need extra indirection. Test them directly through their useful public behavior.
This keeps the design from becoming a network of interfaces that exist only because they can be mocked.
Make failure cases easier to reproduce
One of the strongest reasons to create a seam is not success-path testing. It is controlled failure.
A real remote service may fail rarely and unpredictably. A seam lets a test supply a dependency that fails on demand:
class FailingPaymentGateway:
function charge(payment):
raise TimeoutError()The test can then verify what the application does when payment status is uncertain. Does it retry? Does it leave the order pending? Does it surface an actionable error?
Without a controllable boundary, teams often test only the successful path because reproducing failures is inconvenient. The resulting code may appear well tested while its most important recovery behavior remains unverified.
A seam turns rare external conditions into ordinary test inputs.
Do not confuse a seam with a permanent abstraction
A seam can begin as a tactical tool.
Sometimes the new boundary represents a stable responsibility and deserves to remain. A clock abstraction, payment gateway, repository, or message publisher may continue to be useful because the application genuinely should not own the implementation details behind it.
In other cases, the seam exists only to support a migration. Once the old implementation disappears, some temporary indirection may no longer earn its cost.
Review the boundary after the change is complete. Ask whether it still separates concerns that can vary independently. If not, simplifying it may be better than preserving abstraction for its own sake.
This prevents a common refactoring failure: solving today’s rigidity by creating tomorrow’s unnecessary layers.
Recognize when a seam is not enough
A seam improves control over a dependency, but it does not repair every design problem.
If one class coordinates dozens of unrelated responsibilities, injecting all of them may produce a constructor with dozens of parameters while leaving the class conceptually overloaded. That is useful evidence. The immediate seam may make a risky change testable, but the larger responsibility boundary still needs attention.
Likewise, replacing a database with an in-memory substitute does not prove that production database behavior is identical. Transaction semantics, constraints, query behavior, and concurrency may still require integration tests against the real technology.
Use seams to isolate what should be isolated. Keep integration tests for behavior that depends on the integration itself.
A practical decision process
When existing code feels unsafe to change, trace the difficulty to a concrete boundary.
Ask what condition you need to control in order to verify the change. If the answer is time, randomness, I/O, an external service, global state, or construction hidden inside business logic, look for the smallest place where that dependency can be supplied from outside.
Then preserve existing behavior while introducing that point of control. Write the test that was previously difficult. Only after that should you make the requested behavior change.
This sequence keeps the scope understandable: first gain control, then verify, then change.
Conclusion
A seam is a controlled substitution point that makes difficult code easier to test and change. Its value comes from separating a piece of logic from a dependency it should not need to construct or control directly.
The best seams are small and meaningful. They sit around real boundaries, preserve behavior when introduced, make important conditions reproducible, and avoid exposing irrelevant implementation details.
When a codebase is hard to change, a complete redesign is rarely the safest first move. Creating one well-placed seam can provide enough control to make the next change confidently, while revealing where deeper refactoring is actually worth the cost.