Skip to main content

Architecture

Hexagonal architecture with Spring Boot 4: what it really changes

Creating three folders does not make an architecture hexagonal. Here is the dependency rule, the code that respects it, and the tests it finally makes possible with Spring Boot 4 and Java 21.

- 11 min read

Adopting a hexagonal architecture is not about renaming packages. The benefit only appears if the dependency rule holds in the code itself, verifiable by the compiler and by the tests. Here is how we do it on a Spring Boot 4 project, and what it changes day to day for a team.

Why layered packaging is no longer enough

Classic packaging into controllers, services and repositories quickly produces widespread coupling: the business service knows about persistence annotations, HTTP request shapes and sometimes message queue names. Testing a single billing rule then requires a full Spring context, a database and a broker. The cost shows up as slow tests and rigidity whenever a technology must change.

The practical consequence is familiar: unit tests become integration tests in disguise, their runtime grows from milliseconds to seconds, and the team eventually stops running them before pushing code.

The principle: the domain depends on nothing

A hexagonal architecture inverts the direction of dependencies. The domain holds business rules and uses nothing but standard Java. The application layer orchestrates use cases and defines the interfaces it needs, called ports. Infrastructure provides the implementations: database, HTTP API, message broker, identity provider.

package com.example.billing.domain;

import java.math.BigDecimal;
import java.time.LocalDate;

public final class Invoice {

    private final InvoiceId id;
    private final BigDecimal amount;
    private final LocalDate dueDate;
    private InvoiceStatus status;

    Invoice(InvoiceId id, BigDecimal amount, LocalDate dueDate) {
        this.id = id;
        this.amount = amount;
        this.dueDate = dueDate;
        this.status = InvoiceStatus.ISSUED;
    }

    public void registerPayment(LocalDate paymentDate) {
        if (status == InvoiceStatus.PAID) {
            throw new IllegalStateException("Invoice " + id.value() + " is already paid");
        }
        this.status = paymentDate.isAfter(dueDate) ? InvoiceStatus.PAID_LATE : InvoiceStatus.PAID;
    }
}

This code carries no annotation. It tests in milliseconds, with no Spring context and no database. The use case depends on a port it defines itself: no infrastructure import ever appears inside the application package.

package com.example.billing.application;

import java.time.Clock;
import java.time.LocalDate;

public class SettleInvoiceUseCase {

    private final InvoiceRepository repository;
    private final Clock clock;

    public SettleInvoiceUseCase(InvoiceRepository repository, Clock clock) {
        this.repository = repository;
        this.clock = clock;
    }

    public void settle(InvoiceId id) {
        Invoice invoice = repository.findById(id)
                .orElseThrow(() -> new InvoiceNotFoundException(id));
        invoice.registerPayment(LocalDate.now(clock));
        repository.save(invoice);
    }
}

Wiring happens in a configuration class, which keeps the application free of framework annotations and makes the dependency explicit.

Adapters: one per technology

Each technology gets its own adapter. An inbound adapter translates an HTTP request or a message into a use case call. An outbound adapter implements a port using JPA, an HTTP client or a broker. No adapter calls another adapter: they all go through the application layer.

A naming rule that helps in code review

Naming packages adapter.in.web, adapter.out.persistence and application.port.out makes violations visible immediately in review. A forbidden dependency can be spotted by eye, with no extra tooling.

  • The domain holds only business objects and business exceptions.
  • The application layer holds use cases and inbound and outbound ports.
  • Adapters hold all technology: JPA, HTTP, Kafka, OAuth.
  • Configuration assembles objects; it is the only place that knows about Spring.
  • API schemas and JPA entities are never exposed to the domain.

Testing: what the architecture makes possible

The main gain is being able to choose the right test level instead of paying for a slow test to check a simple rule.

LevelWhat it verifiesIndicative duration
DomainBusiness rules, edge cases, invariantsA few milliseconds
ApplicationOrchestration with simulated portsA few milliseconds
AdapterSQL queries, serialisation, error handlingA few seconds with Testcontainers
End to endFull journey on a deployed versionA few minutes
A hexagonal architecture does not show up in a diagram: it shows up the day you replace an adapter without touching a single line of business rule.

Three common mistakes

The first is letting the domain depend on an external utility library, then adding a second one, until the domain indirectly depends on the framework. The second is placing business validation in JPA entity constraints, which makes it impossible to test without a database and hard to find. The third is creating ports per technology rather than per need, which produces interfaces modelled on tools instead of business.

What to remember

A hexagonal architecture is above all a dependency rule, not a folder structure. It is judged on one simple indicator: how much of your business logic is testable without starting infrastructure. On a Spring Boot 4 project that share can exceed eighty percent, which genuinely changes the pace of development and confidence in releases.

  • Hexagonal architecture
  • JPA and Hibernate
  • Spring Boot
  • Testcontainers

Related articles