backend / architecture design / 11_hexagonal_ports_adapters.md

Hexagonal Architecture (Ports and Adapters)

7 interview angles 7 min read source

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 Order has a to_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: GetOrderUseCase returning repo.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.py or a build_app() factory. Everywhere else, code depends on the port interface.