Skip to content
Back EndJavaJavaEE

The Definitive Guide to Hexagonal Architecture & DDD in Jakarta EE 11

5 min readsalahtobok
Hexagonal Architecture and DDD in Jakarta EE 11

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.

Hexagonal Architecture Core Diagram showing Ports and Adapters with Jakarta EE
The Core Hexagon: Ports and Adapters separating the domain from infrastructure.
  • 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.

Jakarta Data 1.0 Repository Flow Diagram showing Domain Model saving to PostgreSQL
Jakarta Data 1.0 abstracting the persistence logic via Repository interfaces.
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 Order Aggregate 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

Hexagonal Package Structure Diagram showing domain isolation
Enforcing architectural boundaries using strict Java package structures.

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

DDD Bounded Contexts communicating in a Modular Monolith
Scaling Hexagonal Architecture to support multiple DDD 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

References

Comments