Hexagonal architecture is frequently presented as a diagram of concentric rings and adopted as a directory structure, which is how teams end up with four layers of interfaces wrapping a database call that will only ever have one implementation. The idea underneath is narrower and genuinely useful: the code that encodes your business rules should not depend on the code that talks to the outside world.
The test of whether you have it is not the folder layout. It is whether you can change your database, your queue, or your payment provider without editing anything that expresses a business rule.
Where the boundary earns its keep
It pays wherever the outside thing is genuinely likely to change or genuinely hard to test. Payment providers get replaced. Third-party APIs change their contract. A notification channel becomes three channels. For these, an interface owned by your domain — with adapters implementing it — means the change is contained to one file, and you get a test double for free.
It does not pay for your primary database. You are not swapping PostgreSQL for MongoDB, and the repository interface that exists to make that theoretically possible costs you every day: queries become awkward, the ORM's useful capabilities are hidden behind a lowest-common-denominator interface, and the abstraction leaks the moment someone needs a window function.
# The domain defines what it needs, in its own vocabulary.
class PaymentGateway(Protocol):
def charge(self, amount: Money, token: CardToken) -> ChargeResult: ...
def refund(self, charge_id: ChargeId, amount: Money) -> RefundResult: ...
# The domain service depends on the protocol and knows nothing about HTTP,
# retries, or which provider is behind it.
class Billing:
def __init__(self, gateway: PaymentGateway, ledger: Ledger):
self._gateway, self._ledger = gateway, ledger
def settle(self, invoice: Invoice) -> None:
result = self._gateway.charge(invoice.total, invoice.card_token)
if result.declined:
raise PaymentDeclined(result.reason) # a domain concept
self._ledger.record(invoice, result.charge_id)
# The adapter owns everything provider-specific: SDK, retries, error mapping.
class StripeGateway:
def charge(self, amount: Money, token: CardToken) -> ChargeResult:
try:
intent = stripe.PaymentIntent.create(...)
except stripe.error.CardError as e:
return ChargeResult.declined(reason=map_decline_code(e.code))
return ChargeResult.ok(ChargeId(intent.id))The detail that makes this work is that ChargeResult and PaymentDeclined are your types, not the provider's. If the provider's exception classes reach your domain code, you have written a wrapper rather than a boundary, and swapping providers will still touch everything.
Keeping it honest
- Introduce a port when you have the second implementation or a genuine testing need — not in anticipation of one.
- The interface belongs to the domain and is expressed in domain language. An interface that mirrors a vendor SDK is not a port.
- Enforce the dependency direction in CI. Without a check, the first urgent fix will import the adapter directly and nobody will notice.
- Keep domain code free of framework types. Once a request object or an ORM model is a parameter, the boundary is gone.
- Do not wrap the database in a repository interface for swappability. Wrap it if the query logic is worth testing without a database — a different and more honest reason.
The goal is not layers. It is that the code encoding your business rules never has to change because a vendor did.
Applied selectively, this is one of the highest-value structural patterns available, and it shows up most clearly in the test suite: business logic tested in milliseconds with no infrastructure, adapters tested against the real thing on a slower schedule. Applied dogmatically to every dependency, it produces a codebase where finding the line that actually does something requires opening five files, and that experience is why many engineers dismiss the idea entirely.