in

Hexagonal Architecture in Spring Boot: Ports and Adapters

In a layered architecture the business rule depends on JPA and HTTP; hexagonal architecture inverts that dependency. We walk through the port and adapter structure in Spring Boot, the package layout, a dependency rule protected by ArchUnit, the cost of separating domain from JPA entities, and which projects this architecture actually pays off in, with code.

Hexagonal architecture diagram with the domain at the center surrounded by ports and adapters

In the classic layered architecture (controller → service → repository) the dependency direction runs top-down, from the business rule toward the database. The result: OrderService knows JPA entities, @Transactional and HTTP DTOs; testing a business rule needs a database; when the ERP client changes, the service changes. Hexagonal architecture (ports & adapters) reverses that direction: the business rule sits at the center and knows no framework; database, HTTP, message queue and ERP stand outside as adapters.

This article builds the pattern in Spring Boot with a concrete package layout and code, the dependency rule protected by ArchUnit, the real cost of separating domain from JPA, and which projects this architecture pays off in.

The core idea: ports and adapters

  • Domain: Business rules, value objects, aggregates. Plain Java; no Spring, JPA or Jackson.

  • Inbound ports: The use cases the application offers to the outside: PlaceOrderUseCase.

  • Outbound ports: Interfaces for what the application needs from the outside: OrderRepository, StockSource, EventPublisher.

  • Adapters: Technology code implementing the ports: REST controller (in), JPA repository (out), SAP OData client (out), Kafka producer (out).

There is one rule: dependencies point inward. The adapter knows the domain; the domain doesn't know the adapter. If this rule isn't enforced at build time it erodes within three months; hence the ArchUnit test below.

Package layout

com.acme.erp.order
├── domain
│   ├── Order.java                   ← aggregate, business rules
│   ├── OrderNumber.java, Money.java ← value objects
│   └── CreditLimitRule.java
├── application
│   ├── port
│   │   ├── in/PlaceOrderUseCase.java
│   │   └── out/OrderRepository.java, StockSource.java, EventPublisher.java
│   └── PlaceOrderService.java       ← use case implementation
└── adapter
    ├── in/web/OrderController.java
    ├── out/persistence/OrderJpaAdapter.java, OrderEntity.java
    └── out/sap/SapStockAdapter.java

Code: from domain to adapter

// domain — no annotations at all
public class Order {
    private final OrderNumber number;
    private final CustomerId customer;
    private final List<OrderLine> lines;
    private Status status;

    public static Order place(CustomerId customer, List<OrderLine> lines, CreditLimitRule rule, Money openBalance, Money limit) {
        if (lines.isEmpty()) throw new EmptyOrder();
        var o = new Order(OrderNumber.next(), customer, List.copyOf(lines));
        o.status = rule.evaluate(limit, openBalance, o.total()) == Decision.APPROVAL_REQUIRED
                 ? Status.AWAITING_APPROVAL : Status.OPEN;
        return o;
    }
    public Money total() { return lines.stream().map(OrderLine::amount).reduce(Money.ZERO, Money::plus); }
}
// application/port/in — inbound port
public interface PlaceOrderUseCase {
    OrderNumber place(PlaceOrderCommand command);
}

// application/port/out — outbound ports
public interface OrderRepository { void save(Order o); Optional<Order> find(OrderNumber number); }
public interface StockSource     { boolean sufficient(ProductCode product, int quantity); }
public interface EventPublisher  { void publish(Object event); }

// application — the use case; Spring's transaction annotation is acceptable here
@Service
@Transactional
public class PlaceOrderService implements PlaceOrderUseCase {
    private final OrderRepository orders;
    private final StockSource stock;
    private final CustomerInfo customers;
    private final EventPublisher events;

    @Override
    public OrderNumber place(PlaceOrderCommand c) {
        c.lines().forEach(l -> { if (!stock.sufficient(l.product(), l.quantity())) throw new OutOfStock(l.product()); });
        var cust = customers.find(c.customer());
        var o = Order.place(c.customer(), c.lines(), new CreditLimitRule(), cust.openBalance(), cust.creditLimit());
        orders.save(o);
        events.publish(new OrderPlaced(o.number(), o.total()));
        return o.number();
    }
}
// adapter/out/persistence — JPA lives here and doesn't leak into the domain
@Component
class OrderJpaAdapter implements OrderRepository {
    private final OrderJpaRepository jpa;        // Spring Data interface over OrderEntity
    private final OrderMapper mapper;            // MapStruct: Order ↔ OrderEntity

    public void save(Order o) { jpa.save(mapper.toEntity(o)); }
    public Optional<Order> find(OrderNumber number) { return jpa.findByNumber(number.value()).map(mapper::toDomain); }
}

