Appendix C - 5. ServiceClient (typed cross-service seam)

The Structure of ServiceClient (typed cross-service seam) — its shape at a glance:

Every cross-service call goes through the one typed client, whose file argument is a byte stream, not a path. The client posts bytes to the peer service. A lint bans the raw HTTP call; the type refuses the path at compile time.

flowchart LR
  C1[Caller A] --> SC
  C2[Caller B] --> SC
  SC["Typed client<br/>(takes byte stream)"] -->|bytes on the wire| Peer[Peer service]
  C1 -. banned raw HTTP .-> Peer
  L{{Sole-seam lint}} -. fails build .-> C1

Accessible description: two callers reach a peer service only through one typed client, which posts bytes over the wire. A dashed edge marks a caller making a raw HTTP call directly; the sole-seam lint fails the build on it, and the client's byte-stream signature makes passing a path a type error.

Projected from the catalogue entry canonical-models-and-seams / ServiceClient (typed cross-service seam).

On this page: Intent · Motivation · Applicability · Structure · Sample Code · Consequences · Example use within DocAble · Related Patterns

Intent

Intent — Route all cross-service HTTP through one typed client whose BinaryIO signature makes the file-path-over-wire bug class impossible at the type level; direct requests.post against another service is banned.

Motivation

Cross-service calls that pass a file path over the wire (instead of the file's bytes) are a recurring bug: the receiving service, in a different container, cannot open a path from the sender's filesystem. Ad hoc requests.post at each call site lets this exact type-confusion recur wherever someone reaches for the raw HTTP library.

Applicability

Structure

The Structure diagram appears at the top of this page.

Sample Code

The pattern makes the bug unrepresentable by typing it away. The client's post_file takes a byte stream — an open file handle — not a string path. A caller who passes a path gets a type error at the call site, before any request is made. The lint is secondary: it only stops callers from bypassing the client and hand-rolling the raw request.

from typing import BinaryIO

class ServiceClient:
    """The sole cross-service HTTP surface. `post_file` takes an open byte stream,
    so a caller physically cannot pass a filesystem path — the type refuses it, and
    the receiver in another container never gets a path it can't open."""

    def __init__(self, base_url: str, http):
        self._base_url = base_url
        self._http = http

    def post_file(self, endpoint: str, body: BinaryIO) -> dict:
        # body is bytes on the wire, never a path string
        resp = self._http.post(f"{self._base_url}/{endpoint}", data=body.read())
        resp.raise_for_status()
        return resp.json()

# caller: passes a handle, not a path — passing `str` here fails type-checking
with open("/tmp/chunk.pdf", "rb") as fh:
    client.post_file("remediate", fh)

Consequences

Example use within DocAble

Contents
© James C. Davis, 2026–present