Hexagonal Architecture (Ports and Adapters)
Alistair Cockburn’s name (2005) for an architecture that isolates the application core from external concerns through explicit interfaces (“ports”) and concrete implementations (“adapters”). The hexagon shape is just a metaphor — the symmetric edges represent that any external dependency (DB, UI, queue, API) is treated the same way: an adapter plugging into a port.
For Clean Architecture (closely related) see 09_clean_architecture.md. For DDD context see 12_domain_driven_design.md.
The shape
┌───── HTTP API ─────┐
│ │
│ ┌───────────────┐ │
▼ ▼ Application ▲ ▲
── CLI ─►│ │ Core │ │ ◄── Queue Consumer
│ │ (Domain + │ │
── Web ─►│ │ Use Cases) │ │ ◄── Cron
│ │ │ │
│ └───────────────┘ │
│ │ ▲ │
▼ ▼ │ ▼
┌─────────────┐ ┌─────────────┐
│ Postgres │ │ Email API │
│ Adapter │ │ Adapter │
└─────────────┘ └─────────────┘
Driving adapters (above) call INTO the core.
Driven adapters (below) are called BY the core.
The application core is in the middle. External actors interact through adapters that translate between their world (HTTP, SQL, AMQP) and the core’s world (domain entities and use cases).
Ports and Adapters
A port is an interface declared by the core describing “what we need” or “what we offer.”
An adapter is a concrete implementation that plugs the outside world into a port.
Two kinds:
| Type | Direction | Examples |
|---|---|---|
| Driving (primary, “left side”) | adapters CALL the core | HTTP controllers, CLI commands, message consumers, schedulers |
| Driven (secondary, “right side”) | core CALLS adapters | DB repositories, email senders, payment gateways, cache |
# Driving port — what the application offers
class TransferMoneyUseCase(Protocol):
def execute(self, from_id: AccountId, to_id: AccountId, amount: Decimal) -> None: ...
# Driving adapter — how HTTP plugs in
@app.post("/transfer")
def transfer(req, uc: TransferMoneyUseCase = Depends(get_transfer_uc)):
uc.execute(req.from_id, req.to_id, req.amount)
return {"status": "ok"}
# Driven port — what the application needs
class AccountRepository(Protocol):
def get(self, id: AccountId) -> Account: ...
def save(self, account: Account) -> None: ...
# Driven adapter — how PostgreSQL plugs in
class SqlAlchemyAccountRepository:
def __init__(self, session): self.session = session
def get(self, id): return self.session.get(AccountModel, id).to_domain()
def save(self, account): self.session.merge(AccountModel.from_domain(account))
The core sees only ports. The adapters do the translation.
Dependency direction
Same as Clean Architecture: all dependencies point inward toward the core.
# core/ports.py
class AccountRepository(Protocol): # interface in the core
def get(self, id): ...
def save(self, account): ...
# adapters/postgres.py
from core.ports import AccountRepository # adapter depends on core
class SqlAlchemyAccountRepository(AccountRepository): # implements core's port
...
The core never imports from adapters. To switch from Postgres to MongoDB: write a new adapter implementing the same port; rewire in the composition root.
A concrete example — order service
# === Core: Domain ===
# core/domain/order.py
@dataclass
class Order:
id: OrderId
items: list[OrderItem]
status: OrderStatus
def add_item(self, item):
if self.status != OrderStatus.DRAFT:
raise OrderLockedError
self.items.append(item)
def total(self) -> Decimal:
return sum(i.subtotal() for i in self.items)
# === Core: Ports ===
# core/ports.py
class OrderRepository(Protocol):
def get(self, id) -> Order | None: ...
def save(self, order: Order) -> None: ...
class PaymentGateway(Protocol):
def charge(self, amount: Decimal, card_token: str) -> PaymentResult: ...
class NotificationSender(Protocol):
def notify_order_placed(self, order: Order) -> None: ...
# === Core: Use Cases ===
# core/use_cases.py
class PlaceOrderUseCase:
def __init__(self, orders: OrderRepository, payments: PaymentGateway, notifier: NotificationSender):
self.orders = orders
self.payments = payments
self.notifier = notifier
def execute(self, order_id, card_token):
order = self.orders.get(order_id)
result = self.payments.charge(order.total(), card_token)
if result.success:
order.status = OrderStatus.PAID
self.orders.save(order)
self.notifier.notify_order_placed(order)
return result
# === Driving Adapter: HTTP ===
# adapters/http/orders.py
@app.post("/orders/{order_id}/checkout")
def checkout(order_id, body, uc=Depends(get_place_order_uc)):
return uc.execute(order_id, body.card_token)
# === Driven Adapter: SQLAlchemy ===
# adapters/db/order_repo.py
class SqlAlchemyOrderRepository:
def __init__(self, session): ...
def get(self, id): ...
def save(self, order): ...
# === Driven Adapter: Stripe ===
# adapters/payments/stripe.py
class StripePaymentGateway:
def __init__(self, api_key): ...
def charge(self, amount, card_token):
... # stripe.Charge.create(...)
# === Composition Root ===
# main.py
def build_app():
session = create_session()
orders = SqlAlchemyOrderRepository(session)
payments = StripePaymentGateway(os.environ["STRIPE_KEY"])
notifier = SendgridNotifier(os.environ["SENDGRID_KEY"])
place_order_uc = PlaceOrderUseCase(orders, payments, notifier)
return create_fastapi_app(place_order_uc)
The core (Order, ports, PlaceOrderUseCase) has no imports of Flask/FastAPI/SQLAlchemy/Stripe. Pure Python. Testable with fake adapters.
Testing — the killer feature
class FakeOrderRepository:
def __init__(self): self.orders = {}
def get(self, id): return self.orders.get(id)
def save(self, order): self.orders[order.id] = order
class FakePaymentGateway:
def __init__(self, succeed=True): self.succeed = succeed
def charge(self, amount, card_token):
return PaymentResult(success=self.succeed)
class FakeNotifier:
def __init__(self): self.notifications = []
def notify_order_placed(self, order): self.notifications.append(order.id)
def test_place_order_success():
orders = FakeOrderRepository()
orders.orders[OrderId(1)] = Order(id=OrderId(1), items=[...], status=OrderStatus.DRAFT)
payments = FakePaymentGateway(succeed=True)
notifier = FakeNotifier()
uc = PlaceOrderUseCase(orders, payments, notifier)
result = uc.execute(OrderId(1), "card_token_xyz")
assert result.success
assert orders.orders[OrderId(1)].status == OrderStatus.PAID
assert OrderId(1) in notifier.notifications
Pure Python; no DB, no network, milliseconds. Tests the actual business logic without infrastructure.
Why hexagons?
The original metaphor: each edge of the hexagon represents a different external boundary. Six was chosen because it’s enough to suggest “multiple sides” without implying there’s something special about each side. Could be a pentagon or octagon. The shape doesn’t matter; the symmetry does.
The point: any external dependency — UI, DB, queue, third-party API, file system — is treated the same way. They all plug in via adapters. No external concern is “special.”
Hexagonal vs Clean vs Onion
| Hexagonal | Clean | Onion | |
|---|---|---|---|
| Author | Cockburn 2005 | Martin 2017 | Palermo 2008 |
| Naming | ports / adapters | entities / use cases / interface adapters / frameworks | domain model / domain services / app services / infrastructure |
| Layers | core + adapters | 4 explicit rings | 4 concentric layers |
| Dependency rule | core defines ports; adapters implement | dependencies point inward | dependencies point inward |
| Emphasis | symmetric I/O | layered ring structure | DDD-flavored |
Functionally equivalent. Different names emphasize different aspects. “Ports and Adapters” highlights the symmetric I/O nature; “Clean” emphasizes the dependency rule; “Onion” emphasizes the layering.
In practice, teams pick one vocabulary and stick with it. Don’t argue about which is technically correct — they’re describing the same thing from different angles.
When hexagonal pays off
- Multiple entry points: HTTP + CLI + cron + queue all triggering the same use cases.
- Replaceable adapters: switching DB, message broker, payment provider.
- Heavy testing: domain logic deserves fast, isolated tests.
- Long-lived apps: frameworks change, but business logic should be portable.
When it doesn’t
- CRUD: layers add boilerplate without payoff.
- Prototypes: write fast; refactor later.
- Microservices with one trivial endpoint: structure overhead exceeds value.
Common pitfalls
- Adapter logic in the core: if
Orderhas ato_json()method, it knows about JSON. JSON is an adapter concern. Move to a presenter. - Ports defined outside the core: defeats the dependency rule. Ports MUST live in the core (or use-case ring); adapters depend inward.
- Anemic domain: rich domain pays off proportionally to its complexity. With a thin domain, the structure is heavy for what it carries.
- Adapter-per-method: one adapter that does everything is fine. Splitting one external API into 5 adapters is over-engineering.
- Use cases that are just thin wrappers around repositories:
GetOrderUseCasereturningrepo.get(id). Skip the use case for trivial reads; call the repo directly from the controller. Pragmatism over dogma.
Common interview confusions
- “Hexagonal means six layers.” — six is just the shape’s number of sides. Could be any polygon. The shape conveys “multiple sides, all equal.”
- “Hexagonal and Clean are different patterns.” — same idea, different names. Pick one vocabulary for the team.
- “Ports are the same as ORM models.” — ports are interfaces in the core. ORM models live in adapters. They might be related (the adapter implements a port by querying ORM models) but they aren’t the same.
Interview angle
- “What is Hexagonal Architecture?” — Alistair Cockburn’s name for an architecture that isolates the application core from external concerns through ports (interfaces) and adapters (implementations). External actors interact only through adapters. The shape is a metaphor for symmetric I/O.
- “What’s a port vs an adapter?” — port: an interface declared by the core saying “what we need” or “what we offer.” Adapter: concrete implementation. Driving adapters call the core (HTTP, CLI); driven adapters are called by the core (DB, email).
- “Hexagonal vs Clean Architecture?” — functionally equivalent. Clean adds explicit named rings (Entities, Use Cases, Interface Adapters, Frameworks); Hexagonal frames around the I/O symmetry. Same dependency rule (inward only). Pick a vocabulary.
- “How does hexagonal make testing easier?” — the core has no framework dependencies; you test domain logic with fake adapters (in-memory repositories, stub payment gateways). Tests are pure-Python, milliseconds fast, deterministic.
- “When would you NOT use hexagonal?” — CRUD apps where the structure overhead exceeds the value; prototypes; small services with one endpoint and trivial logic.
- “What goes in a port?” — interfaces describing the core’s needs (repositories, gateways, notifiers, event publishers) or offerings (use case signatures). Defined in the core; implemented in adapters.
- “How do you wire ports to adapters?” — composition root: a single function or class that instantiates concrete adapters and injects them into use cases. Typically
main.pyor abuild_app()factory. Everywhere else, code depends on the port interface.