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.
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.
| Level | What it verifies | Indicative duration |
|---|---|---|
| Domain | Business rules, edge cases, invariants | A few milliseconds |
| Application | Orchestration with simulated ports | A few milliseconds |
| Adapter | SQL queries, serialisation, error handling | A few seconds with Testcontainers |
| End to end | Full journey on a deployed version | A 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.
Related articles
JWT and refresh token rotation: the part we often forget
A short-lived access token is good practice. Without rotation and reuse detection on the refresh side, a stolen token stays exploitable silently for weeks.