Domain-Driven Design & Hexagonal Architecture in Java¶
A Practical Primer¶
Published: February 24, 2026 · Last edited: February 24, 2026
Why These Two Patterns Belong Together¶
Domain-Driven Design (DDD) is a philosophy for modeling complex business logic — it tells you what to build and how to think about it. Hexagonal Architecture (also called Ports & Adapters) is a structural pattern for organizing code — it tells you where to put things and how layers communicate.
Together they enforce a single non-negotiable rule:
The domain model is the center of the universe. Infrastructure exists to serve it — never the other way around.
Domain-Driven Design (DDD)¶
Strategic Design¶
Strategic DDD operates at the organizational and system level before a line of code is written.
Bounded Context¶
A Bounded Context is an explicit boundary within which a particular domain model applies. The same word can mean different things in different contexts — this is intentional and correct.
┌────────────────────┐ ┌────────────────────┐
│ Order Context │ │ Shipping Context │
│ │ │ │
│ Customer: buyer │ │ Customer: recipient│
│ Item: line item │ │ Item: parcel │
│ Status: paid/etc │ │ Status: in transit│
└────────────────────┘ └────────────────────┘
Each Bounded Context maps to its own Java module or service. Don't force a single Customer class to serve all contexts.
Ubiquitous Language¶
Every term used in code — class names, method names, variable names — must directly reflect how domain experts talk about the business. If your domain expert says "fulfil the order," your method is fulfil(), not processOrderCompletion().
Context Mapping¶
When Bounded Contexts must communicate, you define how they relate:
| Pattern | Description |
|---|---|
| Shared Kernel | Two contexts share a small, agreed-upon model |
| Customer/Supplier | Upstream context publishes; downstream consumes |
| Anti-Corruption Layer (ACL) | Translation layer that protects your model from a foreign model |
| Open Host Service | Published API for many consumers |
| Conformist | Downstream adopts upstream model without translation |
The ACL is the most important in Java microservice landscapes — it prevents an external model from leaking into your domain.
Tactical Design¶
Tactical DDD provides the building blocks for implementing the model within a Bounded Context.
| Building Block | Responsibility |
|---|---|
| Entity | Has identity that persists through state changes |
| Value Object | Immutable, defined by its attributes, no identity |
| Aggregate | Cluster of entities/VOs with a root that enforces invariants |
| Domain Event | Signals something significant happened in the domain |
| Repository | Abstracts storage/retrieval of aggregates |
| Domain Service | Stateless operation that doesn't belong to an entity or VO |
| Application Service | Orchestrates use cases, lives outside the domain |
| Factory | Complex creation logic extracted from entities/aggregates |
DDD Building Blocks in Java¶
Entity¶
Entities are compared by identity, not by value.
// domain/model/order/Order.java
public class Order {
private final OrderId id;
private CustomerId customerId;
private List<OrderLine> lines;
private OrderStatus status;
// Entities are created through factories or constructors with intent
public static Order place(CustomerId customerId, List<OrderLine> lines) {
if (lines.isEmpty()) {
throw new DomainException("An order must have at least one line.");
}
return new Order(OrderId.generate(), customerId, lines, OrderStatus.PENDING);
}
public void confirm() {
if (this.status != OrderStatus.PENDING) {
throw new DomainException("Only pending orders can be confirmed.");
}
this.status = OrderStatus.CONFIRMED;
// register domain event
DomainEvents.register(new OrderConfirmedEvent(this.id));
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Order other)) return false;
return this.id.equals(other.id); // identity-based equality
}
@Override
public int hashCode() {
return id.hashCode();
}
}
Value Object¶
Value Objects are immutable and compared by value.
// domain/model/order/Money.java
public record Money(BigDecimal amount, Currency currency) {
public Money {
Objects.requireNonNull(amount, "Amount is required");
Objects.requireNonNull(currency, "Currency is required");
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new DomainException("Money cannot be negative.");
}
}
public Money add(Money other) {
assertSameCurrency(other);
return new Money(this.amount.add(other.amount), this.currency);
}
public Money multiply(int factor) {
return new Money(this.amount.multiply(BigDecimal.valueOf(factor)), this.currency);
}
private void assertSameCurrency(Money other) {
if (!this.currency.equals(other.currency)) {
throw new DomainException("Cannot operate on different currencies.");
}
}
public static Money of(String amount, String currencyCode) {
return new Money(new BigDecimal(amount), Currency.getInstance(currencyCode));
}
}
Java tip: Use
recordfor Value Objects. The compiler generatesequals(),hashCode(), andtoString()based on all fields — exactly what you want.
Aggregate¶
An Aggregate is a consistency boundary. All invariants are enforced through the Aggregate Root. External code never holds references to internal entities — only to the root.
// domain/model/cart/Cart.java (Aggregate Root)
public class Cart {
private final CartId id;
private final CustomerId owner;
private final List<CartItem> items = new ArrayList<>(); // internal entity
private CartStatus status;
// Enforce invariants on every mutation
public void addItem(ProductId productId, int quantity, Money unitPrice) {
assertNotCheckedOut();
findItem(productId).ifPresentOrElse(
existing -> existing.increaseQuantity(quantity),
() -> items.add(new CartItem(productId, quantity, unitPrice))
);
}
public void removeItem(ProductId productId) {
assertNotCheckedOut();
items.removeIf(item -> item.productId().equals(productId));
}
public Money total() {
return items.stream()
.map(CartItem::subtotal)
.reduce(Money.ZERO_EUR, Money::add);
}
public void checkout() {
assertNotCheckedOut();
if (items.isEmpty()) {
throw new DomainException("Cannot check out an empty cart.");
}
this.status = CartStatus.CHECKED_OUT;
DomainEvents.register(new CartCheckedOutEvent(this.id, this.owner, List.copyOf(items)));
}
private void assertNotCheckedOut() {
if (this.status == CartStatus.CHECKED_OUT) {
throw new DomainException("Cart is already checked out.");
}
}
// Expose read-only view of internals — never the mutable list
public List<CartItem> items() {
return Collections.unmodifiableList(items);
}
}
Key aggregate rules: - Only the root has a globally unique identity - External objects reference the root by ID only - All business rules live inside the aggregate - Aggregates communicate through domain events, not direct method calls
Domain Event¶
// domain/event/OrderConfirmedEvent.java
public record OrderConfirmedEvent(
OrderId orderId,
Instant occurredAt
) implements DomainEvent {
public OrderConfirmedEvent(OrderId orderId) {
this(orderId, Instant.now());
}
}
Repository (Interface Only — Lives in Domain)¶
// domain/port/repository/OrderRepository.java
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(OrderId id);
List<Order> findByCustomer(CustomerId customerId);
}
The implementation of this interface lives in the infrastructure layer. The domain defines the contract; infrastructure fulfills it.
Domain Service¶
Use a domain service when an operation involves multiple aggregates or requires logic that doesn't naturally belong on one entity.
// domain/service/PricingService.java
public class PricingService {
public Money calculateOrderTotal(List<OrderLine> lines, DiscountPolicy policy) {
Money subtotal = lines.stream()
.map(line -> line.unitPrice().multiply(line.quantity()))
.reduce(Money.ZERO_EUR, Money::add);
return policy.apply(subtotal);
}
}
Hexagonal Architecture (Ports & Adapters)¶
Core Concepts¶
Hexagonal Architecture, coined by Alistair Cockburn, organizes code into three concentric zones:
┌─────────────────────────────────────────┐
│ INFRASTRUCTURE │
│ ┌───────────────────────────────────┐ │
│ │ APPLICATION CORE │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ DOMAIN │ │ │
│ │ │ Entities, Value Objects, │ │ │
│ │ │ Aggregates, Domain Events │ │ │
│ │ └─────────────────────────────┘ │ │
│ │ Application Services, Use Cases │ │
│ └─────────────────┬─────────────────┘ │
│ PRIMARY ADAPTERS ↑ ↓ SECONDARY ADAPTERS│
│ [REST API, CLI] [DB, Queue, Email] │
└─────────────────────────────────────────┘
Ports are interfaces that define how the application communicates with the outside world. There are two kinds:
| Type | Also Called | Direction | Example |
|---|---|---|---|
| Primary Port | Driving Port | Outside → App | Use case interface, CLI handler |
| Secondary Port | Driven Port | App → Outside | Repository, email sender, message bus |
Adapters are concrete implementations of ports.
| Type | Also Called | Direction | Example |
|---|---|---|---|
| Primary Adapter | Driving Adapter | Drives the application | OrderController (REST), OrderCLI |
| Secondary Adapter | Driven Adapter | Driven by the application | JpaOrderRepository, KafkaEventPublisher |
The Dependency Rule: dependencies always point inward. Infrastructure depends on the application core. The application core has zero dependencies on infrastructure.
Structure in Java¶
Primary Port (Use Case Interface)¶
// application/port/in/PlaceOrderUseCase.java
public interface PlaceOrderUseCase {
OrderId placeOrder(PlaceOrderCommand command);
}
// application/port/in/PlaceOrderCommand.java
public record PlaceOrderCommand(
CustomerId customerId,
List<OrderLineDto> lines
) {}
Application Service (Implements Primary Port)¶
// application/service/PlaceOrderService.java
@Service
@Transactional
public class PlaceOrderService implements PlaceOrderUseCase {
private final OrderRepository orderRepository; // secondary port
private final CustomerRepository customerRepository; // secondary port
private final DomainEventPublisher eventPublisher; // secondary port
private final PricingService pricingService; // domain service
public PlaceOrderService(
OrderRepository orderRepository,
CustomerRepository customerRepository,
DomainEventPublisher eventPublisher,
PricingService pricingService
) {
this.orderRepository = orderRepository;
this.customerRepository = customerRepository;
this.eventPublisher = eventPublisher;
this.pricingService = pricingService;
}
@Override
public OrderId placeOrder(PlaceOrderCommand command) {
// 1. Load domain objects
Customer customer = customerRepository
.findById(command.customerId())
.orElseThrow(() -> new CustomerNotFoundException(command.customerId()));
// 2. Map command to domain objects (no framework types in domain)
List<OrderLine> lines = command.lines().stream()
.map(dto -> new OrderLine(dto.productId(), dto.quantity(), dto.unitPrice()))
.toList();
// 3. Execute domain logic
Order order = Order.place(customer.id(), lines);
// 4. Persist
orderRepository.save(order);
// 5. Publish domain events
DomainEvents.getRegistered().forEach(eventPublisher::publish);
return order.id();
}
}
Notice: The application service orchestrates — it delegates all business logic to the domain. No
ifstatements on business rules here.
Secondary Port (Driven Port)¶
// application/port/out/DomainEventPublisher.java
public interface DomainEventPublisher {
void publish(DomainEvent event);
}
Secondary Adapter (Infrastructure Implementation)¶
// infrastructure/adapter/out/persistence/JpaOrderRepository.java
@Repository
public class JpaOrderRepository implements OrderRepository {
private final OrderJpaEntityRepository jpaRepo;
private final OrderMapper mapper;
@Override
public void save(Order order) {
OrderJpaEntity entity = mapper.toEntity(order);
jpaRepo.save(entity);
}
@Override
public Optional<Order> findById(OrderId id) {
return jpaRepo.findById(id.value())
.map(mapper::toDomain);
}
@Override
public List<Order> findByCustomer(CustomerId customerId) {
return jpaRepo.findByCustomerId(customerId.value())
.stream()
.map(mapper::toDomain)
.toList();
}
}
Primary Adapter (REST Controller)¶
// infrastructure/adapter/in/web/OrderController.java
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final PlaceOrderUseCase placeOrderUseCase; // primary port
private final GetOrderUseCase getOrderUseCase; // primary port
public OrderController(PlaceOrderUseCase placeOrderUseCase, GetOrderUseCase getOrderUseCase) {
this.placeOrderUseCase = placeOrderUseCase;
this.getOrderUseCase = getOrderUseCase;
}
@PostMapping
public ResponseEntity<OrderResponse> placeOrder(@RequestBody PlaceOrderRequest request) {
PlaceOrderCommand command = PlaceOrderRequestMapper.toCommand(request);
OrderId orderId = placeOrderUseCase.placeOrder(command);
return ResponseEntity
.created(URI.create("/api/orders/" + orderId.value()))
.body(new OrderResponse(orderId.value()));
}
@GetMapping("/{id}")
public ResponseEntity<OrderDetailResponse> getOrder(@PathVariable String id) {
return getOrderUseCase.findById(new OrderId(id))
.map(OrderDetailMapper::toResponse)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
}
Putting It Together: A Working Example¶
Domain: Order Fulfillment — a customer places an order, it gets confirmed, then fulfilled.
Domain Events Flow¶
Customer places order
│
▼
Order.place()
│
├── Creates Order aggregate (PENDING)
└── Registers: OrderPlacedEvent
│
▼
PaymentService (listens)
│
└── Processes payment
│
├── On success: Order.confirm()
│ └── Registers: OrderConfirmedEvent
│
└── On failure: Order.cancel()
└── Registers: OrderCancelledEvent
Anti-Corruption Layer Example¶
When integrating with a legacy ERP system that has its own Product model:
// infrastructure/adapter/out/erp/ErpProductAdapter.java
public class ErpProductAdapter implements ProductCatalogPort {
private final LegacyErpClient erpClient; // foreign model
@Override
public Optional<Product> findById(ProductId id) {
// Translate foreign ErpProduct into our domain Product
return erpClient.getProduct(id.value())
.map(this::translate);
}
private Product translate(ErpProduct erp) {
// Shield our domain from ERP's naming conventions and structure
return new Product(
new ProductId(erp.getSkuCode()),
erp.getDisplayName(),
Money.of(erp.getPriceExclVat().toString(), erp.getCurrencyIso())
);
}
}
Package Structure¶
A clean Java module following both DDD and Hexagonal Architecture:
src/main/java/com/yourapp/orders/
│
├── domain/ # Pure Java — zero framework imports
│ ├── model/
│ │ ├── order/
│ │ │ ├── Order.java # Aggregate Root
│ │ │ ├── OrderId.java # Value Object (record)
│ │ │ ├── OrderLine.java # Entity (within aggregate)
│ │ │ ├── OrderStatus.java # Enum / Value Object
│ │ │ └── Money.java # Value Object (record)
│ │ └── customer/
│ │ ├── Customer.java
│ │ └── CustomerId.java
│ ├── event/
│ │ ├── DomainEvent.java # Marker interface
│ │ ├── OrderPlacedEvent.java
│ │ └── OrderConfirmedEvent.java
│ ├── service/
│ │ └── PricingService.java # Domain Service
│ └── exception/
│ └── DomainException.java
│
├── application/ # Orchestration — Spring annotations OK here
│ ├── port/
│ │ ├── in/ # Primary Ports (Use Cases)
│ │ │ ├── PlaceOrderUseCase.java
│ │ │ ├── PlaceOrderCommand.java
│ │ │ ├── ConfirmOrderUseCase.java
│ │ │ └── GetOrderUseCase.java
│ │ └── out/ # Secondary Ports
│ │ ├── OrderRepository.java
│ │ ├── CustomerRepository.java
│ │ └── DomainEventPublisher.java
│ └── service/
│ ├── PlaceOrderService.java # Implements PlaceOrderUseCase
│ └── ConfirmOrderService.java
│
└── infrastructure/ # Frameworks, DBs, HTTP — all dirty details
├── adapter/
│ ├── in/
│ │ ├── web/
│ │ │ ├── OrderController.java
│ │ │ ├── PlaceOrderRequest.java
│ │ │ └── OrderDetailResponse.java
│ │ └── messaging/
│ │ └── PaymentEventConsumer.java
│ └── out/
│ ├── persistence/
│ │ ├── JpaOrderRepository.java # Implements OrderRepository
│ │ ├── OrderJpaEntity.java
│ │ ├── OrderJpaEntityRepository.java # Spring Data interface
│ │ └── OrderMapper.java
│ └── messaging/
│ └── KafkaEventPublisher.java # Implements DomainEventPublisher
└── config/
└── OrderModuleConfiguration.java # Spring @Bean wiring
Each layer has a single, obvious job. When you're debugging a persistence bug, you go to infrastructure/adapter/out/persistence. When a business rule is wrong, you go to domain/model. Navigation becomes muscle memory.
Testing Strategy¶
The layered architecture enables each type of test to run in complete isolation.
Unit Test: Domain (No Spring, No Mocks)¶
class OrderTest {
@Test
void order_cannot_be_placed_without_lines() {
assertThatThrownBy(() -> Order.place(aCustomerId(), emptyList()))
.isInstanceOf(DomainException.class)
.hasMessageContaining("at least one line");
}
@Test
void pending_order_can_be_confirmed() {
Order order = Order.place(aCustomerId(), someOrderLines());
order.confirm();
assertThat(order.status()).isEqualTo(OrderStatus.CONFIRMED);
}
@Test
void confirmed_order_cannot_be_confirmed_again() {
Order order = Order.place(aCustomerId(), someOrderLines());
order.confirm();
assertThatThrownBy(order::confirm)
.isInstanceOf(DomainException.class);
}
}
Unit Test: Application Service (Mocked Ports)¶
class PlaceOrderServiceTest {
OrderRepository orderRepository = mock(OrderRepository.class);
CustomerRepository customerRepository = mock(CustomerRepository.class);
DomainEventPublisher eventPublisher = mock(DomainEventPublisher.class);
PricingService pricingService = new PricingService();
PlaceOrderService service = new PlaceOrderService(
orderRepository, customerRepository, eventPublisher, pricingService
);
@Test
void places_order_and_saves_aggregate() {
given(customerRepository.findById(any())).willReturn(Optional.of(aCustomer()));
OrderId result = service.placeOrder(aPlaceOrderCommand());
assertThat(result).isNotNull();
then(orderRepository).should().save(any(Order.class));
}
}
Integration Test: Secondary Adapter (Testcontainers)¶
@DataJpaTest
@Testcontainers
class JpaOrderRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Autowired
JpaOrderRepository repository;
@Test
void persists_and_retrieves_order() {
Order order = Order.place(aCustomerId(), someOrderLines());
repository.save(order);
Optional<Order> found = repository.findById(order.id());
assertThat(found).isPresent();
assertThat(found.get().status()).isEqualTo(OrderStatus.PENDING);
}
}
E2E / Slice Test: Primary Adapter¶
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mockMvc;
@MockBean PlaceOrderUseCase placeOrderUseCase;
@MockBean GetOrderUseCase getOrderUseCase;
@Test
void returns_201_with_location_header() throws Exception {
given(placeOrderUseCase.placeOrder(any()))
.willReturn(new OrderId("ord-123"));
mockMvc.perform(post("/api/orders")
.contentType(APPLICATION_JSON)
.content(validOrderRequest()))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/api/orders/ord-123"));
}
}
Common Pitfalls¶
Anemic Domain Model
The most common DDD mistake. Entities that are just data containers with getters/setters, and all logic lives in services. If your Order class has no methods beyond accessors, your domain model is anemic.
// ❌ Anemic — logic leaked into service
orderService.confirmOrder(order);
// ✅ Rich — logic belongs to the aggregate
order.confirm();
Infrastructure Leaking into Domain JPA annotations, Spring stereotypes, or Hibernate-specific types inside domain classes. This creates a hard dependency that breaks the entire point of the architecture.
// ❌ JPA in domain
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
}
// ✅ Separate JPA entity in infrastructure; map to/from domain
public class OrderJpaEntity {
@Id @GeneratedValue
private Long id;
}
Repositories Returning JPA Entities Your repository interface should return domain objects, not persistence entities. The mapping belongs in the adapter.
One Aggregate Per Transaction Modifying multiple aggregates in a single transaction is a design smell. Aggregates should be consistent in isolation; cross-aggregate consistency uses eventual consistency via domain events.
Over-engineering Small Contexts Not every module needs full DDD + Hexagonal. CRUD modules with no complex business logic don't need aggregates. Apply the pattern where the business complexity justifies it.
Quick Reference¶
QUESTION → ANSWER
──────────────────────────────────────────────────────
Where does business logic go? → Domain (entities, aggregates, domain services)
Where does orchestration go? → Application service
Where does HTTP parsing go? → Primary adapter (controller)
Where does SQL go? → Secondary adapter (repository impl)
What defines a contract? → Port (interface)
What fulfills a contract? → Adapter (implementation)
What crosses boundaries? → Commands (in), Events (out), DTOs (never domain)
Who depends on whom? → Infrastructure → Application → Domain (always inward)
How do aggregates communicate? → Domain events (never direct method calls)
What enforces invariants? → Aggregate root (always, immediately, on every mutation)
This primer covers the 90% you need for production systems. The remaining 10% — CQRS, Event Sourcing, Saga patterns — build directly on these foundations.