Hexagonal Architecture
Isolate domain logic behind ports and adapters so infrastructure can change without rewriting business rules.
Hexagonal architecture (ports and adapters) places domain logic at the centre of the application and pushes technical details—HTTP, databases, message brokers, clocks, email—to the edges. The domain depends on abstractions (ports); adapters implement those ports. Direction of dependency always points inward.
Motivation
Infrastructure changes more often than business rules: swap SQL for another store, replace a REST edge with gRPC, or run the same domain in a worker. If controllers, ORMs, and brokers are woven through domain types, every swap becomes a rewrite. Hexagonal architecture makes the swap a matter of new adapters.
- Port
An interface defined by the application or domain describing a capability it needs or exposes—e.g.
IOrderRepository,IClock,IPlaceOrder.
- Adapter
A concrete implementation of a port that talks to the outside world—EF Core, Stripe SDK, ASP.NET controllers, test doubles.
Structure
Driving adapters (primary) call into the application: HTTP controllers, CLI, message consumers. Driven adapters (secondary) are called by the application: persistence, email, external APIs.
The “hexagon” is a metaphor for many sides—many adapters—around a stable core. The exact shape of folders matters less than the dependency rule.
Application services and the domain
A common layout:
| Layer | Responsibility |
|---|---|
| Domain | Entities, value objects, domain services, invariants |
| Application | Use cases / command handlers that orchestrate ports |
| Adapters | Framework and I/O glue |
public interface IOrderRepository
{
Task<Order?> GetAsync(OrderId id, CancellationToken ct);
Task SaveAsync(Order order, CancellationToken ct);
}
public sealed class PlaceOrderHandler
{
private readonly IOrderRepository _orders;
private readonly IPaymentGateway _payments;
private readonly IClock _clock;
public PlaceOrderHandler(
IOrderRepository orders,
IPaymentGateway payments,
IClock clock)
{
_orders = orders;
_payments = payments;
_clock = clock;
}
public async Task<OrderId> HandleAsync(PlaceOrderCommand cmd, CancellationToken ct)
{
var order = Order.Place(cmd.CustomerId, cmd.Lines, _clock.UtcNow);
await _payments.AuthorizeAsync(order.Total, cmd.PaymentMethodToken, ct);
await _orders.SaveAsync(order, ct);
return order.Id;
}
}Domain types should not import ASP.NET, EF Core, or messaging libraries. That keeps unit tests fast and honest: substitute fakes for ports.
Testing payoff
public class OrderService{ private readonly AppDbContext _db; public async Task PlaceAsync(PlaceOrderDto dto) { var order = new Order { ... }; _db.Orders.Add(order); await _db.SaveChangesAsync(); }}public class PlaceOrderHandler{ private readonly IOrderRepository _orders; public async Task<OrderId> HandleAsync(PlaceOrderCommand cmd, CancellationToken ct) { var order = Order.Place(...); await _orders.SaveAsync(order, ct); return order.Id; }}With ports, you test invariants without a database. Adapter tests (or integration tests) verify mapping and SQL separately.
Mapping and boundaries
Adapters translate between external shapes and domain types:
- Controllers map HTTP DTOs → commands/queries
- Repositories map rows/documents → aggregates
- Message consumers map payloads → application commands
Do not let persistence models leak into controllers “for convenience.” That short-circuit recreates the coupling hexagonal design removes.
Fit with modular systems
In a modular monolith, each module can be a small hexagon: its own ports, adapters, and domain. Module public APIs become the driving face other modules use. When you adopt event-driven architecture, the message consumer is just another driving adapter.
Concurrency strategies such as pessimistic vs optimistic locking belong in persistence adapters—or in domain versioning—without infecting use-case orchestration.
Pitfalls
- Anemic ports that mirror tables — repositories that expose
IQueryableor EF entities erase the boundary - Over-fragmentation — a port per method with no cohesion increases noise
- Circular “application” layer — use cases calling each other instead of shared domain services
- Ignoring composition — without a clear composition root, adapters and domain still tangle at startup
Related articles
Modular Monolith
Design a single deployable application as well-bounded modules with clear ownership, dependencies, and a path to extract services later.
Event-Driven Architecture
Decouple producers and consumers with events, understand delivery semantics, and keep consistency workable with outboxes and idempotency.