A practical, modern guide to understanding Hexagonal Architecture deeply, applying it with current Java and Spring, avoiding over-engineering, and using it in a way that actually improves maintainability, testability, and system evolution.
Business logic stays in the center. UI, database, messaging, and external systems stay outside and connect through ports.
It protects the domain from framework and infrastructure churn, making systems easier to test and evolve.
Hexagonal is still strong, but best results usually come when combined with domain-driven packaging and modular monolith thinking.
Hexagonal Architecture, also called Ports and Adapters, was introduced by Alistair Cockburn. The goal is simple: keep business logic independent from delivery mechanisms and infrastructure. That means your core rules should not depend directly on Spring MVC, JPA, Kafka, REST, GraphQL, PostgreSQL, or any vendor SDK.
The “hexagon” shape is symbolic only. It does not mean six sides are required. It simply shows that your application can talk to many external actors through clearly defined boundaries.
| Term | Meaning | Example |
|---|---|---|
| Inbound port | An interface the application exposes for others to use. | CreateOrderUseCase |
| Outbound port | An interface the application needs from the outside world. | OrderRepository, PaymentGateway |
| Inbound adapter | Transforms external input into a call to an inbound port. | REST controller, Kafka listener, scheduled job, CLI command |
| Outbound adapter | Implements an outbound port using a concrete technology. | JPA repository adapter, HTTP client adapter, S3 adapter |
| Application service | Coordinates a use case. Usually sits in the core. | PlaceOrderService |
| Domain model | Pure business concepts and rules. | Order, Money, OrderStatus |
Hexagonal Architecture is still highly relevant, but experienced teams now apply it more carefully. The architecture itself has not been replaced. What changed is how people use it.
In the current Spring ecosystem, Spring Boot 4.x and Spring Framework 7.x are available, while Spring Modulith is increasingly used to enforce domain/module boundaries in larger applications. That makes a strong combination: Hexagonal inside each business module, Modulith across modules.
For a real-world Java system, this is a practical structure:
com.acme.orders
├── order
│ ├── domain
│ │ ├── Order.java
│ │ ├── OrderId.java
│ │ ├── OrderStatus.java
│ │ └── Money.java
│ ├── application
│ │ ├── port
│ │ │ ├── in
│ │ │ │ └── PlaceOrderUseCase.java
│ │ │ └── out
│ │ │ ├── LoadOrderPort.java
│ │ │ ├── SaveOrderPort.java
│ │ │ └── PublishOrderPlacedPort.java
│ │ └── service
│ │ └── PlaceOrderService.java
│ ├── adapter
│ │ ├── in
│ │ │ └── web
│ │ │ └── OrderController.java
│ │ └── out
│ │ ├── persistence
│ │ │ ├── OrderJpaEntity.java
│ │ │ ├── SpringDataOrderRepository.java
│ │ │ └── OrderPersistenceAdapter.java
│ │ └── messaging
│ │ └── KafkaOrderPlacedAdapter.java
│ └── config
│ └── OrderBeanConfig.java
└── Application.java
controller/service/repository packages across the whole codebase.
Below is a modern example using Java 21 style. It keeps the domain clean, uses ports explicitly, and keeps Spring/JPA only in adapters and configuration.
package com.acme.orders.order.domain;
import java.math.BigDecimal;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
public record OrderId(UUID value) {
public static OrderId newId() {
return new OrderId(UUID.randomUUID());
}
}
public record Money(BigDecimal amount) {
public Money {
Objects.requireNonNull(amount, "amount must not be null");
if (amount.signum() < 0) {
throw new IllegalArgumentException("Money cannot be negative");
}
}
public Money add(Money other) {
return new Money(this.amount.add(other.amount));
}
}
public record OrderLine(String productCode, int quantity, Money unitPrice) {
public OrderLine {
if (productCode == null || productCode.isBlank()) throw new IllegalArgumentException("productCode is required");
if (quantity <= 0) throw new IllegalArgumentException("quantity must be > 0");
Objects.requireNonNull(unitPrice, "unitPrice is required");
}
public Money lineTotal() {
return new Money(unitPrice.amount().multiply(BigDecimal.valueOf(quantity)));
}
}
public enum OrderStatus {
CREATED, CONFIRMED, REJECTED
}
public final class Order {
private final OrderId id;
private final String customerId;
private final List<OrderLine> lines;
private OrderStatus status;
private Order(OrderId id, String customerId, List<OrderLine> lines, OrderStatus status) {
this.id = id;
this.customerId = customerId;
this.lines = List.copyOf(lines);
this.status = status;
validate();
}
public static Order create(String customerId, List<OrderLine> lines) {
return new Order(OrderId.newId(), customerId, lines, OrderStatus.CREATED);
}
private void validate() {
if (customerId == null || customerId.isBlank()) throw new IllegalArgumentException("customerId is required");
if (lines == null || lines.isEmpty()) throw new IllegalArgumentException("order must contain at least one line");
}
public Money total() {
return lines.stream()
.map(OrderLine::lineTotal)
.reduce(new Money(BigDecimal.ZERO), Money::add);
}
public void confirm() {
if (total().amount().compareTo(BigDecimal.ZERO) == 0) {
throw new IllegalStateException("Cannot confirm an order with zero total");
}
this.status = OrderStatus.CONFIRMED;
}
public OrderId id() { return id; }
public String customerId() { return customerId; }
public List<OrderLine> lines() { return lines; }
public OrderStatus status() { return status; }
}
package com.acme.orders.order.application.port.in;
import java.util.List;
public interface PlaceOrderUseCase {
PlaceOrderResult placeOrder(PlaceOrderCommand command);
record PlaceOrderCommand(String customerId, List<OrderItem> items) {}
record OrderItem(String productCode, int quantity, String unitPrice) {}
record PlaceOrderResult(String orderId, String status, String total) {}
}
package com.acme.orders.order.application.port.out;
import com.acme.orders.order.domain.Order;
import com.acme.orders.order.domain.OrderId;
import java.util.Optional;
public interface LoadOrderPort {
Optional<Order> load(OrderId orderId);
}
public interface SaveOrderPort {
void save(Order order);
}
public interface PublishOrderPlacedPort {
void publish(Order order);
}
package com.acme.orders.order.application.service;
import com.acme.orders.order.application.port.in.PlaceOrderUseCase;
import com.acme.orders.order.application.port.out.PublishOrderPlacedPort;
import com.acme.orders.order.application.port.out.SaveOrderPort;
import com.acme.orders.order.domain.Money;
import com.acme.orders.order.domain.Order;
import com.acme.orders.order.domain.OrderLine;
import java.math.BigDecimal;
import java.util.List;
public class PlaceOrderService implements PlaceOrderUseCase {
private final SaveOrderPort saveOrderPort;
private final PublishOrderPlacedPort publishOrderPlacedPort;
public PlaceOrderService(SaveOrderPort saveOrderPort,
PublishOrderPlacedPort publishOrderPlacedPort) {
this.saveOrderPort = saveOrderPort;
this.publishOrderPlacedPort = publishOrderPlacedPort;
}
@Override
public PlaceOrderResult placeOrder(PlaceOrderCommand command) {
List<OrderLine> lines = command.items().stream()
.map(i -> new OrderLine(
i.productCode(),
i.quantity(),
new Money(new BigDecimal(i.unitPrice()))))
.toList();
Order order = Order.create(command.customerId(), lines);
order.confirm();
saveOrderPort.save(order);
publishOrderPlacedPort.publish(order);
return new PlaceOrderResult(
order.id().value().toString(),
order.status().name(),
order.total().amount().toPlainString());
}
}
package com.acme.orders.order.adapter.in.web;
import com.acme.orders.order.application.port.in.PlaceOrderUseCase;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final PlaceOrderUseCase placeOrderUseCase;
public OrderController(PlaceOrderUseCase placeOrderUseCase) {
this.placeOrderUseCase = placeOrderUseCase;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public PlaceOrderUseCase.PlaceOrderResult placeOrder(@Valid @RequestBody PlaceOrderRequest request) {
var command = new PlaceOrderUseCase.PlaceOrderCommand(
request.customerId(),
request.items().stream()
.map(i -> new PlaceOrderUseCase.OrderItem(i.productCode(), i.quantity(), i.unitPrice()))
.toList());
return placeOrderUseCase.placeOrder(command);
}
public record PlaceOrderRequest(
@NotBlank String customerId,
@NotEmpty List<PlaceOrderItemRequest> items
) {}
public record PlaceOrderItemRequest(
@NotBlank String productCode,
@Min(1) int quantity,
@NotBlank String unitPrice
) {}
}
package com.acme.orders.order.adapter.out.persistence;
import com.acme.orders.order.application.port.out.SaveOrderPort;
import com.acme.orders.order.domain.Order;
import org.springframework.stereotype.Component;
@Component
public class OrderPersistenceAdapter implements SaveOrderPort {
private final SpringDataOrderRepository repository;
public OrderPersistenceAdapter(SpringDataOrderRepository repository) {
this.repository = repository;
}
@Override
public void save(Order order) {
OrderJpaEntity entity = OrderJpaEntity.fromDomain(order);
repository.save(entity);
}
}
package com.acme.orders.order.adapter.out.messaging;
import com.acme.orders.order.application.port.out.PublishOrderPlacedPort;
import com.acme.orders.order.domain.Order;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@Component
public class OrderPlacedLogAdapter implements PublishOrderPlacedPort {
private static final Logger log = LoggerFactory.getLogger(OrderPlacedLogAdapter.class);
@Override
public void publish(Order order) {
log.info("Order placed: id={}, customerId={}, total={}",
order.id().value(), order.customerId(), order.total().amount());
}
}
package com.acme.orders.order.config;
import com.acme.orders.order.application.port.in.PlaceOrderUseCase;
import com.acme.orders.order.application.port.out.PublishOrderPlacedPort;
import com.acme.orders.order.application.port.out.SaveOrderPort;
import com.acme.orders.order.application.service.PlaceOrderService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OrderBeanConfig {
@Bean
PlaceOrderUseCase placeOrderUseCase(SaveOrderPort saveOrderPort,
PublishOrderPlacedPort publishOrderPlacedPort) {
return new PlaceOrderService(saveOrderPort, publishOrderPlacedPort);
}
}
@Service to exist. Spring is only used to wire dependencies together.
For complex flows, Java sealed interfaces can make use case results clearer:
public sealed interface PaymentResult permits PaymentAccepted, PaymentRejected, PaymentTimedOut {}
public record PaymentAccepted(String authorizationId) implements PaymentResult {}
public record PaymentRejected(String reason) implements PaymentResult {}
public record PaymentTimedOut() implements PaymentResult {}
This is often better than returning booleans or nullable fields.
The main advantage of hexagonal architecture is that the core can be tested without Spring, database, or network.
class PlaceOrderServiceTest {
@Test
void should_place_and_confirm_order() {
SaveOrderPort saveOrderPort = order -> {};
PublishOrderPlacedPort publishPort = order -> {};
PlaceOrderService service = new PlaceOrderService(saveOrderPort, publishPort);
var result = service.placeOrder(new PlaceOrderUseCase.PlaceOrderCommand(
"CUST-100",
List.of(new PlaceOrderUseCase.OrderItem("BOOK", 2, "25.00"))));
assertEquals("CONFIRMED", result.status());
assertEquals("50.00", result.total());
}
}
You can isolate failures better. If a test fails, you know whether the problem is in the domain, the web translation, persistence mapping, or integration boundary.
| Mistake | Why it hurts | Better approach |
|---|---|---|
| Putting JPA entities in the domain | Domain becomes persistence-driven and harder to test/change | Keep JPA entities in the persistence adapter |
| Creating interfaces for everything | Noise and ceremony without architectural value | Create ports only at true boundaries |
| Using anemic domain models | Business rules leak into services/controllers | Put behavior and invariants in domain objects where it makes sense |
| Package-by-layer only | Feature cohesion gets weak in large systems | Package by feature/domain first |
| Thinking hexagonal means microservices | It is a code structure pattern, not a deployment strategy | Use it inside monoliths or services |
| Returning REST DTOs from the core | HTTP concerns leak inward | Use application commands/results or domain types |
| Style | Main focus | Best use | Weakness if misused |
|---|---|---|---|
| Traditional layered | Controller → service → repository separation | Simple CRUD apps | Business logic often leaks and gets tightly coupled to infrastructure |
| Hexagonal | Protecting the core from outside concerns | Apps with meaningful domain rules and integrations | Can become too abstract if overdone |
| Clean architecture | Dependency rule across concentric boundaries | Conceptually similar to hexagonal, often broader in presentation | Can become theoretical and verbose |
| Onion architecture | Domain-centered layering | DDD-oriented design | Terminology overlaps can confuse teams |
| Modular monolith / Spring Modulith | Enforcing module boundaries inside one deployable app | Large Spring systems that are not yet microservices | Does not replace careful internal design of each module |
To make this guide stronger, here is a direct self-review:
Tip for publishing: you can split this page later into multiple pages such as “concepts”, “code examples”, “testing”, and “anti-patterns”, but this single-page version is ideal as a strong academy starter page.
View the full source code for this Hexagonal Architecture example on GitHub.
Open GitHub Repository