Lesson 0009 · Core Designer · Module 2
Controlling Dependency Direction
The most important arrows in your architecture point at the domain. Ports & Adapters is the mechanism for drawing them correctly — this lesson is the drawing lesson.
Mission tie-in: dependency direction is what makes the domain your asset. Every later phase builds on this: repositories, infrastructure isolation, event-driven systems.
Knowledge: the arrow points inward
In a well-directed system, the domain — the rules that are true regardless of technology — depends on nothing. The world (databases, APIs, files, clocks) depends on the domain. The domain declares ports: the interfaces it needs. The world supplies adapters: implementations of those ports.
# the domain defines the port it needs:
class PaymentGateway(Protocol):
def charge(self, amount: Money, token: str) -> str: ...
class CheckoutService:
def __init__(self, gateway: PaymentGateway, orders: OrderRepository) -> None:
self._gateway = gateway
self._orders = orders
def checkout(self, cart: Cart, token: str) -> Order:
charge_id = self._gateway.charge(cart.total, token)
order = Order.from_cart(cart, charge_id)
self._orders.save(order)
return order
Now the adapter, living outside the domain:
# infrastructure provides the adapter:
class StripeAdapter:
def __init__(self, client: StripeSDK) -> None:
self._client = client
def charge(self, amount: Money, token: str) -> str:
return self._client.payment_intents.create(...).id
Who owns the interface? The domain does. That single fact is the whole pattern. If the payment SDK defines what your checkout logic looks like, the arrow points the wrong way.
Test it: swap Stripe for a fake adapter and run checkout with no network, no SDK, no mocks — the domain never knows. The port made that possible.
† Where do ports live? At the application boundary, not between every pair of classes. Ports answer "what does the domain need from the world?" — not "what interface should this class have?"Dependency direction decides whose calendar you are on. Code that depends on X has to be revisited when X changes. Your business rules change on your company's schedule; a payment SDK, a cloud API, and an ORM change on somebody else's, announced by a release note you did not read. Pointing the arrows inward means the parts you control absorb the parts you do not — at one place per dependency, instead of everywhere the dependency is mentioned.
The second mechanism is subtler and more valuable: inversion changes who shapes the interface. An interface defined by the supplier is as wide as the supplier's feature list — forty methods, because forty customers wanted different things. An interface defined by the consumer is as wide as the consumer's need, which is usually three. That difference is not aesthetic. A three-method port is something a person can hold in their head, fake in four lines, and reason about completely; a forty-method interface is a second system to learn.
The third is that testability is a consequence, not a goal. A test is just another adapter. If the domain can be driven by a fake, it has no hidden dependencies; if it cannot, it does. This makes test difficulty the cheapest measuring instrument you have for dependency direction — you do not need a diagram or a tool, you need to try to write the test and notice what it demands.
Where it stops applying. This costs a level of indirection and a composition root, and in a five-hundred-line service whose "domain" is a database schema with validation, that is ceremony protecting nothing (see lesson 0015). It also breaks down when the port would have to hide something that genuinely cannot be hidden — transactions, streaming, pagination, or a query language. A repository port that pretends the database has no way to express "the fifty most recent, joined to their lines" ends up either re-implementing a query language badly or leaking one through a string. And ports belong at the application boundary, not between every pair of classes: an interface defined next to its single implementation, in the same package, protects nothing and is not a port however much it looks like one.
How a senior designer audits an existing hexagon
A team whose README says "hexagonal architecture" and whose codebase has fourteen ports.
The first thing they check is not the arrows but the implementations. For each port: how many classes implement it, counting the test fake? A port with two or more is doing work. A port with exactly one implementation and no fake is unpaid indirection — it obeys the letter of the pattern and buys nothing, and there are usually four or five of them.
The question they ask per port: name the thing on the other side you might have to replace, or the test that needs the seam. If neither answer exists, the port exists because the pattern was applied, not because a problem was solved. Deleting it is a legitimate outcome of an architecture review, and saying so is what stops a good idea from becoming a ritual.
Then the interesting case. One port is ReportRepository, and its single method is:
class ReportRepository(Protocol):
def run(self, sql: str) -> list[dict]: ...
Every dependency arrow points the right way. The domain imports no database driver. And the boundary is gone anyway, because the database's language crossed it inside a string: the domain is now writing SQL, and swapping the adapter would require rewriting every caller. This is the case worth memorising — you can satisfy the import graph and still lose the boundary, because ports are judged by vocabulary, not by the shape of the imports.
The alternative they reject. The obvious repair is to add a method to the port for each report: top_customers(), refunds_by_week(), and so on. Follow that and in a year the port has forty methods, every one of which is a query with a name — the ORM, reimplemented, with a worse query planner and no composability. The tell: the interface grows one method per use case and never stabilises.
The call they make instead is to stop pretending reporting is domain logic. Reads that answer questions — dashboards, exports, analytics — are given their own module that talks to the database directly and returns plain rows, while the port stays for the transactional domain where invariants actually live. This is not a compromise of the pattern; it is the recognition that the pattern protects rules, and a report has none.
The consequence, measured. While every new report went through the domain port, it needed a change to the interface, a change to the adapter, and a review from the domain owner: median lead time nine days for something an analyst could describe in a sentence. After the read path was split out, the same work was one module and one reviewer, and shipped the same week. The ports that remained got taken seriously again, because they were no longer in the way of work they had nothing to do with.
| You see this | What it costs | The move |
|---|---|---|
| import stripe inside domain/ | The vendor now defines what your checkout means. | Declare the port in the domain; the adapter imports the SDK. |
| a domain method taking Session | Storage vocabulary has crossed the boundary inward. | Take domain arguments; the adapter keeps the session. |
| a domain test that patches requests | The domain performs I/O, so the test must fake a network. | Inject a fake port; delete the HTTP mock entirely. |
| the Protocol defined beside its adapter | The port lives outside the domain — the arrow is reversed. | Move the port into the package that needs it. |
When this is the wrong move
Do not build a hexagon around a service whose domain is its schema. If the business rules amount to "validate these fields and store the row", the ports are ceremony and the composition root is a second thing to maintain; a straightforward layered service is honest and cheaper. Do not build a port for something you cannot actually replace — your language runtime, your cloud provider's identity model, the framework your whole company standardised on. The insurance premium is real and the claim will never be filed. And do not force read-heavy paths through a domain port: hiding a query language costs more than it protects when there are no invariants on the other side. Rules go through the port; reports go around it.
- Any interface is a port.
- A port is an interface owned by the consumer, at an application boundary. An interface declared next to its only implementation, in the same package, is a naming convention. The ownership and the location are the whole content of the idea.
- Ports and Adapters is a folder structure.
- It is a rule about arrows. Folders are a way of making violations visible, and an import-contract check makes them fail the build — but you can have perfect folders and a domain full of vendor vocabulary, which is worse than untidy folders and a clean dependency direction.
- The domain must depend on nothing at all.
- It must depend on nothing volatile.
Decimal,datetime, and your own value objects are fine, and purity for its own sake produces a domain that cannot express dates. The goal is insulation from other people's release schedules, not from the standard library. - Using a repository interface means the domain is decoupled from the database.
- Not if the interface speaks the database's language. Sessions, query objects, lazily-loaded relations and raw SQL strings in the signature all mean the boundary is decorative. Read your port's method signatures and ask whether they would still make sense if the storage were a file.
Skill: which way is the arrow?
In Ports & Adapters, the port interface is owned by:
A ReportRepository port whose only method takes a SQL string is:
The checkout domain can run against a fake gateway because:
Practice on your own code
Pick one external thing your code touches (a database, an API, a filesystem). Draw the arrow: does your domain's shape follow the external thing, or does the external thing conform to your domain? If the former, introduce a port — even if only for that one dependency. Then run the audit in the other direction: for every interface you already call a port, name the second implementation or the test that needs it. Any port that fails that question is a candidate for deletion, and deleting it is as much a win as adding one.
In the lab: the returns-resolution service imports a carrier client and a payments client directly inside its decision logic. The phase-1 checkpoint asks you to introduce one port shaped by what the decision actually needs — not by what the vendor offers — and to prove it by running the resolution rules in a test with no network and no mocking library.
Reveal: an arrow audit
A metrics module calls boto3 directly inside a record_event function. The arrow points at AWS. Port: MetricsSink.record(event). Adapter: CloudWatchSink. Tests now run with an in-memory sink; the domain's "record an event" meaning is no longer AWS's to define.
- Explain to a junior, in two sentences, why "the domain owns the interface" is a statement about release schedules rather than about tidiness.
- Find one dependency in your own code this week whose arrow points outward, and name the cost: what a test of the surrounding logic currently requires, and how many files a vendor change would touch.
- Predict what happens in a year if a vendor's vocabulary keeps spreading inward — which word from their documentation ends up in your domain model's method names, and who will have to explain it to a new hire.
Your win
You can audit any dependency for arrow direction, and you know the one rule that decides it: the domain owns the interface. The full infrastructure application comes in lesson 0023.
Read and watch deeper
- "Hexagonal Architecture", Alistair Cockburn — the original statement of ports and adapters.
- Architecture Patterns with Python, Percival & Gregory — ch. 2 "Repository Pattern" and ch. 3, "A Brief Interlude: On Coupling and Abstractions": the Python working version of this lesson, including the fake repository that proves the seam.
- Clean Architecture, Martin — ch. 17–18 on drawing boundaries and their anatomy, and ch. 22, where the dependency rule is stated in its strongest form.
- import-linter — the tool that turns this lesson's arrow into a build failure. A rule your CI enforces outlives the review that agreed on it (lesson 0022).
- Watch: ArjanCodes YouTube — search "ports and adapters" or "hexagonal architecture".
- Reference: Glossary — port, adapter, dependency direction.
Map your project's dependency arrows with your agent-teacher — let it push back on where you claim the boundary is.