0

Aggregate — a cluster of domain objects treated as one unit

Advanced5 min read·eng-16-017
interviewcompare

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
<?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