Pavan Rangani

HomeBlogHexagonal Architecture with Ports and Adapters in Spring Boot

Hexagonal Architecture with Ports and Adapters in Spring Boot

By Pavan Rangani · May 8, 2026 · Architecture

Hexagonal Architecture with Ports and Adapters in Spring Boot

Hexagonal architecture ports adapters: a pragmatic Spring Boot guide

Alistair Cockburn introduced hexagonal architecture ports adapters in 2005 as an antidote to the layered architectures that quietly leaked persistence and framework concerns into business logic. Two decades later, after migrating four production systems to it, I can confirm the pattern still pays its rent. Therefore, this guide is the practical Spring Boot walk-through I wish I had when I started.

The core insight is simple but surprisingly difficult to internalize. Specifically, your domain logic should not know whether it is being driven by an HTTP controller, a Kafka consumer, or a CLI command. Furthermore, it should not know whether it is persisting to PostgreSQL, DynamoDB, or an in-memory map. Consequently, every external dependency is expressed as a port (an interface owned by the domain) and implemented by an adapter (a module that depends on the domain).

Primary and secondary ports demystified

The terminology trips people up, so let me ground it. Primary ports, also called driving ports, are the use cases your application exposes. They are the API of your domain and they are called by adapters that drive the application, like REST controllers or message listeners. For example, an OrderUseCase interface with a placeOrder method is a primary port.

Secondary ports, also called driven ports, are the abstractions the domain depends on. They represent things the domain needs to call, like databases, payment gateways, or notification services. Moreover, the domain owns the interface, and the infrastructure layer provides the implementation. As a result, the dependency arrow always points inward toward the domain.

hexagonal architecture diagram with primary and secondary adapters
Primary adapters drive the application; secondary adapters are driven by it.

Module layout that survives team growth

I split a hexagonal Spring Boot service into four Maven or Gradle modules. The domain module contains entities, value objects, domain services, and port interfaces, with zero Spring or Jakarta dependencies. The application module orchestrates use cases and may use Spring annotations sparingly. Additionally, two adapter modules, adapter-in-web and adapter-out-persistence, contain the framework-coupled code.

This split is not academic theater. Specifically, it makes the dependency graph enforceable at build time. Consequently, an inexperienced engineer cannot accidentally import EntityManager into a domain service because the domain module does not have JPA on its classpath. Furthermore, the domain module compiles in under two seconds and runs its tests without spinning up an application context.

A complete OrderUseCase example

Let me show the full picture with a realistic order placement flow. The primary port defines the contract, the domain service implements business rules, and the secondary ports abstract the database and payment gateway.

// === domain module ===
package com.example.orders.domain.port.in;

public interface PlaceOrderUseCase {
    OrderConfirmation placeOrder(PlaceOrderCommand command);
}

package com.example.orders.domain.port.out;

public interface OrderRepositoryPort {
    Order save(Order order);
    Optional findById(OrderId id);
}

public interface PaymentGatewayPort {
    PaymentResult charge(CustomerId customer, Money amount);
}

// === domain module: domain service ===
package com.example.orders.domain.service;

public class PlaceOrderService implements PlaceOrderUseCase {

    private final OrderRepositoryPort orders;
    private final PaymentGatewayPort payments;
    private final DomainEventPublisher events;

    public PlaceOrderService(OrderRepositoryPort orders,
                             PaymentGatewayPort payments,
                             DomainEventPublisher events) {
        this.orders = orders;
        this.payments = payments;
        this.events = events;
    }

    @Override
    public OrderConfirmation placeOrder(PlaceOrderCommand command) {
        Order order = Order.create(command.customerId(), command.items());
        order.validate();

        PaymentResult payment = payments.charge(order.customerId(), order.total());
        if (payment.failed()) {
            throw new PaymentRejectedException(payment.reason());
        }
        order.markPaid(payment.transactionId());
        Order saved = orders.save(order);
        events.publish(new OrderPlacedEvent(saved.id(), saved.total()));
        return OrderConfirmation.of(saved);
    }
}

