A Spring Boot project starts out clean: the controller calls a service, and the service calls a repository. Two years later, an @Entity shows up in the JSON response, a service imports HttpServletRequest, and testing a pricing rule means booting a whole database. Hexagonal Architecture, also known as Ports and Adapters, is a way of organizing code to stop that drift: business logic sits at the center, and everything outside it (HTTP, the database, a payment gateway, a queue) plugs in through well-defined ports. This article builds a small order application with Spring Boot 3.3.5 and Java 21 using exactly that model, runs real tests, and then shows when the approach pays off and when it is overkill.
It continues DDD in Java: Entity, Value Object and Aggregate: the domain already protects itself, and now we decide where it lives in the architecture. The groundwork for injecting dependencies is in Dependency Injection: building an IoC container to understand Spring, and hiding the persistence layer behind an interface is covered in Repository pattern and Unit of Work with JPA.
The problem Hexagonal Architecture solves: the app gets dragged along by the framework
Hexagonal Architecture was created to fix a weakness of the traditional layered architecture (controller, service, repository), which has a single direction of dependency: from the top down to the database. The consequences:
- Business logic depends on the persistence layer, so changing the database or splitting out a module is painful.
- Testing a use case needs a real database or heavy mocking of the JPA classes themselves.
- Technical details (annotations, framework types) leak into the domain, and the domain leaks out through the API.
Alistair Cockburn named and described this problem in the original Hexagonal Architecture article (published in 2005): business logic gets mixed with the user interface and external systems, which makes automated testing hard and blocks development while the database is not ready. His stated goal was for an application to be driven equally by users, other programs, automated tests or batch scripts, and to be developed and tested in isolation from its runtime devices and databases.
Hexagonal Architecture: ports and adapters
The core idea of Hexagonal Architecture has two concepts. According to the original article, a port is a "purposeful conversation" with the outside world, defined by an API; an adapter converts between a specific technology (a web interface, a test harness, HTTP, SQL, an in-memory mock) and a port. Many adapters can plug into the same port. The hexagon is just a way of drawing the diagram so there is room for the ports; the number six has no meaning.
Cockburn splits the actors into two sides, and this is the most useful classification when writing code:
| Side | Name | Who calls whom | Example in this article |
|---|---|---|---|
| Left | Primary / driving | The outside calls into the application | HTTP controller, test |
| Right | Secondary / driven | The application calls out to the outside | Database, payment gateway |
The dependency rule of Hexagonal Architecture: arrows always point inward. Adapters know ports, ports know the domain, and the domain knows no one. The driven side applies the dependency inversion principle directly: the application declares interfaces that match its own needs, and the external adapters implement them.
Package structure for an order app in Hexagonal Architecture
The whole Hexagonal Architecture project lives in code/s6-24/. The package names reflect the rings exactly:
demo.hex
├── domain Order (plain Java)
├── application
│ ├── PlaceOrderService implements the use case
│ └── port
│ ├── in PlaceOrderUseCase (driving)
│ └── out OrderRepositoryPort, PaymentPort (driven)
├── adapter
│ ├── in.web OrderController (HTTP)
│ └── out
│ ├── persistence OrderPersistenceAdapter + JPA entity
│ └── payment FakePaymentAdapter
└── config BeanConfig: wiringIn Hexagonal Architecture the domain sits at the center, and here it has no annotations at all. We work from the inside out.
The domain: plain Java, no framework imports
public final class Order {
public enum Status { PLACED, PAYMENT_FAILED }
public record Line(String sku, int quantity, long unitPriceVnd) {
public Line {
if (quantity <= 0) throw new IllegalArgumentException("Quantity must be > 0");
if (unitPriceVnd < 0) throw new IllegalArgumentException("Negative unit price");
}
public long subtotal() { return quantity * unitPriceVnd; }
}
public Order(UUID id, long customerId, List<Line> lines, Status status) {
if (lines == null || lines.isEmpty())
throw new IllegalArgumentException("An order must have at least 1 line");
this.id = id; this.customerId = customerId;
this.lines = List.copyOf(lines); this.status = status;
}
public long total() { return lines.stream().mapToLong(Line::subtotal).sum(); }
public void markPaymentFailed() { status = Status.PAYMENT_FAILED; }
}To keep the article short, the unit price is a long amount in VND rather than a Money Value Object as in the previous article; in a real system you should keep Money.
Inbound and outbound ports: interfaces owned by the application
In Hexagonal Architecture, an inbound port describes what the outside world may ask the application to do. An outbound port describes what the application needs, named in business language rather than technology: OrderRepositoryPort and PaymentPort, not JpaOrderDao or StripeClient.
public interface PlaceOrderUseCase {
record Command(long customerId, List<Order.Line> lines) {}
Order place(Command command);
}
public interface OrderRepositoryPort {
Order save(Order order);
Optional<Order> findById(UUID id);
}
public interface PaymentPort {
boolean charge(UUID orderId, long customerId, long amountVnd);
}The use case: orchestration with no knowledge of technology
public class PlaceOrderService implements PlaceOrderUseCase {
private final OrderRepositoryPort orders;
private final PaymentPort payment;
public PlaceOrderService(OrderRepositoryPort orders, PaymentPort payment) {
this.orders = orders; this.payment = payment;
}
@Override
public Order place(Command c) {
Order order = new Order(UUID.randomUUID(), c.customerId(), c.lines(), Order.Status.PLACED);
if (!payment.charge(order.id(), order.customerId(), order.total())) {
order.markPaymentFailed();
}
return orders.save(order);
}
}This class has no @Service or @Transactional. The wiring below shows why it still works inside Spring. One practical consideration: the transaction boundary usually belongs at the use case. You can add @Transactional (and accept one Spring annotation in the application layer) or use a decorator in the config layer to keep this class clean.
Adapters: where technology is allowed to appear
The inbound adapter over HTTP
The web-side adapter in Hexagonal Architecture only translates JSON into a Command, calls the inbound port, and translates the result back into JSON. It uses its own request and response records and never exposes the domain Order through the API:
@RestController
@RequestMapping("/orders")
class OrderController {
private final PlaceOrderUseCase placeOrder;
OrderController(PlaceOrderUseCase placeOrder) { this.placeOrder = placeOrder; }
record LineRequest(String sku, int quantity, long unitPriceVnd) {}
record PlaceRequest(long customerId, List<LineRequest> lines) {}
record OrderResponse(String id, String status, long total) {}
@PostMapping @ResponseStatus(HttpStatus.CREATED)
OrderResponse place(@RequestBody PlaceRequest req) {
var lines = req.lines().stream()
.map(l -> new Order.Line(l.sku(), l.quantity(), l.unitPriceVnd())).toList();
Order o = placeOrder.place(new PlaceOrderUseCase.Command(req.customerId(), lines));
return new OrderResponse(o.id().toString(), o.status().name(), o.total());
}
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
Map<String, String> invalid(IllegalArgumentException e) { return Map.of("error", e.getMessage()); }
}The outbound adapter: persistence separates the storage model from the domain model
JPA annotations appear only in OrderJpaEntity. The adapter translates back and forth between the storage entity and the domain Order:
@Component
class OrderPersistenceAdapter implements OrderRepositoryPort {
private final SpringDataOrderRepository jpa;
OrderPersistenceAdapter(SpringDataOrderRepository jpa) { this.jpa = jpa; }
@Override
public Order save(Order o) {
OrderJpaEntity e = new OrderJpaEntity();
e.id = o.id(); e.customerId = o.customerId(); e.status = o.status().name();
o.lines().forEach(l -> e.lines.add(
new OrderJpaEntity.LineEmbeddable(l.sku(), l.quantity(), l.unitPriceVnd())));
jpa.save(e);
return o;
}
@Override
public Optional<Order> findById(UUID id) {
return jpa.findById(id).map(e -> new Order(e.id, e.customerId,
e.lines.stream().map(l -> new Order.Line(l.sku(), l.quantity(), l.unitPriceVnd())).toList(),
Order.Status.valueOf(e.status)));
}
}This is the biggest cost of Hexagonal Architecture: we write an extra mapping layer between two models. In return, the domain is not tied to JPA (no empty constructor, no lazy proxies, no setters). The simulated payment adapter is only a few lines and rejects transactions above 10 million VND, enough to exercise the failure branch; you can later replace it with a real HTTP adapter without touching the domain or the use case.
Wiring Hexagonal Architecture with Spring configuration
According to the Spring documentation on @Configuration and @Bean, a @Configuration class is a source of bean definitions, and @Bean methods can receive dependencies as parameters. We use that to declare the use case, an ordinary Java class, as a bean:
@Configuration
class BeanConfig {
@Bean PaymentPort paymentPort() { return new FakePaymentAdapter(); }
@Bean PlaceOrderUseCase placeOrderUseCase(OrderRepositoryPort orders, PaymentPort payment) {
return new PlaceOrderService(orders, payment);
}
}This is "the only place that knows both sides". Spring injects OrderRepositoryPort (registered by OrderPersistenceAdapter with @Component) into the use case through the method parameter. The benefit: the application layer can be tested with new, without Spring.
Testing with Hexagonal Architecture: use cases without Spring or a database
This is the most practical payoff of Hexagonal Architecture. A use case test needs only a fake in-memory adapter and a lambda for PaymentPort:
class PlaceOrderServiceTest {
static class InMemoryOrders implements OrderRepositoryPort {
final Map<UUID, Order> store = new HashMap<>();
public Order save(Order o) { store.put(o.id(), o); return o; }
public Optional<Order> findById(UUID id) { return Optional.ofNullable(store.get(id)); }
}
@Test
void marksOrderWhenPaymentIsDeclined() {
var service = new PlaceOrderService(new InMemoryOrders(), (id, c, amount) -> false);
Order o = service.place(new Command(7, List.of(new Order.Line("AO-01", 1, 100_000))));
assertEquals(Order.Status.PAYMENT_FAILED, o.status());
}
}A lambda works because PaymentPort has a single abstract method. This test runs in a few milliseconds and does not start Spring. There is also an end-to-end test using @SpringBootTest with a random port, making a real HTTP call through RestClient into the controller, through the use case, and down to H2 via the JPA adapter. Run it with mvn test on JDK 21 (point JAVA_HOME at JDK 21); the real result:
POST small -> {"id":"79629d20-…","status":"PLACED","total":500000}
POST large -> {"id":"87e97d33-…","status":"PAYMENT_FAILED","total":25000000}
POST empty -> 422 UNPROCESSABLE_ENTITY {"error":"An order must have at least 1 line"}
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 -- in demo.hex.PlaceOrderServiceTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 -- in demo.hex.EndToEndTest
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 -- in demo.hex.ArchitectureTest
Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS(IDs are shortened and the SQL log is omitted for readability.) The three end-to-end cases of Hexagonal Architecture show that a small order is placed and paid successfully, a 25 million VND order is declined by the payment adapter and ends up with the status PAYMENT_FAILED, and an empty order is rejected by the domain and translated by the web adapter into HTTP 422.
Guarding Hexagonal Architecture with ArchUnit
Hexagonal Architecture survives only if something protects it. According to the ArchUnit user guide, the library analyzes compiled bytecode to check dependencies between packages, classes and layers, runs inside a regular test framework, and each rule is an @ArchTest field. The three rules of this project:
@AnalyzeClasses(packages = "demo.hex", importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureTest {
@ArchTest
static final ArchRule domainIsFrameworkFree = noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAnyPackage(
"org.springframework..", "jakarta.persistence..", "com.fasterxml..",
"..adapter..", "..application..");
@ArchTest
static final ArchRule applicationDoesNotKnowAdapters = noClasses().that().resideInAPackage("..application..")
.should().dependOnClassesThat().resideInAnyPackage(
"..adapter..", "org.springframework..", "jakarta.persistence..");
@ArchTest
static final ArchRule webAdapterDoesNotTouchPersistence = noClasses().that().resideInAPackage("..adapter.in..")
.should().dependOnClassesThat().resideInAPackage("..adapter.out..");
}To find out whether a rule really catches mistakes, I tried to break it: I added Spring's @Component to the Order class in the domain and ran the tests again. The real result:
Tests run: 3, Failures: 1 -- in demo.hex.ArchitectureTest
Architecture Violation [Priority: MEDIUM] - Rule 'no classes that reside in a package '..domain..'
should depend on classes that reside in any package ['org.springframework..', ...]' was violated (1 times):
Class <demo.hex.domain.Order> is annotated with <org.springframework.stereotype.Component> in (Order.java:0)The build goes red immediately, before the code reaches review. After reverting, all three rules are green again. One technical caveat: ArchUnit reads class files with a bundled ASM, so if you run the tests on a JDK newer than the ASM version supports, you will hit an "Unsupported class file major version" error; run on JDK 21 or use a newer ArchUnit release.
Variants of Hexagonal Architecture and related architectures
Hexagonal Architecture belongs to the same family as Jeffrey Palermo's Onion Architecture and Robert C. Martin's Clean Architecture: all three put the domain at the center and let dependencies point only inward. The differences are mostly in how the rings are named and how prescriptive each one is. Some practical choices when applying Hexagonal Architecture:
- One Maven module or several: splitting
domain,applicationandadapterinto separate modules makes the compiler reject wrong dependencies, but it is heavier; a single module plus ArchUnit is a reasonable starting point. - Whether you need a separate storage model: if the domain is simple, many teams put
@Entitydirectly on the domain model and accept the trade-off. Separating them as in this article is safer but costs extra mapping code (which MapStruct can reduce). - Outbound ports driven by need: each use case declares exactly what it needs; avoid one "fat" port like
OrderServicethat collects every operation. - Modulith: several hexagonal modules in one application, communicating through ports or events, is a stepping stone before splitting into microservices. The article on the Observer pattern: from listeners to event buses and the Flow API explains the event mechanism you can use here.
When not to use Hexagonal Architecture
- Thin CRUD applications: if a service only moves data from a form to a table, ports and adapters just add an intermediate layer with no value.
- Prototypes and short-lived projects: the cost of Hexagonal Architecture does not pay for itself in time.
- Teams that are not used to it: Hexagonal Architecture that exceeds what the team can maintain is worse than a simple architecture.
- Read-heavy reporting queries: forcing read queries through the domain and outbound ports is wasteful; allow a separate read path (lightweight CQRS) that goes straight to SQL or a projection.
The trade-off to state plainly to stakeholders: Hexagonal Architecture buys testability and the ability to swap technology at the price of mapping code and more files. The value is clearest when the domain has complex, long-lived rules, or several ways of being driven (HTTP, batch, messaging) and many external systems.
Common mistakes when applying Hexagonal Architecture
- Ports named after technology:
JpaOrderRepositoryin the application layer pulls JPA inside. Name and type ports in business language. - Returning JPA entities from the controller: this exposes the schema and causes lazy loading errors. Use separate request/response records.
- An inbound adapter calling an outbound adapter directly: it skips the use case, so the boundary is lost. ArchUnit catches this.
- A use case that grows into a "god service": one small class per use case, one purpose each.
- Business logic leaking into adapters: for example, the persistence adapter computing the order status itself. Every rule belongs in the domain.
- Untested two-way mapping: add a field to the domain, forget the adapter, and data is silently lost. Add a round-trip test that saves and reloads.
Checklist for Hexagonal Architecture with Spring Boot
- The domain is plain Java; ArchUnit forbids imports from Spring, JPA and Jackson.
- Each use case has its own inbound port; each external need has its own outbound port, named in business terms.
- Adapters are the only place that use technology annotations; keep the storage model separate from the domain model when the domain is complex enough.
- Wiring lives in one
@Configurationclass; a use case can be created withnewin tests. - Test use cases with in-memory adapters; add a few end-to-end tests for the main paths.
- Weigh the cost: a simple CRUD project does not need every layer of Hexagonal Architecture.
In short, Hexagonal Architecture is not about the hexagon shape or the number of layers, but about one rule: dependencies point only inward, and all communication with the outside goes through ports defined by the application. With Spring Boot, that rule can be implemented with a few packages, one configuration class and a few ArchUnit tests.
Next article
Once Hexagonal Architecture has separated the rings, upgrading a framework version becomes an operation on the outer ring. Continue with Upgrading to Spring Boot 3: Jakarta EE, Java 17 and observability.



