Hexagonal / Ports & Adapters
Hexagonal architecture (ports and adapters) places the domain and application use cases at the center. External concerns β HTTP, databases, message buses, third-party APIs β connect through ports (interfaces) and adapters (implementations). Clean Architecture applies the same dependency rule: source code dependencies point inward toward entities and use cases.
Why: protect business rules from framework churn; enable testing without DB/UI; swap adapters when requirements change (new payment provider, new UI). How: define ports in the inside, implement adapters on the outside, inject dependencies at composition root.
Entities / domain model
Business invariants and rules. Pure of frameworks.
Use cases / application services
Orchestrate entities to fulfill a requirement; define required ports.
Ports
Interfaces for driving (inbound) and driven (outbound) interactions.
Adapters
HTTP controllers, CLI, ORM repositories, email senders implementing ports.
Composition root
Wires adapters to use cases at startup β main.ts / container.
| Port kind | Example | Adapter examples |
|---|---|---|
| Driving (inbound) | PlaceOrderUseCase interface | REST controller, GraphQL, CLI |
| Driven (outbound) | PaymentPort, OrderRepository | Stripe adapter, Postgres adapter |
The domain defines what it needs (PaymentPort). Infrastructure implements it. Thus domain does not depend on Stripe SDK β Stripe depends (at runtime wiring) on the port contract. This is dependency inversion applied at architecture scale.
best practice
| 1 | src/ |
| 2 | domain/ |
| 3 | order.ts |
| 4 | order-errors.ts |
| 5 | application/ |
| 6 | place-order.ts # use case |
| 7 | ports/ |
| 8 | order-repository.ts |
| 9 | payment-port.ts |
| 10 | clock.ts |
| 11 | adapters/ |
| 12 | http/ |
| 13 | place-order.controller.ts |
| 14 | persistence/ |
| 15 | pg-order-repository.ts |
| 16 | payment/ |
| 17 | stripe-payment-adapter.ts |
| 18 | main.ts # composition root |
| 1 | export type PaymentPort = {{ |
| 2 | charge: (input: {{ customerId: string; amount: number; currency: string }}) => Promise<{{ chargeId: string }}>; |
| 3 | }}; |
| 4 | |
| 5 | export type OrderRepository = {{ |
| 6 | save: (order: Order) => Promise<void>; |
| 7 | }}; |
| 8 | |
| 9 | export function placeOrder(deps: {{ payments: PaymentPort; orders: OrderRepository }}) {{ |
| 10 | return async (cmd: {{ customerId: string; items: Item[] }}) => {{ |
| 11 | const order = Order.create(cmd.items); |
| 12 | const {{ chargeId }} = await deps.payments.charge({{ |
| 13 | customerId: cmd.customerId, |
| 14 | amount: order.total, |
| 15 | currency: "usd", |
| 16 | }}); |
| 17 | order.markPaid(chargeId); |
| 18 | await deps.orders.save(order); |
| 19 | return order.id; |
| 20 | }}; |
| 21 | }} |
| 1 | src/ |
| 2 | domain/ |
| 3 | order.py |
| 4 | application/ |
| 5 | place_order.py |
| 6 | ports.py |
| 7 | adapters/ |
| 8 | http/ |
| 9 | fastapi_routes.py |
| 10 | persistence/ |
| 11 | sqlalchemy_orders.py |
| 12 | payment/ |
| 13 | stripe_adapter.py |
| 14 | main.py |
| 1 | from dataclasses import dataclass |
| 2 | from typing import Protocol |
| 3 | |
| 4 | class PaymentPort(Protocol): |
| 5 | def charge(self, customer_id: str, amount: int, currency: str) -> str: ... |
| 6 | |
| 7 | class OrderRepository(Protocol): |
| 8 | def save(self, order: "Order") -> None: ... |
| 9 | |
| 10 | @dataclass |
| 11 | class PlaceOrder: |
| 12 | payments: PaymentPort |
| 13 | orders: OrderRepository |
| 14 | |
| 15 | def __call__(self, customer_id: str, items: list) -> str: |
| 16 | order = Order.create(items) |
| 17 | charge_id = self.payments.charge(customer_id, order.total, "usd") |
| 18 | order.mark_paid(charge_id) |
| 19 | self.orders.save(order) |
| 20 | return order.id |
| Requirement | Why hexagonal |
|---|---|
| Swap vendors / DBs without rewrite | Adapters replaceable |
| High domain complexity | Keeps rules testable and central |
| Long-lived system, changing frameworks | UI/DB churn isolated |
| Need fast unit tests without DB | Fake ports in tests |
When it may be overkill: tiny CRUD apps with stable stacks and low domain logic β a simple layered modular structure can suffice. You can still avoid framework leakage without full ports everywhere.
- Ports that leak HTTP or ORM types into domain.
- Anemic use cases + anemic entities β complexity moved nowhere useful.
- Hundreds of one-method interfaces with one implementation forever.
- Skipping the composition root and constructing adapters deep inside domain.
- Calling it Clean/Hexagonal while domain imports Express/Django models.
Clean Architecture names concentric circles: entities, use cases, interface adapters, frameworks/drivers. The rule is the same as hexagonal: dependencies point inward. Controllers and gateways are adapters; presenters shape output without leaking into entities.
| Clean circle | Hexagonal analog |
|---|---|
| Entities | Domain model |
| Use cases | Application services |
| Interface adapters | Controllers, presenters, gateways |
| Frameworks/drivers | Web framework, DB driver, devices |
- Domain tests: pure, no mocks needed beyond simple values.
- Use case tests: fake ports (in-memory repos, fake payments).
- Adapter tests: contract tests against DB or HTTP sandbox.
- Narrow E2E through driving adapters for critical journeys.
| 1 | export function fakePayment(): PaymentPort {{ |
| 2 | return {{ |
| 3 | async charge({{ amount }}) {{ |
| 4 | if (amount <= 0) throw new Error("invalid"); |
| 5 | return {{ chargeId: "ch_test_" + amount }}; |
| 6 | }}, |
| 7 | }}; |
| 8 | }} |
You rarely rewrite overnight. Strangle:
- 1. Identify a critical use case (place order).
- 2. Extract domain types used by that use case.
- 3. Define outbound ports for IO it needs.
- 4. Wrap existing ORM calls in an adapter implementing the port.
- 5. Point the controller at the new use case.
- 6. Repeat; delete dead layered pass-throughs.
note
| 1 | [ ] Domain has no framework/DB imports |
| 2 | [ ] Use cases depend on ports, not adapters |
| 3 | [ ] Composition root wires implementations |
| 4 | [ ] Tests cover use cases with fakes |
| 5 | [ ] Ports exist only where variation/test seams needed |
| 6 | [ ] ADR explains why hexagonal was chosen |
| Requirement | Hexagonal response |
|---|---|
| Annual payment vendor RFP | PaymentPort + new adapter |
| Offline mode / alternate UI | New driving adapter; same use cases |
| Deterministic domain tests in CI | Pure domain + fake ports |
| Framework migration (ExpressβFastify) | Replace HTTP adapter only |
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.