// adapter/in/web — HTTP lives here
@RestController
@RequestMapping("/api/orders")
class OrderController {
    private final PlaceOrderUseCase placeOrder;

    @PostMapping
    ResponseEntity<OrderResponse> place(@Valid @RequestBody OrderRequest request) {
        OrderNumber number = placeOrder.place(request.toCommand());
        return ResponseEntity.created(URI.create("/api/orders/" + number.value())).body(new OrderResponse(number.value()));
    }
}

The test that protects the rule

@AnalyzeClasses(packages = "com.acme.erp")
class ArchitectureRulesTest {

    @ArchTest
    static final ArchRule domainDependsOnNothing = noClasses()
        .that().resideInAPackage("..domain..")
        .should().dependOnClassesThat().resideInAnyPackage("..application..", "..adapter..",
            "org.springframework..", "jakarta.persistence..", "com.fasterxml..");

    @ArchTest
    static final ArchRule applicationIgnoresAdapters = noClasses()
        .that().resideInAPackage("..application..")
        .should().dependOnClassesThat().resideInAPackage("..adapter..");

    @ArchTest
    static final ArchRule adaptersIgnoreEachOther = slices()
        .matching("..adapter.(*)..").should().notDependOnEachOther();
}

With these three rules running in CI, "the controller called the repository directly" or "a JPA annotation crept into the domain" gets caught at the pull-request stage.

The mapping cost: must domain and JPA entity be separated?

This is the most debated point of hexagonal architecture. The separation frees the domain model from persistence concerns (lazy proxies, no-arg constructors, mandatory setters, table shape); the cost is one entity and one mapper per aggregate. Our pragmatic rule:

Situation

Recommendation

Core module with rich business rules, value objects, state machines

Separate; the mapping cost with MapStruct is low

Simple CRUD module (parameter tables, definitions)

Don't separate; the JPA entity is the domain, the port is still kept

Read-only / reporting screens

Projection without touching the domain; a separate read model

Legacy table structure doesn't fit the domain

Definitely separate; the adapter translates

The testing advantage

Because ports are interfaces, the use case is tested with in-memory fakes, without Spring or a database:

@Test
void doesNotPlaceOrderWhenStockIsInsufficient() {
    var orders = new InMemoryOrderRepository();
    var stock = (StockSource) (product, quantity) -> false;   // lambda: single-method port
    var service = new PlaceOrderService(orders, stock, fixedCustomer(), event -> {});

    assertThatThrownBy(() -> service.place(command("10001", 5)))
        .isInstanceOf(OutOfStock.class);
    assertThat(orders.count()).isZero();
}

Adapters are tested with their own technology: the JPA adapter on Testcontainers, the SAP adapter with WireMock contracts. The layers don't contaminate each other's tests.

When it pays off, and when it's too much

  • Pays off: Long-lived core systems, many external integrations (ERP, payments, e-invoicing), infrastructure expected to change (database, messaging), heavy business rules.

  • Too much: Short-lived internal tools, a few CRUD screens, prototypes. Layered architecture with good packaging is enough there.

In a modular monolith, every module can be its own hexagon: Spring Modulith protects module boundaries, the hexagonal structure protects the dependency direction inside the module. The two are complementary, not rivals.

Pitfalls

  1. Anemic domain: Domain classes only carry fields, rules live in services. Hexagonal structure doesn't fix this; move the rule into the aggregate.

  2. Interface inflation per port: A separate port interface for every repository method is unnecessary; a port is the set of capabilities a use case needs.

  3. Business rules in adapters: An "if the customer is VIP" check in the controller is a rule that leaked out of the domain.

  4. Mapping everywhere: If the DTO → command → domain → entity chain gets long, separate the read model; queries don't have to go through the domain.

Frequently asked questions

Are hexagonal, clean architecture and onion architecture the same thing?

Different drawings of the same idea: domain at the center, dependencies inward. The terminology changes (port/adapter, use case/interface adapter, layer rings); the implementation practice is the same as above.

Can a Spring Data repository be the port directly?

In simple modules, yes: having the port interface extend the Spring Data interface is a common shortcut. In modules where domain and entity are separated, a mapping adapter must sit in between.

How do we convert an existing layered project to hexagonal?

Gradually: first extract the outbound ports (make the repositories and clients the service calls into interfaces), then strip the domain of Spring/JPA, and add the inbound ports last. Tighten the ArchUnit rule at every step.

Conclusion

Hexagonal architecture is a dependency discipline that makes the business rule independent of frameworks and pushes technology into replaceable adapters. Its cost is mapping and a few extra interfaces; its gain is testability and a core that will live for a decade. In long-lived enterprise systems, the cost is almost always worth paying.

To map the dependency structure of your current application and plan a gradual move to hexagonal architecture, see our software design and architecture service.