Skip to content

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:

python
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:

python
@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 SDKSubclass the SDK clientNarrow port you own
Requires changing production codeNoNoYes, once
Survives an SDK minor upgradeOften notRarelyYes
Test knows SDK typesYesYesNo
Works for the clock tooAwkwardNoYes
Failure message points at your bugSometimesSometimesYes
Cost to add the tenth testCopy 12 lines of SDK typesCopy 12 linesTwo 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:

python
@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:

python
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.

python
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.

Flowchart: build_summarizer → AnthropicClient; AnthropicClient → anthropic SDK; anthropic SDK → api.anthropic.com; build_summarizer → Summarizer; AnthropicClient → Summarizer; test function → FakeLLMClient; test function → Summarizer; FakeLLMClient → Summarizer.

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:

python
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 += seconds

FakeClock.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:

python
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

Back to top