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.
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.javaCode: 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
Anemic domain: Domain classes only carry fields, rules live in services. Hexagonal structure doesn't fix this; move the rule into the aggregate.
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.
Business rules in adapters: An "if the customer is VIP" check in the controller is a rule that leaked out of the domain.
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.