Pavan Rangani

HomeBlogHexagonal Architecture: Ports and Adapters Pattern Complete Guide

Hexagonal Architecture: Ports and Adapters Pattern Complete Guide

By Pavan Rangani · February 26, 2026 · Architecture

Hexagonal Architecture: Ports and Adapters Pattern Complete Guide

Hexagonal Architecture Ports and Adapters Guide

Hexagonal architecture ports and adapters pattern isolates your business logic from external concerns like databases, APIs, and UI frameworks. Therefore, your domain code remains testable and independent of infrastructure decisions. This guide walks through practical implementation with real-world examples, the trade-offs that come with the structure, and the situations where a simpler design serves you better.

Understanding the Core Concept

Traditional layered architectures create tight coupling between business logic and infrastructure. Moreover, database queries leak into service classes, making unit testing difficult. As a result, changing your persistence layer requires rewriting business logic.

In contrast, this pattern defines clear boundaries through ports and adapters. Consequently, the domain core depends on nothing external and communicates only through well-defined interfaces. The name “hexagonal” is incidental — Alistair Cockburn chose a hexagon simply to leave room to draw several ports on different sides, not because six is a meaningful number. The essential idea is the symmetry: the application sits in the center, and every external technology plugs into it from the outside.

This inverts the dependency direction you find in a classic three-tier app. Rather than the service layer pointing down at a concrete repository, the domain declares an interface and the infrastructure points inward to satisfy it. Therefore, the compiler enforces that no database type, HTTP annotation, or framework import ever appears inside the core.

Hexagonal architecture ports and adapters system design
System design showing hexagonal boundaries between domain and infrastructure

Implementing Ports and Adapters in Java

Ports are interfaces that define how the outside world interacts with your domain. Specifically, driving ports represent use cases that external actors trigger, while driven ports define what the domain needs from infrastructure:

// Driving port (use case)
public interface CreateOrderUseCase {
    OrderId execute(CreateOrderCommand command);
}

// Driven port (infrastructure need)
public interface OrderRepository {
    Order save(Order order);
    Optional<Order> findById(OrderId id);
}

// Domain service implementing the use case
public class OrderService implements CreateOrderUseCase {
    private final OrderRepository repository;
    private final PaymentGateway paymentGateway;

    public OrderId execute(CreateOrderCommand cmd) {
        Order order = Order.create(cmd.customerId(), cmd.items());
        paymentGateway.charge(order.total());
        return repository.save(order).getId();
    }
}

Adapters implement these ports for specific technologies. Therefore, a PostgreSQL adapter implements OrderRepository, while a Stripe adapter implements PaymentGateway.

Mapping at the Boundary

A detail that separates a clean implementation from a leaky one is the mapping layer inside each adapter. The domain works with rich objects like Order and value objects like OrderId, but the database speaks rows and the API speaks DTOs. Consequently, every driven adapter is responsible for translating between the two worlds so that no persistence annotation ever bleeds into the domain.

// Persistence adapter: implements the driven port
@Repository
class JpaOrderRepository implements OrderRepository {

    private final OrderJpaRepository jpa;   // Spring Data interface
    private final OrderMapper mapper;

    @Override
    public Order save(Order order) {
        OrderEntity entity = mapper.toEntity(order);
        OrderEntity saved = jpa.save(entity);
        return mapper.toDomain(saved);      // back to a pure domain object
    }

    @Override
    public Optional<Order> findById(OrderId id) {
        return jpa.findById(id.value()).map(mapper::toDomain);
    }
}

This translation is real work, and beginners often resent it. However, it is exactly what buys you independence: when you later swap JPA for jOOQ or move to a document store, only the adapter and its mapper change. The Order class, the OrderService, and every test against them stay untouched.

The mapping boundary also protects the domain from accidental over-fetching and lazy-loading surprises. Because the adapter decides exactly which fields it reads and how it assembles an aggregate, the domain never trips over a detached-entity exception or an unexpected database round trip mid-business-rule. Furthermore, the same discipline applies on the driving side: a REST controller validates and parses the raw request into a clean command object, so malformed input is rejected at the edge rather than deep inside the core. As a result, the domain receives only well-formed, framework-free data from every direction, which keeps its logic focused purely on business rules.

