Hexagonal Architecture
Ports and Adapters — domain in the middle, I/O at the edges, and the testability win that makes the ceremony worthwhile
Hexagonal Architecture
Alistair Cockburn published Hexagonal Architecture in 2005 with a simple proposition: structure your application so the domain logic does not know what kind of process is on the other side. A test, an HTTP request, a CLI invocation, a Kafka message, a batch file — to the domain, they are all the same. The domain exposes ports (interfaces); the outside world wires up adapters to those ports.
The name comes from Cockburn's original drawing, which used a hexagon to convey "there are several sides, none privileged." It could just as well have been an octagon; the shape is incidental. The substance is the strict separation between domain logic and the channels through which it is invoked or stored.
Hexagonal Architecture is the operational mechanism that Clean Architecture and Onion Architecture describe in different terms. Most practitioners use the three names interchangeably; this page focuses on the ports-and-adapters mechanism specifically.
The Picture
Driving adapters Driven adapters
(call into the domain) (called by the domain)
HTTP controller ──┐ ┌── Postgres repository
CLI command ──────┤ ├── S3 file store
Test runner ──────┼─▶ DOMAIN ──┐──┼── External payment SDK
Kafka consumer ───┤ │ ├── Email sender
gRPC handler ─────┘ │ └── Audit log writer
│
driving ports │ driven ports
(inbound) │ (outbound)
▼
domain logic
(entities, use cases,
business rules)The domain is the hexagon. It knows about business rules and nothing about HTTP, SQL, message brokers, file systems, or test frameworks.
Ports are interfaces declared by the domain:
- Driving ports (sometimes called primary or inbound) are the operations external actors can invoke.
PlaceOrder(req),CancelReservation(id). The domain says "here is what I do." - Driven ports (sometimes called secondary or outbound) are the operations the domain needs from the outside world.
SaveOrder(order),ChargeCard(...). The domain says "here is what I need."
Adapters are implementations of ports:
- Driving adapters translate external calls into domain calls. An HTTP controller receives a POST and calls
PlaceOrder. A Kafka consumer receives a message and calls the samePlaceOrder. - Driven adapters translate domain calls into external system actions. A
PostgresOrderRepositoryimplementsOrderRepository. AStripePaymentGatewayimplementsPaymentGateway.
The composition root (your main) wires concrete adapters to ports at startup.
A Worked Example
A simple "place order" flow:
// === DOMAIN ===
// Driving port: what external actors can ask the domain to do
interface PlaceOrderUseCase {
execute(req: PlaceOrderRequest): Promise<PlaceOrderResponse>
}
// Driven ports: what the domain needs from the outside
interface OrderRepository {
save(order: Order): Promise<void>
}
interface PaymentGateway {
charge(amount: Money, card: Card): Promise<Receipt>
}
interface NotificationService {
sendOrderConfirmation(order: Order): Promise<void>
}
// Domain logic: depends on the ports, not on any specific adapter
class PlaceOrder implements PlaceOrderUseCase {
constructor(
private orders: OrderRepository,
private payments: PaymentGateway,
private notifications: NotificationService,
) {}
async execute(req: PlaceOrderRequest): Promise<PlaceOrderResponse> {
const order = Order.create(req.items, req.customer)
const receipt = await this.payments.charge(order.total, req.card)
order.markPaid(receipt)
await this.orders.save(order)
await this.notifications.sendOrderConfirmation(order)
return { orderId: order.id, receipt }
}
}
// === ADAPTERS ===
// Driving adapter: HTTP
class OrderController {
constructor(private usecase: PlaceOrderUseCase) {}
async post(req: HttpRequest): Promise<HttpResponse> {
const result = await this.usecase.execute(parseBody(req))
return { status: 201, body: result }
}
}
// Driven adapter: Postgres
class PostgresOrderRepository implements OrderRepository {
async save(order: Order): Promise<void> { /* SQL */ }
}
// Driven adapter: Stripe
class StripePaymentGateway implements PaymentGateway {
async charge(amount: Money, card: Card): Promise<Receipt> { /* Stripe API */ }
}
// === COMPOSITION ROOT ===
const usecase = new PlaceOrder(
new PostgresOrderRepository(pgClient),
new StripePaymentGateway(stripeClient),
new EmailNotificationService(sendgridClient),
)
const controller = new OrderController(usecase)
httpServer.route("POST", "/orders", controller.post.bind(controller))What Hexagonal Architecture Gives You
Three concrete wins:
Adapter Swappability
The same domain logic runs in production, in tests, and in alternative deployments. The driven adapters change; the domain does not.
// In tests, substitute in-memory adapters:
const usecase = new PlaceOrder(
new InMemoryOrderRepository(),
new FakePaymentGateway(), // always succeeds, records the charge
new NoOpNotificationService(),
)
// Run the test against the real PlaceOrder logic, fast and deterministic.This is the strongest argument for Hexagonal. Tests that exercise business rules without a database, an HTTP server, or a payment provider stay fast and reliable.
Inversion of the Frameworks
Most frameworks want to be at the center of your application. Rails generates models that inherit from ActiveRecord::Base. Django models inherit from django.db.models.Model. Spring controllers extend framework classes. Hexagonal Architecture inverts this: the framework is one of the adapters, not the architectural center.
This costs more boilerplate up front but means swapping frameworks (Rails → Phoenix, Express → Fastify) is a controller-layer change, not a rewrite.
Multiple Driving Channels
The same use case can be invoked from many places without code duplication. Place an order from HTTP, from a CLI tool, from a Kafka consumer, from a recurring batch job — all wire up to the same PlaceOrderUseCase.
This pays off when an application grows beyond a single delivery channel.
What Hexagonal Architecture Costs
The same costs as Clean Architecture:
- Boilerplate. Interfaces, implementations, DTOs at the boundary.
- Indirection. Reading the code requires stepping through interface layers.
- Wrong default for many apps. Simple CRUD does not need port/adapter ceremony.
- Discipline required. Without rigor, the domain layer leaks framework dependencies and the architecture becomes ceremony without benefit.
When to Use Hexagonal
The signals are the same as Clean Architecture, with one specific addition: you have, or expect, multiple driving channels. If the same business logic will be invoked from HTTP and a message queue and a batch job, Hexagonal pays back faster than in single-channel applications.
The other strong signal: you want fast, deterministic tests of business rules. Hexagonal makes this almost free — the test is just a driving adapter, and driven adapters can be in-memory.
When Not to Use It
- One driving channel, simple domain. A CRUD admin tool driven by HTTP only. The framework's defaults are fine.
- Throwaway code or prototypes. Hexagonal's payoff is over months and years; for code that may not survive a quarter, do not pay it.
- Heavy framework reliance. Rails / Django / Phoenix idioms produce different code shapes; trying to apply Hexagonal on top yields the worst of both.
Hexagonal vs Clean vs Onion
The three are close enough that arguments about which is "correct" are mostly aesthetic.
| Pattern | Emphasis | Diagram |
|---|---|---|
| Hexagonal | The boundary mechanism: ports and adapters | Hexagon with adapters around the edges |
| Onion | Concentric layers of stability | Onion: inner core, application services, outer layers |
| Clean | Synthesis of Hexagonal + Onion + others | Concentric circles labeled Entities / Use Cases / Adapters / Frameworks |
Practitioners use the names interchangeably and that is fine. The substance is consistent: business logic in the middle, infrastructure at the edges, dependencies pointing inward.
Common Mistakes
- Ports without inversion. The "port" is the same class as the database client. No abstraction, no benefit.
- Adapter as namespace. Treating "adapter" as just a folder for infrastructure code without making the domain depend on an interface. The folder structure looks right; the dependencies do not.
- Driving the domain through driven ports. Using a repository's
findfrom a controller to populate a view, bypassing the use case. Now business logic exists in the controller. - Use case per HTTP endpoint. One-to-one mapping between routes and use cases produces shallow use cases that are pass-through layers. Use cases are domain operations, not URL handlers.
- Hexagonal with framework leakage. Importing the framework's HTTP types into a "domain" interface. The hexagon's boundary has been breached.
Relation to Other Pages
- Clean Architecture — the synthesis that includes Hexagonal as its outer mechanism.
- Onion Architecture — the third name for substantially the same idea.
- DDD Tactical Patterns — Hexagonal pairs naturally with DDD; the domain in the hexagon is typically an aggregate-based DDD model.
- Anticorruption Layer — a driven adapter that wraps a foreign system's model.
- Modular Monolith — each module of a modular monolith is often structured hexagonally.
Further Reading
- Alistair Cockburn, Hexagonal Architecture (2005) — the original article. Two pages, the entire idea.
- Alistair Cockburn, Hexagonal Architecture revisited (2018) — Cockburn's own retrospective.
- Tom Hombergs, Get Your Hands Dirty on Clean Architecture (2019) — a small, practical book that uses Hexagonal vocabulary throughout.
- Vaughn Vernon, Implementing Domain-Driven Design (2013) — Chapter 4 explicitly discusses Hexagonal in a DDD context.
- Wlaschin, Domain Modeling Made Functional — a functional take on the same idea using the F# type system.
Pre-commit Checklist
- For each module structured hexagonally, do I have multiple driving channels (HTTP + something else), or is the ceremony unjustified?
- Are my ports interfaces owned by the domain — and do the adapters depend on the domain, not the other way?
- Is there a fast test suite that exercises business rules through driving ports with in-memory driven adapters?
- Have I avoided letting framework types leak into the domain's ports?
- Are my use cases doing real domain orchestration, or are they URL handlers in disguise?
- Have I avoided cargo-cult Hexagonal — interfaces and adapters that add ceremony without enabling the swappability they imply?