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
- A typed client whose signature encodes the invariant (
BinaryIO, notstr): the type is the constraint. - A sole-seam lint banning the raw HTTP call to other services.
- Migration of existing cross-service calls onto the client.
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
- All cross-service calls funnel through one client — a coupling point that must cover every HTTP shape callers need.
- Slightly more ceremony. Callers must open a file handle rather than pass a path string (the point, but it is friction).
- The seam is a bottleneck for evolution. New call patterns require extending the one client.
Example use within DocAble
ServiceClient.post_file(BinaryIO): the typed cross-service seam.- The IPC sole-seam lint (the one cross-service-HTTP surface for the web tier).
- The file-path-over-wire bug class the
BinaryIOsignature eliminates.
Related Patterns
- See also (sibling) — raw-redis-seam: the same
bounded-servicepattern for the Redis boundary: one lint-enforced seam owning a whole class of dangerous raw calls. - Counterpart — the sole-seam lint (hard) that bans the raw
requests.postalternative.