Skip to main content

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.

  1. A boundary that groups entities and value objects
  2. It has an aggregate root: the access point from outside
  3. A consistency boundary: it guarantees consistency within the aggregate
  4. A unit of transaction: as a rule, update one aggregate per transaction
"One transaction = one aggregate" is not an absolute rule

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:

  1. Reconsider whether the aggregate boundaries are appropriate
  2. Handle it with eventual consistency using domain events
  3. 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
Why you must not directly access an entity outside the aggregate
  1. You can bypass business rules → invalid states get created
  2. The consistency boundary is unclear → you don't know what should be saved together
  3. The transaction boundary is ambiguous → a risk of data inconsistency
  4. 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.

ConditionDescriptionExample in the Order aggregate
The starting point of access from outsideOther objects are reached via this objectOrder lines are operated on via the order
The manager of the lifecycleManages the creation and deletion of internal objectsWhen an order is deleted, its line items are deleted too
The party responsible for consistencyResponsible for enforcing business rulesManages "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.

CriterionSame aggregateSeparate aggregates
LifecycleCreated/deleted togetherExists independently
Scope of consistencyAlways needs consistencyEventual consistency is fine
TransactionUpdated togetherCan be updated separately
Reference sourceOnly from this aggregateReferenced 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

CriterionImmediate consistencyEventual consistency
Business requirementData inconsistency is not allowedShort-term inconsistency is acceptable
PerformanceThe transaction becomes heavyFaster with asynchronous processing
ComplexitySimple (a single transaction)Complex (event processing is needed)
ExampleThe order total and the sum of the line itemsDecreasing 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()
);
}
}
}
What is eventual consistency?
  • 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 methodDescriptionExample
SnapshotA copy of the state at a point in timeThe shipping address at order time, the product price at order time
ReferenceRefer to the latest by IDA reference to the product master (showing the latest information)

Further reading

If you want to learn more about aggregates, see the following resource.

In the next chapter, we learn about the "domain service," which handles domain logic that does not belong to an entity.