The Definitive Guide to Hexagonal Architecture & DDD in Jakarta EE 11
When building complex enterprise applications, tightly coupling your business logic to a framework like Spring or Jakarta EE can be a costly mistake. If the database schema changes or a new API protocol is required, the entire system breaks. This is where Hexagonal Architecture (Ports and Adapters) and Domain-Driven Design (DDD) excel.
In this guide, we will explore how to build a pure Java 25 domain model using tactical DDD patterns and wrap it in a modern Jakarta EE 11 infrastructure layer utilizing Jakarta REST (JAX-RS) and the brand new Jakarta Data 1.0 specification.
Understanding Hexagonal Architecture
Hexagonal Architecture, created by Alistair Cockburn, aims to isolate the core business logic from outside concerns (UI, databases, message brokers). It achieves this by defining clear boundaries called Ports.

- The Domain (Inner Hexagon): Pure Java code (Entities, Value Objects, Aggregates). It knows absolutely nothing about HTTP, JSON, or SQL.
- Driving Adapters (Inbound): The outside world talking to your application. In our case, a Jakarta REST (JAX-RS) controller.
- Driven Adapters (Outbound): Your application talking to the outside world. In our case, a Jakarta Data 1.0 Repository persisting data to PostgreSQL.
Building the Pure Domain Model
Let’s design a simple Order Aggregate. Notice how this code contains zero annotations from Jakarta EE, Spring, or Hibernate. We leverage Java 25 features like record to create immutable Value Objects.
package com.webcodein.hexagonal.domain.model;
import java.math.BigDecimal;
import java.time.LocalDateTime;
// Immutable Value Objects
public record Money(BigDecimal amount) {
public Money {
if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("Amount cannot be negative");
}
}
}
public record OrderId(String value) {}
// The Aggregate Root
public class Order {
private final OrderId id;
private final String customerId;
private Money totalAmount;
private OrderStatus status;
private final LocalDateTime createdAt;
public Order(OrderId id, String customerId, Money totalAmount) {
this.id = id;
this.customerId = customerId;
this.totalAmount = totalAmount;
this.status = OrderStatus.CREATED;
this.createdAt = LocalDateTime.now();
}
public void pay() {
if (this.status != OrderStatus.CREATED) {
throw new IllegalStateException("Only CREATED orders can be paid");
}
this.status = OrderStatus.PAID;
}
// Getters omitted for brevity...
}
By protecting invariants inside the Order class, we ensure invalid states are impossible to represent.
Defining the Ports (Interfaces)
To interact with the domain, we define Ports. The Inbound Port represents a Use Case, while the Outbound Port represents data access.
// Inbound Port (Driving)
public interface CreateOrderUseCase {
Order createOrder(String customerId, java.math.BigDecimal amount);
}
// Outbound Port (Driven)
public interface OrderPort {
Order save(Order order);
Optional<Order> findById(OrderId id);
}
Implementing the Driving Adapter: JAX-RS
Now, we move to the outer hexagon: the Infrastructure layer. We use Jakarta REST (JAX-RS) to expose a REST API. The controller only talks to the CreateOrderUseCase interface, completely decoupled from how it's implemented.
@Path("/api/orders")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class OrderResource {
private final CreateOrderUseCase createOrderUseCase;
@Inject
public OrderResource(CreateOrderUseCase createOrderUseCase) {
this.createOrderUseCase = createOrderUseCase;
}
@POST
public Response createOrder(OrderRequest request) {
Order order = createOrderUseCase.createOrder(request.customerId(), request.amount());
return Response.status(Response.Status.CREATED)
.entity(new OrderResponse(order.getId().value(), order.getStatus().name()))
.build();
}
}
Implementing the Driven Adapter: Jakarta Data 1.0
The most challenging part of mixing DDD with Java frameworks is persistence. Hibernate demands no-arg constructors and getters/setters, which ruins pure DDD Aggregates. The Hexagonal solution is to map the pure domain Order to an infrastructure-specific OrderEntity.
We use the new Jakarta Data 1.0 specification for ultra-clean repository definitions.

import jakarta.data.repository.CrudRepository;
import jakarta.data.repository.Repository;
@Repository
public interface OrderRepository extends CrudRepository<OrderEntity, String> {
}
Finally, we implement the OrderPort to bridge the gap between the Domain and Jakarta Data:
@Named
public class OrderPersistenceAdapter implements OrderPort {
private final OrderRepository repository;
@Inject
public OrderPersistenceAdapter(OrderRepository repository) {
this.repository = repository;
}
@Override
public Order save(Order order) {
// Map Domain -> Entity
OrderEntity entity = new OrderEntity(
order.getId().value(),
order.getCustomerId(),
order.getTotalAmount().amount(),
order.getStatus().name(),
order.getCreatedAt()
);
repository.save(entity);
return order;
}
}
Advantages and Limitations
Advantages
- Framework Independence: If you decide to migrate from Spring Boot to Quarkus, your core business logic remains untouched.
- Testability: You can unit test the
OrderAggregate instantly without spinning up a CDI container or database. - Maintainability: The mental model is incredibly clear. Domain rules live in the domain, HTTP rules live in adapters.
Limitations
- Boilerplate: Mapping between Domain objects (
Order) and Infrastructure objects (OrderEntity) requires extra code. - Performance Overhead: While negligible for most systems, high-frequency low-latency trading systems might suffer from the object translation overhead.
Best Practices

Always strictly separate the physical Java packages. Your domain package should not import jakarta.persistence.* or jakarta.ws.rs.*. Use tools like ArchUnit to enforce these architectural boundaries in your CI/CD pipeline.
Modular Monoliths & Bounded Contexts

When your system grows, you can apply this architecture across multiple Bounded Contexts. Each context acts as its own isolated hexagon, communicating with other contexts through dedicated ports and event brokers like Apache Kafka, enabling a clean modular monolith approach.
Conclusion
Combining Hexagonal Architecture with DDD in Jakarta EE 11 provides an incredibly robust foundation for enterprise applications. By leveraging the new Jakarta Data 1.0 spec alongside traditional JAX-RS and CDI, you can achieve a modern, decoupled architecture that will stand the test of time.
Companion Code
You can find the complete, runnable Spring Boot 4 / Jakarta EE 11 companion project on our GitHub workshop repository here:
Companion Workshop Code: Hexagonal Architecture & DDD in Jakarta EE 11
