Skip to content

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.

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:

PatternDescription
Shared KernelTwo contexts share a small, agreed-upon model
Customer/SupplierUpstream context publishes; downstream consumes
Anti-Corruption Layer (ACL)Translation layer that protects your model from a foreign model
Open Host ServicePublished API for many consumers
ConformistDownstream 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 BlockResponsibility
EntityHas identity that persists through state changes
Value ObjectImmutable, defined by its attributes, no identity
AggregateCluster of entities/VOs with a root that enforces invariants
Domain EventSignals something significant happened in the domain
RepositoryAbstracts storage/retrieval of aggregates
Domain ServiceStateless operation that doesn't belong to an entity or VO
Application ServiceOrchestrates use cases, lives outside the domain
FactoryComplex creation logic extracted from entities/aggregates

DDD Building Blocks in Java ​

Entity ​

Entities are compared by identity, not by value.

java
// 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.

java
// 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 record for Value Objects. The compiler generates equals(), hashCode(), and toString() 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.

java
// 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 ​

java
// 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) ​

java
// 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.

java
// 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:

Ports are interfaces that define how the application communicates with the outside world. There are two kinds:

TypeAlso CalledDirectionExample
Primary PortDriving PortOutside → AppUse case interface, CLI handler
Secondary PortDriven PortApp → OutsideRepository, email sender, message bus

Adapters are concrete implementations of ports.

TypeAlso CalledDirectionExample
Primary AdapterDriving AdapterDrives the applicationOrderController (REST), OrderCLI
Secondary AdapterDriven AdapterDriven by the applicationJpaOrderRepository, 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) ​

java
// application/port/in/PlaceOrderUseCase.java
public interface PlaceOrderUseCase {
    OrderId placeOrder(PlaceOrderCommand command);
}
java
// application/port/in/PlaceOrderCommand.java
public record PlaceOrderCommand(
    CustomerId customerId,
    List<OrderLineDto> lines
) {}

Application Service (Implements Primary Port) ​

java
// 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 if statements on business rules here.

Secondary Port (Driven Port) ​

java
// application/port/out/DomainEventPublisher.java
public interface DomainEventPublisher {
    void publish(DomainEvent event);
}

Secondary Adapter (Infrastructure Implementation) ​

java
// 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) ​

java
// 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 ​

Anti-Corruption Layer Example ​

When integrating with a legacy ERP system that has its own Product model:

java
// 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:

text
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) ​

java
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) ​

java
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) ​

java
@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 ​

java
@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.

java
// ❌ 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.

java
// ❌ 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 ​

text
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.