DDD Aggregates & Aggregate Roots
debt(d7/e7/b7/t7)
Closest to 'only careful code review or runtime testing' (d7). While phpstan and deptrac are listed in detection_hints, they cannot automatically detect poor aggregate boundaries — tools can catch some symptoms like layer violations, but identifying that an aggregate is too large, that transactions span multiple aggregates, or that internal collections are exposed requires careful architectural review or runtime observation of contention issues. The detection_hints explicitly note 'automated: no'.
Closest to 'cross-cutting refactor across the codebase' (e7). Fixing poorly designed aggregates means redrawing consistency boundaries, which typically requires: splitting large aggregates into smaller ones, introducing domain events for cross-aggregate communication, changing how references work (ID-only instead of object references), and updating all code that interacts with these aggregates. The quick_fix mentions redesigning aggregates and potentially introducing domain events, indicating significant architectural work.
Closest to 'strong gravitational pull' (b7). Aggregate boundaries shape how every feature touching the domain is implemented — they define transaction boundaries, what can be loaded together, how invariants are enforced, and how different parts of the system communicate. Once established, aggregate design influences all future domain work. The term applies across web/cli/queue contexts, and poor aggregate design creates persistent drag on the entire domain layer.
Closest to 'serious trap' (t7). The misconception field directly states developers believe 'aggregates should be as large as possible to keep related data together' when the opposite is true — aggregates should be as small as possible. This contradicts intuitive thinking about grouping related data and differs from how ORMs encourage loading object graphs. Developers coming from CRUD/Active Record patterns will naturally design oversized aggregates, causing contention and complexity.
Also Known As
TL;DR
Explanation
An Aggregate is a cluster of related domain objects (entities and value objects) with a single Aggregate Root — the only entity that external objects can hold a reference to. The root ensures all invariants are maintained: an Order (root) controls its OrderItems — you can't add an OrderItem without going through Order::addItem(), which validates business rules like maximum quantity. Aggregates define transactional boundaries: you load and save an aggregate atomically; two aggregates in the same transaction is a design smell. Keep aggregates small — an Order with thousands of items should be reconsidered. Reference other aggregates by ID (not direct object reference) to enforce boundaries. Events crossing aggregate boundaries use domain events.
Diagram
flowchart TD
subgraph OrderAggregate
ROOT[Order aggregate root]
LINE[OrderLine]
ADDR[ShippingAddress]
ROOT --> LINE & ADDR
end
subgraph CustomerAggregate
CROOT[Customer aggregate root]
PROFILE[Profile]
CROOT --> PROFILE
end
ROOT -->|reference by ID only| CROOT
INFO[Aggregates communicate by ID<br/>Each has its own transaction boundary]
style ROOT fill:#6e40c9,color:#fff
style CROOT fill:#6e40c9,color:#fff
style INFO fill:#1f6feb,color:#fff
Common Misconception
Why It Matters
Common Mistakes
- Aggregates that are too large — every entity in a domain is not part of one aggregate; keep them small.
- Referencing aggregate internals from outside the aggregate root — go through the root always.
- Saving multiple aggregates in a single transaction — use eventual consistency via domain events instead.
- Exposing internal collection references from the aggregate root — callers can modify state without invariant checks.
Code Examples
// Modifying aggregate internals directly — bypasses invariants:
$order->items[] = new OrderItem($product, $qty); // Direct collection mutation
// Should be: $order->addItem($product, $qty) — root enforces invariants
// Aggregate — cluster of domain objects treated as a single unit
// All changes go through the Aggregate Root
class Order { // Aggregate Root
private array \$items = [];
private OrderStatus \$status;
// Only Order can add items — enforces invariants
public function addItem(Product \$product, int \$qty): void {
if (\$this->status !== OrderStatus::Draft) throw new OrderNotEditableException();
if (\$qty < 1) throw new InvalidQuantityException();
\$this->items[] = new OrderItem(\$product, \$qty);
}
public function getTotal(): Money {
return array_reduce(\$this->items, fn(\$sum, \$i) => \$sum->add(\$i->getTotal()), Money::zero());
}
}
// Never manipulate OrderItem directly from outside — always go through Order
// Repository saves/loads the entire aggregate atomically