Blog/Hexagonal Architecture: Ports, Adapters, and Why They Free Your Domain
hexagonal-architectureclean-architecturedomain-driven-designtestingdesign-patterns

Hexagonal Architecture: Ports, Adapters, and Why They Free Your Domain

February 14, 2024·14 min read·by Bishwambhar Sen
Hexagonal diagram showing domain core surrounded by inbound and outbound ports with adapter implementations on the outside

Alistair Cockburn coined the term "Hexagonal Architecture" in 2005, and the name has caused confusion ever since. The hexagon is not significant — it is not a six-layer system, and there is nothing special about the number of sides. The hexagon is simply a visual device to emphasize that a system has many sides, not just a "front" (UI) and a "back" (database).

The actual idea is simpler and more powerful: your domain logic should have no compile-time dependency on any infrastructure. Not on your ORM. Not on your message broker. Not on your HTTP framework. Not on your logging library. Infrastructure concerns flow inward through ports — interfaces defined by the domain — and are fulfilled by adapters — implementations defined in the outer layer. The domain remains unaware that the adapter exists.

The result is a codebase where you can test an entire business workflow — including persistence, messaging, and external API calls — using in-memory fakes, without mocking frameworks, without a running database, and in milliseconds rather than seconds.

Concept

Ports: The Domain's Declared Needs

A port is an interface defined inside the domain layer. It expresses what the domain needs from the outside world, in domain terms. The key word is "domain terms." An inbound port might be IProcessRefundUseCase — an interface the outer world calls to trigger domain logic. An outbound port might be IRefundRepository or IPaymentGateway — interfaces through which the domain pushes data or calls external systems.

Ports come in two directions:

Inbound ports (driving ports): Called by the outside world — an HTTP controller, a message consumer, a CLI command — to trigger domain logic. The domain defines the interface; the calling adapter invokes it. This is where use cases live.

Outbound ports (driven ports): Called by the domain logic to communicate with the outside world. The domain defines the interface in its own vocabulary; the infrastructure adapter implements it. IOrderRepository is defined in the domain; SqlOrderRepository is defined in the infrastructure layer and depends on Entity Framework. The domain only ever references the interface.

Adapters: The Infrastructure Implementation

An adapter is a class in the outermost layer that implements a port interface and bridges it to a specific technology. There are two symmetrical families:

Primary (inbound) adapters: These are the entry points to the system. An ASP.NET Controller is a primary adapter — it receives an HTTP request, translates it into a command or query, and calls an inbound port on the application layer. A Kafka consumer background service is a primary adapter. A gRPC handler is a primary adapter.

Secondary (outbound) adapters: These implement outbound ports and speak to infrastructure. SqlOrderRepository : IOrderRepository, StripePaymentGateway : IPaymentGateway, EmailSmtpNotificationSender : INotificationSender — these live in infrastructure projects, depend on the domain (for the interfaces and domain types), but are never referenced by the domain itself.

Clean Architecture vs. Hexagonal Architecture

Uncle Bob's Clean Architecture and Hexagonal Architecture express the same dependency inversion idea but use different terminology and visual metaphors. Clean Architecture layers the system into concentric rings: Entities, Use Cases, Interface Adapters, Frameworks & Drivers. Hexagonal Architecture speaks of domain, ports, and adapters.

The practical difference is emphasis. Clean Architecture adds a distinct "Interface Adapters" ring that translates between the domain model and the external representation (DTOs, view models). Hexagonal Architecture tends to merge this translation concern into the adapter itself.

Both enforce the Dependency Rule: source code dependencies can only point inward — inner layers never reference outer layers.

Testing Without Infrastructure

The core payoff of Hexagonal Architecture is the ability to wire in-memory adapters for all outbound ports during testing. Your test configures an InMemoryOrderRepository, an InMemoryPaymentGateway that simulates success or failure on demand, and an InMemorySmsNotifier that captures sent messages. The use case under test runs against these fakes using the exact same code path that runs in production, with no mocking framework needed.

This produces tests that are fast (no I/O), deterministic (no network), and high-signal (they exercise real domain logic rather than mock expectations).

Constraints

