Sometimes a small code change feels much larger than the requirement. You want to test one decision, replace one dependency, or alter one behavior, but the code gives you no place to do that without executing or editing a large surrounding block.
A useful way to reason about this problem is to look for a seam: a place where you can change the behavior of a program without editing the code that uses that behavior. A seam might be a function parameter, an object boundary, a configurable callback, or another point where one implementation can be substituted for another.
Seams matter because they reduce the amount of code involved in a change. Instead of redesigning an entire subsystem before you can test it, you can often introduce one small substitution point and work from there.
This article shows how to recognize seams, create the smallest useful one, and avoid turning every dependency into an abstraction.
Start with the change you cannot isolate
Imagine a service that decides whether an order should be expedited. The rule depends on the current time:
shouldExpedite(order):
hour = systemClock.currentHour()
if order.isPriority and hour < 15:
return true
return falseThe decision is simple, but a test cannot choose the hour directly. It must accept whatever systemClock reports. Tests around 15:00 can therefore be awkward or unreliable, and reproducing a time-specific case requires control over the environment.
The problem is not that reading a clock is inherently wrong. The problem is that the decision and the source of time are connected in a way that gives the caller no substitution point.
A seam can make that dependency controllable:
shouldExpedite(order, clock):
hour = clock.currentHour()
if order.isPriority and hour < 15:
return true
return falseProduction code passes the real clock. A test can pass a clock that returns 14 or 15 on demand.
The business rule did not become more complicated. The important change is structural: the code that decides no longer chooses the concrete source of time.
Think in terms of substitution points
A seam is easiest to understand as a substitution point.
Suppose code contains these two responsibilities:
choose a dependency
use the dependencyWhen both happen in the same place, changing the dependency often requires editing the consumer. A seam separates those responsibilities:
outside:
choose dependency
consumer(dependency):
use dependencyNow the consumer can stay unchanged while the supplied behavior varies.
This is useful beyond tests. The same structure can support a real payment provider in production and a sandbox provider in development, a normal notifier and a disabled notifier, or an old implementation and a replacement during a migration.
The key property is not a particular language feature. It is the ability to select behavior somewhere other than the code that performs the main job.
Find the seam before creating one
Existing code often already contains usable seams.
Consider a report generator:
generateReport(records, formatter):
rows = summarize(records)
return formatter.format(rows)If formatter can already be supplied by the caller, there is no need to introduce another interface merely for testing. The parameter is the seam.
Other common seams include:
- constructor parameters that receive collaborators;
- function or method parameters;
- callbacks or strategy objects;
- module or component boundaries with replaceable implementations;
- configuration that selects among existing implementations.
Before refactoring, ask: where can behavior already be substituted? Reusing an existing boundary usually creates less code and fewer concepts than inventing a new one.
Create the smallest seam that gives control
When no useful seam exists, introduce only enough structure to control the dependency that blocks the change.
Suppose an invoice function constructs its mailer internally:
sendInvoice(invoice):
mailer = SmtpMailer(loadMailSettings())
message = renderInvoice(invoice)
mailer.send(invoice.customerEmail, message)A test of sendInvoice is now tied to mail configuration and the concrete mailer. One possible seam is to receive the mailer:
sendInvoice(invoice, mailer):
message = renderInvoice(invoice)
mailer.send(invoice.customerEmail, message)Composition moves outward:
mailer = SmtpMailer(loadMailSettings())
sendInvoice(invoice, mailer)A test can supply a recording mailer and verify the intended recipient and message without opening a network connection.
Notice what did not change. Rendering stayed where it was. The invoice model did not gain a new abstraction. No generic dependency container was introduced. The refactoring moved one construction decision because that decision prevented controlled substitution.
That is a useful constraint: create the seam around the dependency you need to vary, not around every object the function happens to touch.
A seam needs a clear contract
Substitution is useful only when implementations agree on what the consumer can expect.
For the mailer example, the consumer may rely on a small contract:
mailer.send(recipient, message)But the method shape is only part of the contract. The code may also need answers to questions such as:
- Does
sendreport failure, throw an error, or retry internally? - Is calling it twice allowed?
- Does success mean the message was accepted locally or delivered remotely?
A test double that behaves unlike the real dependency can make tests pass while production still fails. For example, an in-memory mailer that can never report an error does not exercise the caller’s error path.
So a seam should expose the behavior the consumer actually depends on, including meaningful failure behavior. It should not pretend that difficult operational properties have disappeared.
Use seams to protect a risky change
Seams are especially valuable when code is difficult to test and a large cleanup would make the current change riskier.
Imagine a legacy billing routine that reads the current exchange rate from a global client, calculates an amount, updates state, and writes a receipt. You need to change only the calculation rule.
A broad redesign might be desirable eventually, but doing it at the same time as the rule change increases the number of moving parts. A narrower sequence can be safer:
- Identify the external behavior that prevents a controlled test, such as fetching the exchange rate.
- Introduce a seam that lets the routine receive or reach a replaceable rate provider.
- Add tests that exercise the existing calculation through that seam.
- Make the required rule change.
- Refactor further only if the remaining design still justifies it.
The seam does not solve every legacy-code problem. It creates a controlled boundary so the next change can be made with better feedback.
Do not confuse seams with mocks
A mock, fake, stub, or other test double can be placed on one side of a seam, but the seam and the test double are different things.
In this function:
calculatePrice(cart, discountPolicy)discountPolicy is the seam because callers can substitute behavior. A fixed policy used in a test is one possible test double.
This distinction matters because seams have production design consequences even when no test double is involved. Production may select different policies for different markets. A migration may route some calls to a new implementation. The substitution point exists independently of how tests use it.
Thinking only in terms of mocks can also encourage unnecessary mocking. If a dependency is a simple value or a deterministic function, passing the value or calling the function directly may be clearer than introducing a test double.
Watch for seams that make design worse
A seam adds flexibility, and flexibility has a cost. There is another parameter, interface, callback, or configuration choice for developers to understand.
Several mistakes are common.
Abstracting stable details without a reason
Wrapping every library call behind a custom interface can create more maintenance work than it removes. If you do not need to substitute the behavior and the dependency is already easy to exercise, a new seam may provide little value.
Making the seam too broad
An interface with fifteen unrelated methods is difficult to replace faithfully. Consumers become coupled to a large contract even when they need only one operation.
Prefer a boundary shaped around what the consumer needs. A receipt sender may need sendReceipt, not complete access to a general messaging platform.
Moving complexity instead of reducing it
Dependency injection can become a chain of objects that exist only to pass other objects downward. If a simple value is sufficient, pass the value. If a pure calculation can be extracted cleanly, that may be simpler than introducing a replaceable service.
Using substitution to hide integration risk
A seam can isolate your code from a database, queue, filesystem, or remote service during focused tests. It cannot prove that the real integration works. Keep appropriate integration tests for assumptions that only the real boundary can verify.
Choose seams based on the reason for change
A good seam usually appears where independent changes meet.
If business rules change independently of the system clock, separating the rule from the clock is useful. If application logic changes independently of a mail provider, a mail boundary may be useful. If two pieces always change together and are easy to test together, separating them may only add indirection.
A practical decision process is:
- Name the behavior you need to change or control.
- Identify what currently prevents that control.
- Look for an existing substitution point.
- If none exists, introduce the narrowest one that solves the problem.
- Define the behavior expected across the boundary, including failures.
- Keep real integration coverage where substitution cannot verify the real system.
This keeps the design tied to an engineering need rather than to a rule that everything must be abstract.
Conclusion
A seam is a place where behavior can vary without rewriting the code that consumes it. The concept is useful because many difficult changes are difficult for structural reasons: the code that performs a job also decides exactly which environment, service, or implementation it must use.
When that coupling blocks testing or safe change, first look for a substitution point that already exists. If you need to create one, make it as narrow as the problem requires and give it a clear contract. Do not add abstraction merely because substitution is possible.
The practical goal is not to maximize the number of seams. It is to place a small number of useful boundaries where independent behavior genuinely needs to change independently.