Aggregates & Entities in DDD
IntermediateAn aggregate is a cluster of domain objects treated as a single unit; the aggregate root enforces invariants and is the only object that external code holds references to.
Overview
An Aggregate is a DDD pattern for grouping related domain objects (Entities and Value Objects) into a consistency boundary. The Aggregate Root is the top-level Entity of the cluster: it is the only object external code holds a reference to, and all mutations must go through it. The root enforces all business invariants for the whole cluster. Aggregates should be designed to be as small as possible while still maintaining consistency — large aggregates cause contention and poor concurrency. Reference between aggregates is done by ID (not direct object reference).
Aggregate Root & Invariant Enforcement
The root entity controls all access to internal objects. External services may only hold a reference to the root's ID, not to child entities. State changes only happen through root methods that validate invariants before applying changes.
// Order aggregate — Order is the root; OrderLine is an internal entity
@Entity
@Table(name = "orders")
public class Order { // Aggregate Root
@Id
private OrderId id;
private CustomerId customerId; // reference by ID (not the Customer object)
private OrderStatus status;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderLine> lines = new ArrayList<>();
private Money total;
// All mutations go through root methods
public void addLine(ProductId productId, int qty, Money price) {
if (status != OrderStatus.DRAFT)
throw new OrderNotEditableException(id);
if (lines.size() >= 50)
throw new TooManyLinesException(id);
lines.add(new OrderLine(productId, qty, price));
this.total = recalculateTotal();
}
public void place() {
if (lines.isEmpty())
throw new EmptyOrderException(id);
this.status = OrderStatus.PLACED;
registerEvent(new OrderPlaced(id, customerId, total));
}
private Money recalculateTotal() {
return lines.stream()
.map(OrderLine::lineTotal)
.reduce(Money.ZERO, Money::add);
}
}Aggregate Size & Concurrency
Large aggregates cause contention because loading and saving the whole cluster serialises concurrent access. Keep aggregates small — favour eventual consistency between aggregates over strict ACID within one large aggregate.
// Anti-pattern: over-large aggregate
// Customer aggregate with all orders, addresses, payment methods, reviews
public class Customer {
private List<Order> orders; // ✗ wrong — Order is its own aggregate
private List<Address> addresses;
private List<PaymentMethod> paymentMethods;
// Any write to Customer locks ALL this data — concurrency nightmare
}
// Correct: separate aggregates, reference by ID
public class Customer {
private CustomerId id;
private String email;
private List<AddressId> addressIds; // IDs only, not objects
}
public class Order {
private OrderId id;
private CustomerId customerId; // reference Customer by ID
// ...
}
// Eventual consistency between aggregates via Domain Events
// ✓ Order.place() → OrderPlaced event
// ✓ LoyaltyService subscribes to OrderPlaced → updates points asynchronously
// ✗ NOT: order.place() + customer.addPoints() in same ACID transactionRepository per Aggregate
Each aggregate root has exactly one repository. The repository loads and saves the complete aggregate. Never query internal entities (OrderLine) directly — always go through the root.
// Repository interface — domain layer
public interface OrderRepository {
Optional<Order> findById(OrderId id);
List<Order> findByCustomer(CustomerId customerId);
void save(Order order);
void delete(OrderId id);
}
// Rule: no repository for OrderLine — access via Order
// ✗ orderLineRepository.findByOrderId(orderId); — wrong
// ✓ orderRepository.findById(orderId)
// .map(order -> order.getLines()) — correct
// Application service — load aggregate, call root method, save
@Service
@Transactional
public class OrderApplicationService {
public void addLineToOrder(AddLineCommand cmd) {
Order order = orderRepository.findById(cmd.orderId())
.orElseThrow(() -> new OrderNotFoundException(cmd.orderId()));
order.addLine(cmd.productId(), cmd.quantity(), cmd.unitPrice());
orderRepository.save(order);
order.pullDomainEvents().forEach(eventPublisher::publish);
}
}Key Points to Remember
- 1Aggregate Root is the single entry point for all mutations within the cluster.
- 2External code holds only the Aggregate Root's ID — never direct references to internal entities.
- 3All invariants are enforced inside Root methods before state changes are applied.
- 4Keep aggregates small; use Domain Events + eventual consistency between aggregates.
- 5One Repository per Aggregate Root — never repository methods for internal entities.
- 6Reference other aggregates by ID to avoid loading unrelated data on every operation.
Interview Questions
Sign in to ask AriaWhat is an Aggregate Root and why should external code only hold its ID?
Why should aggregates be kept small?
How do you maintain consistency between two aggregates in DDD?
Can two aggregates share a repository? Why or why not?
What is the difference between an Entity and a Value Object inside an aggregate?
Ask Aria about Aggregates & Entities in DDD
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.