Software Design Reference Core Designer · Structuring for Stability

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?"
Field notes · what it looks like in real code
You see thisWhat it costsThe 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 SessionStorage vocabulary has crossed the boundary inward.Take domain arguments; the adapter keeps the session.
a domain test that patches requestsThe 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 adapterThe port lives outside the domain — the arrow is reversed.Move the port into the package that needs it.

Skill: which way is the arrow?

In Ports & Adapters, the port interface is owned by:

A database library's session type appears in domain classes. This 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.

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.

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

Map your project's dependency arrows with your agent-teacher — let it push back on where you claim the boundary is.