// === adapter-in-web module ===
package com.example.orders.adapter.in.web;

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final PlaceOrderUseCase placeOrder;

    public OrderController(PlaceOrderUseCase placeOrder) {
        this.placeOrder = placeOrder;
    }

    @PostMapping
    public ResponseEntity place(@Valid @RequestBody OrderRequest request) {
        OrderConfirmation confirmation = placeOrder.placeOrder(request.toCommand());
        return ResponseEntity.created(URI.create("/api/orders/" + confirmation.id()))
            .body(OrderResponse.from(confirmation));
    }
}

// === adapter-out-persistence module ===
package com.example.orders.adapter.out.persistence;

@Repository
public class JpaOrderAdapter implements OrderRepositoryPort {

    private final OrderJpaRepository jpa;
    private final OrderEntityMapper mapper;

    public JpaOrderAdapter(OrderJpaRepository jpa, OrderEntityMapper mapper) {
        this.jpa = jpa;
        this.mapper = mapper;
    }

    @Override
    public Order save(Order order) {
        OrderEntity entity = mapper.toEntity(order);
        return mapper.toDomain(jpa.save(entity));
    }

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

Testing strategies that exploit the architecture

The biggest payoff of hexagonal architecture is testability. Domain tests run as plain JUnit 5 against in-memory port implementations, with no Spring context. Therefore, a 200-test domain suite finishes in under three seconds, which keeps TDD cycles tight. Additionally, you can stand up integration tests against a real Postgres via Testcontainers without the domain module knowing.

For example, testing PlaceOrderService is a matter of providing a HashMap-backed OrderRepositoryPort and a stub PaymentGatewayPort. In contrast, the adapter-out-persistence module gets dedicated integration tests against Testcontainers PostgreSQL, asserting that the JPA mapping round-trips correctly. Consequently, slow tests stay slow only where they must.

Spring Boot ports and adapters module layout
Build-enforced module boundaries prevent accidental coupling between domain and infrastructure.

Comparison with Clean and Onion architecture

Clean Architecture (Robert Martin) and Onion Architecture (Jeffrey Palermo) are close cousins. All three insist that dependencies point inward toward the domain. However, the differences matter in practice. Clean Architecture defines four concentric rings (entities, use cases, interface adapters, frameworks), which can be over-engineered for small services.

Hexagonal architecture, by contrast, only mandates two zones: inside the hexagon and outside. Furthermore, it is silent on internal layering, leaving you free to pick what fits. As a result, I default to hexagonal for services with up to about 30 use cases, and reach for Clean Architecture only when the use case layer itself becomes complex enough to warrant subdivision. For canonical references, see Martin Fowler’s articles and the microservices.io pattern catalog.

Anti-patterns that quietly destroy the design

Three anti-patterns recur in every hexagonal codebase I have rescued. First, the anemic domain, where entities are getter-and-setter bags and all logic lives in services. Specifically, this turns the domain module into a glorified data class library, and you lose the entire benefit. Therefore, push invariants and behavior into entities and value objects.

Second, leaking JPA annotations into domain entities. Whenever I see @Entity on a domain class, I know the team gave up. Instead, keep separate OrderEntity (JPA) and Order (domain) classes and translate at the adapter boundary. Third, putting orchestration in controllers. Controllers should be thin: parse, delegate to a use case, format the response.

Migrating a legacy Spring Boot service

Migrating an existing service does not require a rewrite. Subsequently, I follow a four-step strangler approach. First, introduce a domain module with port interfaces and move the most business-rich service into it. Second, wrap existing repositories with adapter classes that implement the new ports. Third, push controllers to depend only on use case interfaces. Fourth, ratchet a no-Spring-imports rule on the domain module via ArchUnit.

For richer architectural patterns that pair well with hexagonal design, explore event sourcing and CQRS and cell-based architecture. Notably, hexagonal architecture composes cleanly with both, since ports give you a natural place to plug event publishing or cell routing.

In conclusion, hexagonal architecture ports adapters is not a silver bullet, but it is the most reliable way I know to keep a Spring Boot codebase honest as it grows past 50,000 lines. Invest in the module split, push behavior into the domain, and enforce dependency direction with tooling. As a result, your business logic becomes durable across framework upgrades, database swaps, and team turnover.

← Back to all articles