Summary
Hexagonal Architecture (also called Ports and Adapters) structures an application so that the core domain logic is entirely independent of external systems — databases, APIs, UIs, and messaging infrastructure. External interactions flow through defined ports (interfaces) and are wired up via adapters at runtime.
Problem
Application logic becomes tightly coupled to infrastructure concerns: database queries embedded in business logic, HTTP transport leaking into domain models, testing requiring live external services.
- How do you test business logic without a real database?
- How do you swap a REST API for a message queue without rewriting the core?
- How do you prevent delivery mechanism concerns from polluting domain code?
Solution
Define the application core as a set of ports — interfaces that describe what the application needs (driven/secondary ports) or what it exposes to callers (driving/primary ports). Adapters implement those interfaces and translate between the external world and the application.
[ UI Adapter ] [ CLI Adapter ]
\ /
[ Primary Port (inbound) ]
|
[ Application Core ]
|
[ Secondary Port (outbound) ]
/ \
[ DB Adapter ] [ HTTP Adapter ]
The core contains use cases and domain logic. It never imports adapters. Adapters import the core and implement or call its ports.
// Secondary port — defined by the core, implemented by an adapter
interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
}
// Application core — depends only on the port interface
class PlaceOrderUseCase {
constructor(private readonly orders: OrderRepository) {}
async execute(cmd: PlaceOrderCommand): Promise<void> {
const order = Order.create(cmd.items, cmd.customerId);
await this.orders.save(order);
}
}
// Adapter — implements the port, owns the infrastructure detail
class PostgresOrderRepository implements OrderRepository {
constructor(private readonly db: Pool) {}
async save(order: Order): Promise<void> {
await this.db.query(
`INSERT INTO orders (id, customer_id, items) VALUES ($1, $2, $3)`,
[order.id, order.customerId, JSON.stringify(order.items)]
);
}
async findById(id: string): Promise<Order | null> {
const row = await this.db.query(`SELECT * FROM orders WHERE id = $1`, [id]);
return row ? Order.fromRow(row) : null;
}
}
// Wiring — swapping to an in-memory adapter for tests requires no core changes
const useCase = new PlaceOrderUseCase(new InMemoryOrderRepository());
PlaceOrderUseCase never references Postgres. A test suite can inject InMemoryOrderRepository and exercise the same domain logic with zero infrastructure.
The same shape holds in Python, using Protocol for the port and structural typing for the adapters:
from typing import Protocol, Optional
# Secondary port — defined by the core, implemented by an adapter
class OrderRepository(Protocol):
async def save(self, order: "Order") -> None: ...
async def find_by_id(self, order_id: str) -> Optional["Order"]: ...
# Application core — depends only on the port protocol
class PlaceOrderUseCase:
def __init__(self, orders: OrderRepository) -> None:
self._orders = orders
async def execute(self, cmd: "PlaceOrderCommand") -> None:
order = Order.create(cmd.items, cmd.customer_id)
await self._orders.save(order)
# Adapter — implements the port, owns the infrastructure detail
class PostgresOrderRepository:
def __init__(self, pool: "Pool") -> None:
self._pool = pool
async def save(self, order: "Order") -> None:
await self._pool.execute(
"INSERT INTO orders (id, customer_id, items) VALUES ($1, $2, $3)",
order.id, order.customer_id, order.items,
)
async def find_by_id(self, order_id: str) -> Optional["Order"]:
row = await self._pool.fetchrow("SELECT * FROM orders WHERE id = $1", order_id)
return Order.from_row(row) if row else None
# Wiring — swapping to an in-memory adapter for tests requires no core changes
use_case = PlaceOrderUseCase(InMemoryOrderRepository())
Because OrderRepository is a Protocol, PostgresOrderRepository and InMemoryOrderRepository never need to declare the interface explicitly — the core depends on shape, not inheritance, keeping adapters free to evolve independently.
When to Use
- Applications with complex business logic that must be unit-tested in isolation
- Systems that need to support multiple delivery mechanisms (REST, GraphQL, CLI, event-driven)
- Projects expecting to swap infrastructure components (e.g., migrating databases)
- Teams enforcing strict separation between domain and technical concerns
Avoid when:
- CRUD-heavy applications with minimal domain logic — the abstraction overhead outweighs the benefit
- Small services where the complexity of ports/adapters isn’t justified
Trade-offs
| Benefit | Cost |
|---|---|
| Core logic is fully unit-testable without infrastructure | More boilerplate: interfaces, adapters, wiring |
| Infrastructure is swappable without touching domain code | Indirection can make control flow harder to trace |
| Clear dependency direction enforced by structure | Requires discipline to avoid leaking adapters into core |
| Supports parallel development (core vs. adapters) | Initial setup cost is higher than layered architecture |
Live Playground
Experiment with the pattern below. before.ts couples the use case directly to Postgres; after.ts extracts an OrderRepository port and swaps in an in-memory adapter with zero changes to the core. Edit either file and the preview updates in real time.
Related Patterns
- Dependency Injection — The mechanism used to wire adapters into ports at runtime
- Strategy Pattern — Ports are essentially named strategy interfaces
- CQRS — the command and query ports are a natural expression of hexagonal thinking; each is an inbound port with a dedicated adapter
- Single Responsibility — hexagonal architecture is SRP applied at the architectural boundary: domain, ports, and adapters each own exactly one concern
- Multi-Database Orchestration — each database container is an adapter behind a port; swapping between local containers and managed services is a pure adapter swap