Aggregates — The Consistency Boundary and the Aggregate Root
What is an aggregate?
Up to the previous chapter, you learned about value objects and entities. This chapter covers the concept of the "aggregate" that groups them.
An aggregate is a structure (boundary) with the following characteristics.
- A boundary that groups entities and value objects
- It has an aggregate root: the access point from outside
- A consistency boundary: it guarantees consistency within the aggregate
- A unit of transaction: as a rule, update one aggregate per transaction
The original DDD text says that "you can update multiple aggregates in one transaction, but that is a sign to reconsider your design." When you need to update multiple aggregates at once, consider the following:
- Reconsider whether the aggregate boundaries are appropriate
- Handle it with eventual consistency using domain events
- Only when unavoidable, update multiple aggregates in the same transaction
This is covered in detail in this book's chapter on transaction management.
What is the aggregate root?
The aggregate root is the sole entry point for accessing the aggregate from outside.
The role of the aggregate root
The aggregate root plays the role of a house's "front door."
[A house analogy]
The front door (the aggregate root)
↓
- Visitors first go through the front door
- The rule "take off your shoes" is enforced at the front door
- The living room and bedrooms are accessed via the front door
[An aggregate analogy]
Order (the aggregate root)
↓
- The outside accesses everything via Order
- Order enforces the business rules
- OrderLine is accessed via Order
Good and bad examples
// Good: access via the aggregate root (Order)
$order = $orderRepository->findById($orderId);
$order->addItem($productId, $quantity, $unitPrice);
$orderRepository->save($order);
// → Order enforces rules such as "cannot add after confirmation"
// Bad: accessing OrderLine directly
$orderLine = $orderLineRepository->findById($lineId); // This is bad
$orderLine->updateQuantity(5); // A risk of breaking consistency
// → Problem: you cannot check Order's state (DRAFT/CONFIRMED)
// → Problem: you can change the line items of a confirmed order without permission
Why access must go through the aggregate root
Allowing direct access causes the following problems.
Problem 1: bypassing business rules
// Bad pattern: accessing OrderLine directly
class UpdateOrderLineQuantityUseCase
{
public function execute(OrderLineId $lineId, int $newQuantity): void
{
// Retrieve OrderLine directly
$orderLine = $this->orderLineRepository->findById($lineId);
$orderLine->updateQuantity($newQuantity);
$this->orderLineRepository->save($orderLine);
}
}
// Problem: it does not check the parent Order's state (whether it is confirmed)!
// Problem: you can change the line items of a confirmed order!
// Good pattern: access via Order
class UpdateOrderLineQuantityUseCase
{
public function execute(OrderId $orderId, OrderLineId $lineId, int $newQuantity): void
{
// Retrieve Order (via the aggregate root)
$order = $this->orderRepository->findById($orderId);
$order->updateLineQuantity($lineId, $newQuantity);
$this->orderRepository->save($order);
}
}
class Order
{
public function updateLineQuantity(OrderLineId $lineId, int $newQuantity): void
{
// Business rule: can be changed only in the draft state
if (!$this->status->isDraft()) {
throw new DomainException('Line items cannot be changed after confirmation');
}
// Find the line item and update it
foreach ($this->orderLines as $line) {
if ($line->id()->equals($lineId)) {
$line->updateQuantity($newQuantity);
return;
}
}
}
}
Problem 2: breaking consistency
// Bad pattern: saving Order and OrderLine separately
$order = $orderRepository->findById($orderId);
$orderLine = $orderLineRepository->findById($lineId);
$orderLine->updateQuantity(10);
$orderLineRepository->save($orderLine); // Saves only OrderLine
// Problem: the order's total amount is not updated!
// Problem: the transaction boundary is unclear
// Good pattern: save the aggregate root (Order)
$order = $orderRepository->findById($orderId);
$order->updateLineQuantity($lineId, 10);
$orderRepository->save($order); // Saves the whole Order
// → OrderLine is saved along with it
// → The total amount is recalculated
// → Consistency is maintained
- You can bypass business rules → invalid states get created
- The consistency boundary is unclear → you don't know what should be saved together
- The transaction boundary is ambiguous → a risk of data inconsistency
- The scope of a change is hard to track → reduced maintainability
The aggregate root acts as the "gatekeeper" and prevents these problems.
The conditions for becoming an aggregate root
An entity that meets the following conditions becomes the aggregate root.
| Condition | Description | Example in the Order aggregate |
|---|---|---|
| The starting point of access from outside | Other objects are reached via this object | Order lines are operated on via the order |
| The manager of the lifecycle | Manages the creation and deletion of internal objects | When an order is deleted, its line items are deleted too |
| The party responsible for consistency | Responsible for enforcing business rules | Manages "cannot add after confirmation" |
Why access is via the aggregate root
Because the aggregate root guarantees consistency.
class Order
{
public function addItem(ProductId $productId, int $quantity, Money $unitPrice): void
{
// Business rule: products cannot be added after confirmation
if ($this->status === OrderStatus::CONFIRMED) {
throw new DomainException('Products cannot be added after confirmation');
}
$this->orderLines[] = new OrderLine($lineId, $productId, $quantity, $unitPrice);
}
}
If OrderLine could be accessed directly, you could bypass this business rule.
Criteria for deciding aggregate boundaries
Use the following criteria to decide whether to put things in the same aggregate or in separate aggregates.
| Criterion | Same aggregate | Separate aggregates |
|---|---|---|
| Lifecycle | Created/deleted together | Exists independently |
| Scope of consistency | Always needs consistency | Eventual consistency is fine |
| Transaction | Updated together | Can be updated separately |
| Reference source | Only from this aggregate | Referenced from multiple places |
Understanding aggregate boundaries with concrete examples
Example 1: Order and OrderLine are in the same aggregate
Question 1: Same lifecycle?
→ YES: deleting an order deletes its line items too
Question 2: Always need consistency?
→ YES: the order's total amount and the sum of the line items should always match
Question 3: Updated in the same transaction?
→ YES: adding a line item also updates the order's total amount at the same time
Question 4: Referenced only from this aggregate?
→ YES: OrderLine is accessed only from Order
Conclusion: put them in the same aggregate
Example 2: Order and Product are in separate aggregates
Question 1: Same lifecycle?
→ NO: deleting an order does not delete the product
Conclusion: put them in separate aggregates
→ Order holds only the ProductId
→ If you need product information, retrieve it via ProductRepository
Example 3: Order and User are in separate aggregates
Question 1: Same lifecycle?
→ NO: deleting an order does not delete the user
Conclusion: put them in separate aggregates
→ Order holds only the UserId
→ If you need user information, retrieve it via UserRepository
Immediate consistency vs eventual consistency
An important concept in deciding aggregate boundaries is the timing of consistency.
Immediate consistency
When you need to keep consistency immediately within the same transaction.
// Example: the order's total amount and the sum of the line items
$order->addItem($productId, 2, Money::jpy(1000));
// → order.totalAmount must be updated to 2000 yen immediately
// Consistent immediately within the transaction
DB::transaction(function () use ($order) {
// Save Order and OrderLine at the same time
$this->orderRepository->save($order);
});
// → Consistency is guaranteed at the point of commit
Eventual consistency
When it is fine as long as consistency is achieved eventually. It does not have to be in the same transaction.
// Example: decreasing stock after an order is confirmed
// Confirm the order (the Order aggregate)
$order->confirm();
$this->orderRepository->save($order); // Transaction 1
// Decreasing the stock (the Inventory aggregate) can be done later
// Process it asynchronously with a domain event or a queue
event(new OrderConfirmed($order->id()));
// → It is fine as long as the stock eventually decreases
// → A few seconds of lag is acceptable
Criteria for choosing between immediate and eventual consistency
| Criterion | Immediate consistency | Eventual consistency |
|---|---|---|
| Business requirement | Data inconsistency is not allowed | Short-term inconsistency is acceptable |
| Performance | The transaction becomes heavy | Faster with asynchronous processing |
| Complexity | Simple (a single transaction) | Complex (event processing is needed) |
| Example | The order total and the sum of the line items | Decreasing stock after an order, granting points |
An example of implementing eventual consistency
// 1. Publish a domain event
class Order
{
public function confirm(): void
{
// ... confirm the state
// Record the domain event
$this->recordEvent(new OrderConfirmed($this->id));
}
}
// 2. Process asynchronously in an event handler
class OrderConfirmedHandler
{
public function handle(OrderConfirmed $event): void
{
// Decrease the stock in a separate transaction
$order = $this->orderRepository->findById($event->orderId);
foreach ($order->orderLines() as $line) {
// Update the Inventory aggregate (a separate transaction)
$this->inventoryService->decrease(
$line->productId(),
$line->quantity()
);
}
}
}
- A consistency model in which you do not need to keep consistency immediately, but the state becomes consistent eventually
- Example: the processing of decreasing stock after an order is confirmed does not have to be in the same transaction; in some cases it is fine as long as the stock is correctly decreased eventually
- Benefits: improved performance and scalability
- Downsides: a more complex implementation and temporary inconsistency
Points for choosing
- Business requirement is "consistency is needed immediately" → put it in the same aggregate
- Business requirement is "consistency achieved eventually is fine" → put it in a separate aggregate
The domain events for achieving eventual consistency are covered in detail in Chapter 9, "Domain events".
References across aggregates
When referencing another aggregate, hold only the ID.
Why only the ID?
// Bad: holding the whole Product
class OrderLine
{
private Product $product; // Holds the whole Product aggregate
}
// Problems:
// - When Product changes, it affects OrderLine too
// - It becomes unclear which aggregate holds the "correct" state
// Good: holding only the ProductId
class OrderLine
{
private ProductId $productId; // Holds only the ID
}
// Benefits:
// - Loose coupling between aggregates
// - Each aggregate can change independently
Snapshots
A copy of the state at a certain point in time—such as the shipping address at the time of an order—is called a snapshot.
[Timeline]
1. At order time
User's address: Shibuya, Tokyo
Order's shipping address: Shibuya, Tokyo ← saved as a snapshot
2. The user moves
User's address: Umeda, Osaka ← changed
Order's shipping address: Shibuya, Tokyo ← unchanged
What is the shipping address? → Shibuya, Tokyo (the address at order time)
| Storage method | Description | Example |
|---|---|---|
| Snapshot | A copy of the state at a point in time | The shipping address at order time, the product price at order time |
| Reference | Refer to the latest by ID | A reference to the product master (showing the latest information) |
Further reading
If you want to learn more about aggregates, see the following resource.
- DDD_Aggregate - Martin Fowler: a clear explanation of the concept of the aggregate
In the next chapter, we learn about the "domain service," which handles domain logic that does not belong to an entity.