Chapter 2 of 7 · 16 min read
Seams, ports and the composition root
On this page
A seam, in Michael Feathers' definition from Working Effectively with
Legacy Code, is a place where you can alter a program's behaviour without
editing in that place. The second clause is what makes the idea useful: any code
can be changed by editing it, and a seam lets a test change what the code does
from outside, which is what allows the same code to run in production and under
test. Backend engineers usually have this instinct for databases already. Nobody
is surprised that a service calling psycopg.connect() in the middle of a
request handler is hard to test, and the standard repair — a repository object
passed in — is exactly the move this chapter applies to a model provider.
Provider calls tend to escape that instinct, because the vendor's software development kit, the client library shipped so you can call the API from your own language, is so convenient at the call site, and because the thing being called feels like a library function rather than an I/O boundary. This chapter puts three seams in: one for the client, so a fake can be substituted; one for the clock, so time can be driven rather than spent; and one for prompt rendering, so the finished string exists as a value before anything sends it. Every later chapter of this topic depends on at least one of them, and none of them can be added by a test alone — each is a small change to production code, made once.
What a module-level client costs before any test runs
Sitting down to write the truncation test from the previous chapter — the one
that hands summarize a response with finish_reason="length" and asserts it
raises rather than parsing garbage — against a typical module takes about ninety
seconds to become impossible. The module opens like this:
from anthropic import Anthropic
_client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
def summarize(transcript: str) -> Summary:
resp = _client.messages.create(
model="claude-sonnet-4-5",
max_tokens=512,
messages=[{
"role": "user",
"content": f"Summarise this support transcript as JSON.\n\n{transcript}",
}],
)
...The client is a module-level singleton, so it is constructed at import time,
which means the environment variable is read at import time, which means
import myapp.summarize raises KeyError: 'ANTHROPIC_API_KEY' on a laptop with
no key configured. Collection of the whole test file fails before any test runs.
There is also nowhere in that function to put a different response: the call
site constructs its own collaborator and reads its own configuration, which are
the two properties that between them make substitution impossible.
The step in that chain most likely to be skipped is the first one, because a
line like _client = Anthropic(...) sitting at the top of a file reads as
configuration. It occupies the same visual register as the imports above it, and
it looks like a declaration of what this module uses. It is not a declaration. A
module body is ordinary executable code, and Python runs it top to bottom the
first time anything imports the module. So the constructor call runs then, and
the environment lookup nested inside it runs then, at first import.
For a test run, first import happens during collection — the phase where the runner walks the test files and imports each one to find the functions in it, before it has decided to execute a single one of them. That is why the failure arrives as a collection error rather than as a failing test, and it is why no fixture can get in front of it: fixtures are functions the runner calls after collection, and there is no collection left to protect once the import has raised. The only two places that can prevent the error are the environment the runner started in and the module itself.
Patching is a seam, and a poor one
Python will let you substitute the constructor without touching the production module:
@mock.patch("myapp.summarize.Anthropic")
def test_truncated(mock_cls):
mock_cls.return_value.messages.create.return_value = Message(
id="msg_01",
type="message",
role="assistant",
model="claude-sonnet-4-5",
content=[TextBlock(type="text", text='{"summary": "Customer wants a ref')],
stop_reason="max_tokens",
usage=Usage(input_tokens=812, output_tokens=512),
)
...Two mechanics in that snippet are worth naming before the criticism of it, since
a reader who cannot see how it works cannot judge what it costs. The
@mock.patch decorator wraps the test function: before the body runs it
replaces the named attribute with a stand-in object, after the body runs it puts
the original back, and it passes the stand-in into the test as an extra
positional argument, which is where mock_cls in the signature comes from. And
the stand-in is a class stand-in rather than an instance, which is why the
assignment reaches through .return_value twice — once for what calling the
class returns, which is the client, and once for what calling
messages.create on that client returns, which is the message.
This works, and it is worth understanding what it costs rather than dismissing
it. The patch target is a string, so a refactor that moves the import breaks the
test with an AttributeError about a module rather than anything resembling the
actual problem. Building the return value requires importing the SDK's own
Message, TextBlock and Usage types into your test module, which means the
test now asserts a claim about the SDK's internal data model — a claim a minor
version bump can invalidate, and one that has to be repeated in every test file
that touches the model. When the SDK adds a required constructor field, every
one of those tests fails at once, and none of the failures are about your code.
The comparison across the plausible strategies is worth laying out, because "just patch it" is a defensible answer for a single test and stops being one at thirty. The third column names the arrangement the rest of this chapter builds: a port, meaning a narrow interface your own code declares and owns, written in your own types and describing only what the application needs from a model provider, with a vendor-specific adapter sitting behind it whose single job is translating between that interface and the SDK.
mock.patch on the SDK | Subclass the SDK client | Narrow port you own | |
|---|---|---|---|
| Requires changing production code | No | No | Yes, once |
| Survives an SDK minor upgrade | Often not | Rarely | Yes |
| Test knows SDK types | Yes | Yes | No |
| Works for the clock too | Awkward | No | Yes |
| Failure message points at your bug | Sometimes | Sometimes | Yes |
| Cost to add the tenth test | Copy 12 lines of SDK types | Copy 12 lines | Two lines |
The last row is the one that decides it in practice. Patching does not get cheaper with repetition; a port amortises.
A port is a promise about your side of the boundary
The interface to define is the one your application needs, described in types your application owns:
@dataclass(frozen=True)
class LLMResponse:
text: str
finish_reason: Literal["stop", "length", "tool_use", "content_filter"]
tool_calls: tuple[ToolCall, ...]
input_tokens: int
output_tokens: int
model: str
class LLMClient(Protocol):
def complete(
self,
*,
model: str,
prompt: str,
max_tokens: int,
tools: Sequence[ToolSpec] = (),
) -> LLMResponse: ...LLMClient is declared as a Protocol, and what that keyword buys is the
reason a fake written in a test file needs no relationship to the port at all. A
protocol describes a shape rather than an ancestry. Any object with a complete
method of that signature counts as an LLMClient, whether or not its class has
ever heard of LLMClient, and nothing has to inherit or register. That is the
property doing the work in every test in this topic: FakeLLMClient is a small
class living in a test module, satisfying a protocol it never imports.
The price of that convenience is where the conformance is checked, and it is not
at runtime. A type checker reads the protocol and the fake and compares them
before the code runs; the interpreter compares nothing, and isinstance against
a plain protocol is not even permitted unless the protocol has been explicitly
marked as runtime-checkable. So declaring the port does not, on its own,
guarantee that any fake matches it. Add a parameter to complete, update the
adapter, and every fake in the suite is now the wrong shape — and each of those
tests goes on passing wherever its own path does not happen to exercise the new
parameter, which is most of them. What closes that gap is running the type
checker over the test files as well as the application files. That is a decision
somebody has to make deliberately, and this is the reason to make it.
The adapter that satisfies this protocol is the only file in the codebase that imports the vendor SDK, and its job is translation:
class AnthropicClient:
def __init__(self, sdk: Anthropic) -> None:
self._sdk = sdk
def complete(self, *, model, prompt, max_tokens, tools=()) -> LLMResponse:
msg = self._sdk.messages.create(
model=model,
max_tokens=max_tokens,
messages=[{"role": "user", "content": prompt}],
tools=[t.to_json_schema() for t in tools],
)
return LLMResponse(
text="".join(b.text for b in msg.content if b.type == "text"),
finish_reason=_FINISH.get(msg.stop_reason, "stop"),
tool_calls=tuple(_to_tool_call(b) for b in msg.content if b.type == "tool_use"),
input_tokens=msg.usage.input_tokens,
output_tokens=msg.usage.output_tokens,
model=msg.model,
)
_FINISH = {"end_turn": "stop", "max_tokens": "length", "tool_use": "tool_use"}That _FINISH dictionary is a small thing that earns the whole exercise. In the
original code the string "length" was compared against whatever the SDK
happened to call it, in the same expression as the business logic, in every
module that made a call. Here the vendor's vocabulary is mapped to yours exactly
once, in a file whose entire purpose is to know about the vendor, and every test
everywhere else can talk about finish_reason="length" without caring what
Anthropic or OpenAI call it this year.
The shape _FINISH gives that mapping is worth naming, because it recurs at
every boundary of this kind. On the vendor's side of the adapter, strings like
"end_turn" and "max_tokens" are data: they arrive over the wire, they are
the vendor's to define, and they can gain a new member the week a new model
ships. On your side they are constants, fixed by the literal type on
LLMResponse.finish_reason and changed only when somebody edits that line. The
dictionary is the one place where a value of the first kind becomes a value of
the second, and that is why the translation can be reviewed: there is a single
file to read to find out what your code believes about the vendor's vocabulary.
The .get(msg.stop_reason, "stop") at the call site is the other half of that
decision. It says that an unrecognised vendor string will be reported as a
normal completion, which is a choice with consequences the last section of this
chapter comes back to.
The version of this that buys nothing is a port whose methods take and return
the SDK's own objects, on the theory that a thin wrapper is less work. A test
against that port still has to construct Message and TextBlock, so the
coupling has moved by one file and not gone anywhere.
How wide to make the interface is a judgement, and the useful discipline is to
add a parameter when a caller needs it rather than when the vendor offers it.
The protocol above has four parameters because the application uses four; the
SDK's messages.create accepts a dozen more, including system,
stop_sequences, top_p and metadata, and adding them speculatively means
every fake in the suite grows a signature nobody exercises. Streaming is the
interesting exclusion: it returns an iterator rather than a value, its failure
modes are different, and a port covering both a blocking and a streaming call
with one method usually ends up with a stream: bool flag and a union return
type. A second method, or a second port, keeps both honest.
There is also a practical reason to make LLMResponse a frozen dataclass rather
than a dict. A fake returning a dict will happily return one with a typo in a
key, and the test that consumes it fails somewhere downstream with a KeyError
that reads like a bug in the code under test. A dataclass constructor rejects
the typo at the point where the fake is defined, which is where the mistake is.
Construction moves to one place
Once nothing constructs its own dependencies, something has to, and that something is the composition root: one function, called once at startup, that reads configuration and assembles the object graph.
def build_summarizer(settings: Settings) -> Summarizer:
sdk = Anthropic(api_key=settings.anthropic_key, timeout=10.0)
client = AnthropicClient(sdk)
client = RetryingClient(client, clock=SystemClock(), max_attempts=3)
return Summarizer(client=client, clock=SystemClock())The environment is read in exactly one function, so importing any module no
longer has side effects. Two properties of this arrangement matter more than the
pattern's name. The composition root is the only code that fails when a key is
missing, so a misconfigured deployment fails at startup with a message naming
the setting rather than on the first request an hour later. And because
RetryingClient implements LLMClient and holds an LLMClient, the retry
behaviour can be tested by wrapping a fake, while the summarizer can be tested
with no retry layer at all — the composition is a production concern that no
unit test has to reproduce.
The one thing a composition root does not do is test itself. build_summarizer
is the function with the least coverage in any codebase organised this way,
because covering it means constructing real objects, and the usual mitigation is
to keep it dull: no conditionals, no loops, no logic beyond reading settings and
calling constructors. When it starts branching on feature flags, that branching
belongs in a factory that can be tested, with the root calling it.
Both wirings produce a Summarizer holding something that satisfies
LLMClient, and the Summarizer cannot tell the difference. That is the whole
trick, and everything else in this topic is a question of what you put on the
left-hand side of the injection.
Time and the prompt string are dependencies too
The second substitution is the clock, for the same reason and with less obvious
motivation, since time.sleep does not need an API key and does not fail on a
laptop. It costs wall-clock seconds instead:
class Clock(Protocol):
def now(self) -> float: ...
def sleep(self, seconds: float) -> None: ...
class FakeClock:
def __init__(self) -> None:
self.t = 0.0
self.slept: list[float] = []
def now(self) -> float:
return self.t
def sleep(self, seconds: float) -> None:
self.slept.append(seconds)
self.t += secondsFakeClock.slept is a list of the delays the code under test asked for, which
is a far more direct assertion than measuring elapsed time, and now() advances
consistently with those sleeps so that a deadline computed from now() behaves
the way it would in production.
Two things change once the clock arrives through a parameter, and they are worth
separating. The first is speed, and its cause is mechanical: the retry wrapper
never asked the operating system to wait, it asked a collaborator to. With
SystemClock in place that collaborator calls time.sleep, and with
FakeClock in place it appends to a list and returns immediately. A test that
exercises three attempts with exponential backoff therefore takes microseconds
instead of the several seconds those delays would have cost, and it takes them
without anybody shortening the delays for the test — which matters, because
shortening them means the tested configuration is not the shipped one.
The second is that the assertion gets sharper. Measuring elapsed wall time makes
the verdict depend on the machine's scheduler, so the assertion has to be
loosened into a range wide enough to survive a loaded build agent, and a range
that wide can no longer tell a backoff of one second from a backoff of two. The
list in slept carries the exact values the code asked for, in order, so the
test can state the backoff schedule it expects and fail when the schedule
changes. The reflex against this is that a fake clock makes the test
unrealistic, and it is worth answering rather than waving away: the behaviour
under test is which delays the code requests, not whether the operating system
honours them. Honouring them is not your code's job, and there is nothing about
it a unit test could have discovered.
The third substitution is not a substitution at all. When the prompt is an
f-string inside the arguments to messages.create, the only way to see the
finished string is to intercept the call. Pulling rendering out into a function
that takes data and returns text makes the string an ordinary value:
def render_summary_prompt(transcript: str, examples: Sequence[Example] = ()) -> str:
...Nothing about this is clever. It matters because a function returning a string can be called from a test with no client at all, no fake, and no interception. The version to avoid is a renderer that takes a request-builder and mutates it, or one that returns a list of message dicts assembled from four helpers, since in both cases the thing that goes on the wire is still only assembled at the moment of the call.
The adapter is the one component a fake cannot test
A port makes substitution possible and says nothing about whether what you
substitute resembles reality. FakeLLMClient returns an LLMResponse you wrote
by hand, and if the adapter's translation is wrong — if msg.stop_reason can
also be "refusal" and _FINISH maps it to "stop" — every test using the
fake stays green while production mislabels refusals as successful completions.
The adapter exists precisely to convert between the two worlds, so exercising it
needs either a recorded real response or a live call, and both of those are
later chapters.
It is also worth separating this from vendor independence, which is a different goal that borrows the same shapes. A port sized for testability wants the narrowest interface your application uses. A port sized for switching providers wants to cover streaming, tool-call formats, prompt caching, structured-output modes and rate-limit semantics, all of which differ enough between vendors that the abstraction leaks and the second implementation ends up written against the first one's quirks. Building the large version because the small one looked similar is a common way to spend a fortnight and end up with a worse test suite than the one you started with. Topic 2.18 takes that trade apart in detail; the version needed here is the small one.
The three seams are cheap, and their cost is paid once in production code rather than repeatedly in test code. A port with an adapter behind it gives a test somewhere to put a fake and gives the codebase one file that knows a vendor's vocabulary. A composition root gives configuration one place to be read and gives a misconfigured deployment somewhere honest to fail. A renderer that returns a string gives the prompt an existence independent of the call that sends it.
With those in place, the substitutions the rest of this topic depends on are available: a fake client where the port is, a fake clock where time is read, and a rendered prompt that exists before anything sends it. The last of those is the cheapest to obtain and the least obvious to use well, because the assertion that suggests itself — comparing the string to an expected value written by hand in the test — is unmaintainable at the size real prompts reach.
Testing code that calls an LLM — read the current version at https://daviddominguez.dev/learn/en/testing-code-that-calls-an-llm/seams-ports-and-the-composition-root