Port proliferation: In a large domain, the number of outbound ports can grow significantly. Every external dependency — repository, message bus, gateway, clock, ID generator — should ideally be behind a port. Teams that are new to the pattern sometimes resist this granularity and collapse multiple concerns into one port, which reduces the testability benefit.

DTO mapping overhead: Each adapter must translate between its native representation and the domain model. An HTTP adapter translates JSON into commands; a SQL adapter maps OrderRow objects into Order aggregates. This translation code is often boilerplate-heavy. In small systems, this overhead can feel disproportionate.

Adapter composition root: Someone has to wire all the ports to their adapter implementations. In ASP.NET Core this is the dependency injection container, configured at startup. The composition root becomes a high-churn file as new adapters are added, and it must be organized carefully to remain readable.

Shared kernel vs. duplicated types: If multiple bounded contexts exist in the same solution, they may each define their own ports for similar infrastructure concerns (each has an IOrderRepository). This is correct by DDD principles but requires discipline to prevent developers from collapsing distinct contexts into a shared repository layer.

Trade-offs

Hexagonal Architecture pays its dividends at scale — when the system is large enough that changing infrastructure is a real concern, when the test suite would otherwise be slow due to database dependencies, and when multiple teams need to work independently on different adapters.

For a small API with three endpoints and a single PostgreSQL database, the overhead of defining ports, writing adapters, and managing the mapping layer is almost certainly larger than the benefit. The pattern becomes attractive as the number of external dependencies grows, as the domain logic becomes complex enough to warrant first-class testability, and as the team grows large enough that module boundaries provide meaningful isolation.

The other trade-off is learnability. Developers unfamiliar with the pattern take time to understand why an interface defined in the domain and an implementation in infrastructure creates value. This is an onboarding cost that must be weighed against the long-term maintainability benefit.

Code

Domain Port Definitions

The domain project contains no using statements referencing infrastructure libraries. It defines its needs through interfaces.

// Domain/Ports/Outbound/IOrderRepository.cs
// Lives in the domain layer — no infrastructure dependency
namespace MPC.Domain.Ports.Outbound;

public interface IOrderRepository
{
    Task<Order?> FindByIdAsync(OrderId orderId, CancellationToken cancellationToken);
    Task SaveAsync(Order order, CancellationToken cancellationToken);
}

// Domain/Ports/Outbound/IPaymentGateway.cs
public interface IPaymentGateway
{
    Task<PaymentResult> ChargeAsync(
        PaymentMethod paymentMethod,
        Money amount,
        CancellationToken cancellationToken);

    Task<RefundResult> RefundAsync(
        PaymentReference paymentReference,
        Money amount,
        CancellationToken cancellationToken);
}

// Domain/Ports/Inbound/IPlaceOrderUseCase.cs
// The inbound port: what the primary adapter calls
public interface IPlaceOrderUseCase
{
    Task<PlaceOrderResult> ExecuteAsync(
        PlaceOrderCommand command,
        CancellationToken cancellationToken);
}
// Application/UseCases/PlaceOrderUseCase.cs
// The use case depends only on domain ports — no infrastructure types
namespace MPC.Application.UseCases;

public sealed class PlaceOrderUseCase : IPlaceOrderUseCase
{
    private readonly IOrderRepository _orderRepository;
    private readonly IPaymentGateway _paymentGateway;
    private readonly IInventoryService _inventoryService;
    private readonly IOrderEventPublisher _eventPublisher;

    public PlaceOrderUseCase(
        IOrderRepository orderRepository,
        IPaymentGateway paymentGateway,
        IInventoryService inventoryService,
        IOrderEventPublisher eventPublisher)
    {
        _orderRepository = orderRepository;
        _paymentGateway = paymentGateway;
        _inventoryService = inventoryService;
        _eventPublisher = eventPublisher;
    }