Driving and Driven Adapters

Driving adapters call into the domain through driving ports. For example, a REST controller is a driving adapter that translates HTTP requests into use case calls. Furthermore, CLI commands, GraphQL resolvers, and message consumers all serve as driving adapters.

Driven adapters are called by the domain through driven ports. Specifically, database repositories, email services, and external API clients are driven adapters. However, the domain never depends on adapter implementations directly. Wiring the two sides together is the job of a composition root — typically your dependency-injection container — which is the only place in the system that knows about both interfaces and concrete classes at once.

Software architecture blueprint with system boundaries
Clear separation between driving and driven adapters around the domain core

Testing Benefits of Port-Based Design

The greatest advantage of this architecture is testability. Additionally, you can test the entire domain logic with simple in-memory adapter implementations. As a result, tests run in milliseconds without databases, networks, or external services.

Moreover, integration tests swap only the specific adapters under test. Consequently, you achieve high coverage with fast, reliable test suites. A concrete example makes the payoff obvious: an in-memory fake repository backed by a HashMap lets you exercise the order workflow exhaustively without spinning up a container.

// Test double — a driven adapter with no infrastructure
class InMemoryOrderRepository implements OrderRepository {
    private final Map<OrderId, Order> store = new ConcurrentHashMap<>();

    public Order save(Order order) {
        store.put(order.getId(), order);
        return order;
    }
    public Optional<Order> findById(OrderId id) {
        return Optional.ofNullable(store.get(id));
    }
}

@Test
void chargesPaymentAndPersistsOrder() {
    var repo = new InMemoryOrderRepository();
    var gateway = new RecordingPaymentGateway();   // captures charge calls
    var service = new OrderService(repo, gateway);

    OrderId id = service.execute(sampleCommand());

    assertThat(repo.findById(id)).isPresent();
    assertThat(gateway.lastCharge()).isEqualTo(expectedTotal());
}

Notice that this test never mentions HTTP, JPA, or Stripe. Because the domain depends only on ports, the test substitutes hand-written fakes and asserts on behavior directly. Therefore, the bulk of your business rules can be verified with plain, fast unit tests, leaving the slower integration suite to confirm that each real adapter honors its port contract.

Common Pitfalls and Trade-offs

This pattern is not free, and pretending otherwise sets teams up for frustration. The most common mistake is letting framework concerns sneak into the core — for instance, annotating a domain entity with @Entity so JPA can persist it directly. The moment that happens, the boundary is gone, and you carry all the mapping ceremony with none of the independence.

A second pitfall is over-porting. Teams sometimes create an interface for every collaborator, including ones that will never have a second implementation, which buries simple code under indirection. In practice, you only need a port where the boundary is genuinely volatile or genuinely worth faking in tests. Finally, the extra layers add real cognitive overhead for newcomers, who must learn where a piece of logic “belongs” before they can change it.

When to Choose This Pattern

Hexagonal architecture shines in complex domains with multiple integration points. Meanwhile, simple CRUD applications may not benefit from the additional structure. Therefore, evaluate your domain complexity before committing to this pattern.

As a rough guide, the pattern earns its keep when you have rich business rules, several inbound channels, or a realistic expectation of swapping infrastructure over the system’s life. By contrast, a thin admin tool that maps form fields straight to table columns will only suffer under the indirection — a transaction-script or plain layered design ships faster and reads more directly. In short, adopt the structure deliberately, not reflexively.

Technical planning workspace for system architecture
Evaluating architectural patterns for domain-driven applications

Related Reading:

Further Resources:

In conclusion, hexagonal architecture ports and adapters create maintainable systems by inverting dependencies and isolating domain logic. Therefore, adopt this pattern when your application demands testability and long-term flexibility — and reach for something simpler when it does not.

← Back to all articles