Skip to main content
Articles/Java/domain-driven-design-aggregates-value-objects
Series: Low-Level Design Interview Prep•Part 8 of 17
Scenario Article

Domain-Driven Design (DDD): Designing Aggregates, Value Objects, and Context Boundaries

Concept: Transitioning to Domain-Driven Design

Abstract Algorithms•Jun 18, 2026
💡 Executive Summary

Domain-Driven Design (DDD) shifts the focus of software development from database schemas to core business logic.

🎯 System Scenario & Requirements

System Goal

Domain-Driven Design (DDD) shifts the focus of software development from database schemas to core business logic.

Actors & Components

  • Application Workers
  • Volatile Memory Tier
  • Persistent Disk Tier
  • Worker Process
  • In-Memory Cache
  • Primary Database
  • Edge Traffic Ingress
  • Stateless Microservices

Functional Requirements

  • TLDR: Domain-Driven Design (DDD) shifts the focus of software development from database schemas to core business logic.
  • By implementing a rich domain model using Entities, Value Objects, and Aggregate Roots, we protect business invariants directly inside our code.
  • This guide contrasts anemic domain models with rich DDD patterns in Java.

Constraints & Assumptions

  • Complexity Overhead: Splitting code into clean domain layers, applications layers, and infrastructure mapping repositories requires more classes and boilerplate converters.
  • Database Mapping Overhead: Hibernate/JPA is naturally designed for table-first entities.
  • Design Large Aggregates: Keeping large collections inside an aggregate makes the aggregate slow to load and save, and causes database locking issues.
  • Keep aggregates small and reference other aggregates by ID.

⚡ Execution Stages & Decision Trace

1

Stage 1 — Mechanics: Entities, Value Objects, and Aggregates

Action / Decision:

To build a rich domain model, DDD divides domain concepts into three primary building blocks: 1\.

Component Behavior:

Entities An Entity has a thread of continuity and a unique identity that remains constant throughout its lifecycle, even if its properties change.

Observable Output: For example, an Order is an entity because it is identified by a unique ID, even if items are added or the shipping status changes.
Read Full Stage in Deep Dive ↓
📖

Deep Dive

Detailed Architectural Analysis

Scenario context

Domain-Driven Design (DDD) shifts the focus of software development from database schemas to core business logic. By implementing a rich domain model using Entities, Value Objects, and Aggregate Roots, we protect business invariants directly inside our code. This guide contrasts anemic domain models with rich DDD patterns in Java.


Observable outcome: The flowchart below visualizes how an Aggregate Root processes a command, validates invariants, mutates state, registers a domain event, and transactionally commits changes: The table below contrasts the behavioral diff…

Stage 3 — Deep Dive: Object Encapsulation & Thread Consistency

  • Designing domain boundaries requires a clear understanding of transactional boundaries, consistency models, and performance overheads.
  • Aggregate Root Consistency Internals The Aggregate Root guarantees that all rules within the aggregate are satisfied during any operation.
  • Because external objects cannot modify internal entities directly, the root controls all state transitions.
  • For example, if a Customer aggregate has a list of Address objects, changes to the primary address must go through Customer.changeAddress() , allowing the customer root to verify that the customer account is active befo…

Observable outcome: For example, if a Customer aggregate has a list of Address objects, changes to the primary address must go through Customer.changeAddress() , allowing the customer root to verify that the customer account is active befo…

