Aggregate — a cluster of domain objects treated as one unit
Concept
Aggregate — in Domain-Driven Design (DDD), a cluster of domain objects (entities and value objects) that are treated as a single unit with one entity acting as the "aggregate root." All external access to the cluster goes through the root.
Why aggregates exist: To enforce consistency boundaries. An Order with its OrderItems must always be consistent — the total must match the sum of items, no item can exist without an order, etc. The aggregate enforces these invariants by controlling access.
Aggregate root: The single entity in the aggregate that external code interacts with. You NEVER access OrderItem directly from outside — you go through Order.
Consistency boundary: All objects inside an aggregate are updated together in a single transaction. The aggregate ensures its invariants hold after every operation. This is the KEY rule: one transaction = one aggregate.
Repository per aggregate root: You have an OrderRepository, not an OrderItemRepository. Items are always fetched/saved through the Order aggregate.
Cross-aggregate references: If two aggregates need to reference each other, they do it by ID (not by direct object reference). Order stores userId (integer), not a User object. This keeps aggregates loosely coupled.
Aggregate size: Keep aggregates small. A large aggregate (Order with 100+ items) will have locking contention. Consider splitting if items can be managed independently.
In Laravel (practical):
- Eloquent model = aggregate root (when it has relations it controls).
- The model's methods enforce invariants.
- Save via repository or
$model->save()+ eager events.
Code Example
<?php
// ORDER AGGREGATE — cluster of Order + OrderItems
class Order // aggregate root
{
private array $items = [];
private array $events = [];
private float $total = 0.0;
public function __construct(
public readonly int $id,
public readonly int $userId, // reference by ID, not User object
private string $status = 'draft',
) {}
// All mutations go through the aggregate root
public function addItem(int $productId, float $unitPrice, int $quantity): void
{
$this->assertNotConfirmed();
$this->items[] = new OrderItem($productId, $unitPrice, $quantity);
$this->total += $unitPrice * $quantity;
}
public function removeItem(int $productId): void
{
$this->assertNotConfirmed();
$item = $this->findItem($productId) ?? throw new \DomainException('Item not found');
$this->items = array_values(array_filter($this->items, fn($i) => $i->productId !== $productId));
$this->total -= $item->subtotal();
}
public function confirm(): void
{
if (empty($this->items)) throw new \DomainException('Cannot confirm empty order');
if ($this->status !== 'draft') throw new \DomainException("Already {$this->status}");
$this->status = 'confirmed';
$this->events[] = new OrderConfirmed($this->id, $this->total);
}
public function total(): float { return $this->total; }
public function items(): array { return $this->items; }
public function releaseEvents(): array { $events = $this->events; $this->events = []; return $events; }
private function assertNotConfirmed(): void
{
if ($this->status !== 'draft') throw new \DomainException("Cannot modify a {$this->status} order");
}
private function findItem(int $productId): ?OrderItem
{
return collect($this->items)->firstWhere('productId', $productId);
}
}
class OrderItem // entity inside the aggregate
{
public function __construct(
public readonly int $productId,
public readonly float $unitPrice,
public readonly int $quantity,
) {}
public function subtotal(): float { return $this->unitPrice * $this->quantity; }
}
// REPOSITORY for the aggregate root (not for OrderItem!)
class OrderRepository
{
public function save(Order $order): void
{
// Persist the order and all its items in one transaction
\DB::transaction(function () use ($order) {
$record = \DB::table('orders')
->updateOrInsert(['id' => $order->id], ['status' => $order->status, 'total' => $order->total()]);
\DB::table('order_items')->where('order_id', $order->id)->delete();
foreach ($order->items() as $item) {
\DB::table('order_items')->insert([
'order_id' => $order->id,
'product_id' => $item->productId,
'unit_price' => $item->unitPrice,
'quantity' => $item->quantity,
]);
}
});
// Dispatch domain events
foreach ($order->releaseEvents() as $event) {
event($event);
}
}
}
// USAGE — all through aggregate root
$order = new Order(1, userId: 42);
$order->addItem(productId: 5, unitPrice: 29.99, quantity: 2);
$order->addItem(productId: 7, unitPrice: 9.99, quantity: 1);
$order->confirm();
echo $order->total(); // 69.97
$repo = new OrderRepository();
$repo->save($order); // saves order + items + dispatches OrderConfirmed event