Lesson 0023 · System Designer · Module 3
Ports & Adapters for Infrastructure
Lesson 0009 drew one arrow. Now draw them all: database, APIs, filesystem, clock, email — every technology behind a port, wired at a single composition root.
Mission tie-in: "apply Ports & Adapters to isolate infrastructure concerns" — the System Designer's full application of the pattern, and the shape your AI prompts should prescribe.
Knowledge: everything outside is an adapter
The domain speaks only to ports. The infrastructure — storage, gateways, files, the clock itself — implements them. The full inventory of infrastructure in a typical system:
- Persistence →
OrderRepository(lesson 0013) - External APIs →
PaymentGateway,SupplierFeed - Filesystem →
InvoiceStore - Email/notifications →
Notifier - The clock →
Clock.now()— even time is infrastructure worth a port
# domain/ports.py — everything the core needs from the world:
class Clock(Protocol):
def now(self) -> datetime: ...
class OrderRepository(Protocol):
def get(self, order_id: str) -> Order: ...
def add(self, order: Order) -> None: ...
# infrastructure/ — the world, conforming:
class SystemClock:
def now(self) -> datetime:
return datetime.now()
class SqlAlchemyOrderRepository:
...
# composition root — the only place the two meet:
def build_app() -> App:
clock: Clock = SystemClock()
orders: OrderRepository = SqlAlchemyOrderRepository(make_session())
notifier: Notifier = EmailAdapter(smtp_config())
checkout = CheckoutService(orders, notifier, clock)
return App(checkout=checkout)
A port converts an unknown into a choice. Every direct call to a technology embeds three assumptions in its caller: that the thing is available, that it is fast, and that its vocabulary is yours. A port makes all three properties of one file instead of properties of everywhere.
The payoff people advertise — swapping vendors — is the rarest one. The daily payoff is determinism: the ability to run a rule with time, randomness, and the network held still. That is why the clock earns a port although nobody will ever replace time, and it is the honest justification to reach for when someone asks what the indirection buys.
Where it stops applying. A port pays when the thing behind it is non-deterministic, slow, chatty in a foreign vocabulary, or plausibly replaceable. A dependency that is none of those — json, re, decimal, your own logging helper — gets no port, because the wrapper is a pass-through with a name: two things to read where there was one, and no seam gained. And a port whose only implementation is the real one, with no fake and no second adapter, is speculative generality (lesson 0030). The fake in your tests is what makes it the second implementation, which is why testability is the argument that actually holds.
The composition root — one visible function where everything is constructed and injected — is the pattern's capstone. Nothing anywhere else says "SQLAlchemy" or "stripe" or "datetime.now". Swap a technology by editing this one function.
Worked example: the test that only failed at night
A six-engineer team has a promotional pricing rule that reads datetime.now() to decide whether a promo window is open. Its test asserts "no discount at 23:59". The test passes for eleven months, then starts failing intermittently.
The first thing a senior designer looks for is not the bug but what in this test is not controlled by the test. The answer is the clock: the assertion is about an instant, and the instant is supplied by the machine. CI runs about forty times a day and the flake costs roughly 2% of runs — under one red build a day, and each one buys a re-run plus ten minutes of somebody's attention. That is only a few hours a month, and the hours are not the real cost. The real cost is that the team learns to re-run red builds without reading them, so the next genuine failure is also re-run.
The tempting repair is to monkeypatch datetime in the test. It is rejected for three reasons and it is worth holding all three: it mutates a module global for the whole process (lesson 0019) and leaks into whatever runs next; it silently fails if the rule imported now directly rather than the module; and it leaves the production signature lying — the function still claims to depend on nothing while depending on the wall clock.
The port makes the dependency part of the contract. The fake is three lines, and the test now names the instant it is asserting about:
# domain/ — the dependency is now visible in the signature
class Clock(Protocol):
def now(self) -> datetime: ...
def promo_price(base: Money, promo: Promo, clock: Clock) -> Money:
if promo.starts_at <= clock.now() < promo.ends_at:
return base * promo.factor
return base
# tests/ — the second implementation, and the reason the port exists
class FrozenClock:
def __init__(self, at: datetime) -> None:
self._at = at
def now(self) -> datetime:
return self._at
def test_promo_is_closed_one_second_before_it_opens() -> None:
clock = FrozenClock(datetime(2026, 7, 1, 9, 59, 59))
assert promo_price(Money(100), july_promo, clock) == Money(100)
Read the two signatures side by side: the old one said promo_price(base, promo) and lied; the new one declares that pricing depends on time. Over the following year the same clock let the team test a daylight-saving boundary, a promo that spans midnight, and an expiry-at-year-end case — none of which anyone would have written while the only way to reach them was to wait.
| In the domain today | Its port | Its fake in tests |
|---|---|---|
datetime.now() | Clock.now() | FrozenClock(datetime(2026, 1, 1)) |
requests.post(...) | PaymentGateway.charge() | a fake recording the calls it received |
| open(path) / S3 upload | InvoiceStore.put() | a dict-backed store |
os.environ["API_KEY"] | a settings object, passed in | a plain dataclass built in the test |
| uuid4() / random.random() | IdGenerator.next() | a counter, so assertions can name the id |
- "Ports are for swapping databases."
- Swapping is the rarest return. Determinism and testability are the daily ones. Justify a port by naming the fake you are going to write; if you can't name one, you are buying indirection on speculation.
- "Every third-party import needs a port."
- A pure, fast, deterministic library behind a port is a pass-through. You added a file, a name, and a hop, and gained no seam — the wrapper's only effect is that readers now traverse two layers to find one call.
- "A port means an abstract base class and a folder."
- Often a
Protocoland a function parameter is the entire port. The pattern is about the direction of the dependency arrow, not about the machinery used to express it (lesson 0017). - "The composition root should be a DI container."
- A container is one implementation of the idea and it trades greppability for wiring convenience. A plain
build_app()function you can read top to bottom is usually enough, and it fails at import time rather than at first request.
When this is the wrong move
Skip the port in a spike whose whole purpose is to learn the vendor's API — the abstraction would hide exactly what you are trying to see. Skip it in scripts. Skip it when the proposed port would have one implementation, no fake, and a vocabulary copied verbatim from the vendor: that is not a boundary, it is a rename with an extra file. And be honest about the failure mode on the other side — a system where every stdlib call has been ported is one where finding out what actually happens requires opening four files, and that cost is paid on every read by everyone.
Skill: what gets a port?
Which dependency least deserves a port?
The composition root is where:
A technology that will never change and never needs faking:
Practice on your own code
List every technology your domain touches today (imports of SDKs, datetime.now(), filesystem, env vars). For each, mark: changeable? needs faking in tests? Give a port to every "yes"; wire them all in a composition root function.
In the lab. The phase-2 checkpoint has you build the infrastructure inventory for the returns-resolution service — carrier SDK, database session, datetime.now() inside the eligibility rule, an environment read three files deep — then introduce a clock and a carrier port with fakes, and wire them in one composition root.
Reveal: the inventory
A reporting service touches: SQLAlchemy (changeable, faked → port), an FTP uploader (changeable → port), and datetime.now() for "as of" timestamps (faked in tests → port). After: three ports, three adapters, one build_reporting_app() root, and the service's tests run offline with fakes for all three.
- Explain to a junior, in two sentences, why the clock gets a port even though time will never be replaced.
- Find one function in your own code this week whose signature hides a dependency on the network, the clock, or randomness. Name its cost: which test could you not write because of it?
- Predict what happens in a year if that dependency stays hidden: which behaviour goes untested, and how do you find out it was wrong?
Your win
You can inventory a system's infrastructure, give each changeable or fakeable technology a port, and wire the whole system at one composition root — with the cost of indirection weighed honestly.
Read and watch deeper
- Architecture Patterns with Python, Percival & Gregory — ch. 2–4: the working pattern — repository, service layer, and the composition root.
- "Hexagonal Architecture", Cockburn — the origin, and the "application is a core surrounded by adapters" diagram.
- Growing Object-Oriented Software, Guided by Tests, Freeman & Pryce — ch. 2 and 8: "only mock types you own", which is this lesson's rule arrived at from the testing side.
- Clean Architecture, Martin — ch. 17–18: boundaries, the dependency rule, and crossing the boundary cleanly.
- Watch: ArjanCodes YouTube — search "ports and adapters".
- Next: lesson 0024 — keeping async out of domain logic.
- Reference: Patterns — Ports & Adapters, composition root; Design Principles — depend on abstractions.
Inventory your domain's technology imports with your agent-teacher and decide each port together.