Stage 4 — Advanced Concepts: Bounded Context Integration and Domain Events

  • In large-scale systems, different departments use the same term to describe different concepts.
  • For example, to Sales, a Product represents a price and description.
  • To Shipping, a Product represents weight and dimensions.
  • Instead of building a single, bloated product model, DDD divides the system into Bounded Contexts (e.g.

Observable outcome: Instead of building a single, bloated product model, DDD divides the system into Bounded Contexts (e.g.

Stage 5 — Applications: Real-World Enterprise Architectures

  • E-Commerce Checkouts: Managing inventories and applying discount rules within an Order aggregate before committing payment.
  • Banking Ledger Systems: Ensuring that transfer amounts match available balances across account roots while preventing concurrent double-spending.
  • SaaS Multi-Tenant Roles: Managing permissions and user groups within tenant boundaries using Bounded Context mappings.

Observable outcome: SaaS Multi-Tenant Roles: Managing permissions and user groups within tenant boundaries using Bounded Context mappings.

Stage 6 — Practical Implementation: Java Before/After Case Study

  • Let us compare an anemic implementation with a rich DDD domain model implementation of an Order aggregate in Java.
  • Before: The Anemic Domain Model (Anti-Pattern) Here, the entity is a passive data holder, and the validation logic is externalized in a service, allowing any class to bypass rules by calling setters directly.
  • After: The Rich DDD Domain Model (Recommended Pattern) In this implementation, the Order aggregate root enforces invariants within its boundary.
  • Internal data structures are encapsulated (returned as unmodifiable lists), and fields can only be modified through explicit domain methods.
// Anemic Entity
import java.util.List;

public class AnemicOrder {
    private Long id;
    private String status;
    private double totalAmount;
    private List<AnemicOrderLine> lines;

    // Standard getters/setters exposing internals
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }
    public double getTotalAmount() { return totalAmount; }
    public void setTotalAmount(double totalAmount) { this.totalAmount = totalAmount; }
    public List<AnemicOrderLine> getLines() { return lines; }
    public void setLines(List<AnemicOrderLine> lines) { this.lines = lines; }
}

// Procedural Service executing business rules
class AnemicOrderService {
    public void addOrderLine(AnemicOrder order, AnemicOrderLine line) {
        // Validation logic is written outside the entity
        if ("SHIPPED".equals(order.getStatus())) {
            throw new IllegalStateException("Cannot add items to a shipped order");
        }
        order.getLines().add(line);
        order.setTotalAmount(order.getTotalAmount() + (line.getPrice() * line.getQuantity()));
    }
}
// Immutable Value Object representing Money
import java.math.BigDecimal;
import java.util.Objects;

final class Money {
    private final BigDecimal amount;
    private final String currency;

    public Money(BigDecimal amount, String currency) {
        if (amount == null || currency == null) {
            throw new IllegalArgumentException("Amount and currency cannot be null");
        }
        this.amount = amount;
        this.currency = currency;
    }

    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException("Currency mismatch");
        }
        return new Money(this.amount.add(other.amount), this.currency);
    }

    public BigDecimal getAmount() { return amount; }
    public String getCurrency() { return currency; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Money)) return false;
        Money money = (Money) o;
        return amount.compareTo(money.amount) == 0 && currency.equals(money.currency);
    }

    @Override
    public int hashCode() {
        return Objects.hash(amount, currency);
    }
}

// Child Entity inside the Aggregate Boundary
class OrderLine {
    private final String productId;
    private final Money price;
    private final int quantity;

    public OrderLine(String productId, Money price, int quantity) {
        if (productId == null || price == null || quantity <= 0) {
            throw new IllegalArgumentException("Invalid order line values");
        }
        this.productId = productId;
        this.price = price;
        this.quantity = quantity;
    }

    public Money calculateSubtotal() {
        return new Money(price.getAmount().multiply(BigDecimal.valueOf(quantity)), price.getCurrency());
    }
}

// Aggregate Root
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class Order {
    private final Long id;
    private OrderStatus status;
    private Money totalAmount;
    private final List<OrderLine> lines;

    public enum OrderStatus { DRAFT, CONFIRMED, SHIPPED }

    public Order(Long id, String currency) {
        this.id = id;
        this.status = OrderStatus.DRAFT;
        this.totalAmount = new Money(BigDecimal.ZERO, currency);
        this.lines = new ArrayList<>();
    }

    // Method encapsulates state mutation and checks business rules
    public void addProduct(String productId, Money price, int quantity) {
        // Enforce business invariant
        if (this.status == OrderStatus.SHIPPED) {
            throw new IllegalStateException("Cannot modify an order that has already been shipped.");
        }

        OrderLine line = new OrderLine(productId, price, quantity);
        this.lines.add(line);

        // Update total using value object addition
        this.totalAmount = this.totalAmount.add(line.calculateSubtotal());
    }

    public void ship() {
        if (this.lines.isEmpty()) {
            throw new IllegalStateException("Cannot ship an empty order.");
        }
        this.status = OrderStatus.SHIPPED;
    }

    // Expose read-only view of child collection to preserve encapsulation
    public List<OrderLine> getLines() {
        return Collections.unmodifiableList(this.lines);
    }

    public Long getId() { return id; }
    public OrderStatus getStatus() { return status; }
    public Money getTotalAmount() { return totalAmount; }
}

Observable outcome: Internal data structures are encapsulated (returned as unmodifiable lists), and fields can only be modified through explicit domain methods.

End-to-end outcome

After working through the stages, you should be able to explain:

  • Rich Domain Model: Combines data and behavior inside entities, enforcing invariants directly in the domain layer.
  • Value Objects: Immutable objects with no identity.

Alternatives considered

Decision Guide: Anemic vs. Rich Domain Models

Use this guide to choose the right model for your application:

Failure and degraded modes

Trade-offs and Failure Modes

  • Detection: Complexity Overhead: Splitting code into clean domain layers, applications layers, and infrastructure mapping repositories requires more classes and boilerplate converters.
  • User or operator impact: Complexity Overhead: Splitting code into clean domain layers, applications layers, and infrastructure mapping repositories requires more classes and boilerplate converters.

Lessons Learned: Common DDD Pitfalls

  • Detection: Design Large Aggregates: Keeping large collections inside an aggregate makes the aggregate slow to load and save, and causes database locking issues.
  • User or operator impact: Design Large Aggregates: Keeping large collections inside an aggregate makes the aggregate slow to load and save, and causes database locking issues.

Share this case study

Discuss architecture trade-offs and lessons with your team.

Share:XLinkedInReddit