    public async Task<PlaceOrderResult> ExecuteAsync(
        PlaceOrderCommand command,
        CancellationToken cancellationToken)
    {
        // All logic here is pure domain — no SQL, no HTTP, no RabbitMQ
        var inventory = await _inventoryService.ReserveAsync(
            command.Items, cancellationToken);

        if (!inventory.IsSuccessful)
            return PlaceOrderResult.Failure("Insufficient inventory for one or more items.");

        var order = Order.Create(command.CustomerId, command.Items, inventory.Reservations);

        var paymentResult = await _paymentGateway.ChargeAsync(
            command.PaymentMethod, order.Total, cancellationToken);

        if (!paymentResult.IsSuccessful)
        {
            await _inventoryService.ReleaseReservationAsync(
                inventory.Reservations, cancellationToken);
            return PlaceOrderResult.Failure("Payment declined.");
        }

        order.ConfirmPayment(paymentResult.TransactionId);
        await _orderRepository.SaveAsync(order, cancellationToken);
        await _eventPublisher.PublishOrderPlacedAsync(order, cancellationToken);

        return PlaceOrderResult.Success(order.Id);
    }
}

Infrastructure Adapter and In-Memory Test Fake Side by Side

// Infrastructure/Adapters/Outbound/EntityFrameworkOrderRepository.cs
// Implements the domain port — depends on EF Core, but domain does NOT see this
namespace MPC.Infrastructure.Adapters.Outbound;

public sealed class EntityFrameworkOrderRepository : IOrderRepository
{
    private readonly OrderDbContext _dbContext;
    private readonly IOrderMapper _mapper;

    public EntityFrameworkOrderRepository(OrderDbContext dbContext, IOrderMapper mapper)
    {
        _dbContext = dbContext;
        _mapper = mapper;
    }

    public async Task<Order?> FindByIdAsync(OrderId orderId, CancellationToken cancellationToken)
    {
        var row = await _dbContext.Orders
            .Include(o => o.LineItems)
            .SingleOrDefaultAsync(o => o.Id == orderId.Value, cancellationToken);

        return row is null ? null : _mapper.ToDomain(row);
    }

    public async Task SaveAsync(Order order, CancellationToken cancellationToken)
    {
        var row = _mapper.ToRow(order);
        _dbContext.Orders.Update(row);
        await _dbContext.SaveChangesAsync(cancellationToken);
    }
}

// Tests/Fakes/InMemoryOrderRepository.cs
// Used in unit and integration tests — no EF Core, no database
namespace MPC.Tests.Fakes;

public sealed class InMemoryOrderRepository : IOrderRepository
{
    private readonly Dictionary<OrderId, Order> _store = new();

    public Task<Order?> FindByIdAsync(OrderId orderId, CancellationToken cancellationToken)
        => Task.FromResult(_store.TryGetValue(orderId, out var order) ? order : null);

    public Task SaveAsync(Order order, CancellationToken cancellationToken)
    {
        _store[order.Id] = order;
        return Task.CompletedTask;
    }

    // Test helper: inspect internal state after use case execution
    public IReadOnlyDictionary<OrderId, Order> SavedOrders => _store;
}
// Tests/UseCases/PlaceOrderUseCaseTests.cs
// Full use case test with zero infrastructure dependencies
public class PlaceOrderUseCaseTests
{
    [Fact]
    public async Task PlaceOrder_WithValidPayment_SavesOrderAndPublishesEvent()
    {
        // Arrange
        var orderRepository = new InMemoryOrderRepository();
        var paymentGateway = new InMemoryPaymentGateway(simulateSuccess: true);
        var inventoryService = new InMemoryInventoryService(availableQuantity: 100);
        var eventPublisher = new CapturingOrderEventPublisher();

        var useCase = new PlaceOrderUseCase(
            orderRepository, paymentGateway, inventoryService, eventPublisher);

        var command = new PlaceOrderCommand(
            CustomerId: CustomerId.New(),
            Items: [new OrderItem(ProductId.New(), Quantity: 2)],
            PaymentMethod: PaymentMethod.CreditCard("4111111111111111"));

        // Act
        var result = await useCase.ExecuteAsync(command, CancellationToken.None);

        // Assert
        Assert.True(result.IsSuccess);
        Assert.Single(orderRepository.SavedOrders);
        Assert.Single(eventPublisher.PublishedEvents.OfType<OrderPlacedEvent>());
    }
}

Further Reading