onse
Architecture

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:

LayerResponsibility
DomainEntities, value objects, domain services, invariants
ApplicationUse cases / command handlers that orchestrate ports
AdaptersFramework 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

Domain coupled to EF
public class OrderService{  private readonly AppDbContext _db;  public async Task PlaceAsync(PlaceOrderDto dto)  {      var order = new Order { ... };      _db.Orders.Add(order);      await _db.SaveChangesAsync();  }}
Domain behind a port
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 IQueryable or 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

On this page