Skip to content

Aggregates Guide

Overview

An Aggregate is a cluster of domain objects treated as a single unit for data changes. It has a root entity (Aggregate Root) that controls access to internal entities and enforces invariants. Aggregates define consistency boundaries and control transactional scope.

Core Concepts

Aggregate Root

The entry point to the aggregate that enforces business rules:

php
Product (Aggregate Root)
├── ProductImages (Entities)
├── ProductAttributes (Entities)
└── ProductVariants (Entities - could be separate aggregates)

PurchaseOrder (Aggregate Root)
├── PurchaseOrderItems (Entities)
└── PurchaseOrderActivities (Entities)

Rules:

  • External objects can only reference the aggregate root
  • Internal entities accessed through root
  • Root enforces all invariants
  • Root records domain events

Consistency Boundary

Aggregates define transactional boundaries:

php
// ✅ Single aggregate = single transaction
$product = Product::find(1);
$product->activate();
$product->save();

// ❌ Don't modify multiple aggregates in single method
// Each aggregate = separate transaction

Product Aggregate

Structure

php
<?php

namespace App\Modules\Operations\Domain\Models;

use App\Shared\Domain\Models\BaseEntity;

final class Product extends BaseEntity // Aggregate Root
{
    // Product is the aggregate root
    // It controls access to:
    // - Product attributes (EAV)
    // - Product images
    // - Product tags
    // - Inventory records (separate aggregate?)

    protected $fillable = [
        'name',
        'sku',
        'type',
        'status',
        'selling_price',
        'cost_price',
        // ...
    ];

    // Domain methods enforce business rules
    public static function create(array $data): self
    {
        // Validation
        if (empty($data['name'])) {
            throw new \DomainException('Product name is required');
        }

        // Creation
        $product = new self($data);

        // Record event
        $product->recordEvent(ProductCreated::fromProduct($product));

        return $product;
    }

    public function activate(): void
    {
        // Business rule: can't activate discontinued product
        if ($this->status === ProductStatus::DISCONTINUED) {
            throw new \DomainException('Cannot activate discontinued product');
        }

        // Business rule: already active
        if ($this->status === ProductStatus::ACTIVE) {
            throw new \DomainException('Product is already active');
        }

        // State change
        $this->status = ProductStatus::ACTIVE;

        // Record event
        $this->recordEvent(ProductActivated::fromProduct($this));
    }

    public function deactivate(string $reason): void
    {
        if ($this->status !== ProductStatus::ACTIVE) {
            throw new \DomainException('Only active products can be deactivated');
        }

        $this->status = ProductStatus::INACTIVE;

        $this->recordEvent(
            ProductDeactivated::create($this->id, $reason)
        );
    }

    public function discontinue(string $reason): void
    {
        // Business rule: cannot reactivate after discontinue
        if ($this->status === ProductStatus::DISCONTINUED) {
            throw new \DomainException('Product already discontinued');
        }

        $this->status = ProductStatus::DISCONTINUED;

        $this->recordEvent(
            ProductDiscontinued::create($this->id, $reason)
        );
    }

    // Aggregate controls access to attributes
    public function assignAttribute(string $attributeCode, mixed $value): void
    {
        // Validation
        $attribute = ProductAttribute::where('code', $attributeCode)->firstOrFail();

        // Business logic
        $this->attributes()->updateOrCreate(
            ['attribute_id' => $attribute->id],
            ['value' => $value]
        );

        $this->recordEvent(
            ProductAttributeAssigned::create($this->id, $attributeCode, $value)
        );
    }

    // Relationships
    public function attributes()
    {
        return $this->hasMany(ProductAttributeValue::class);
    }

    public function images()
    {
        return $this->hasMany(ProductImage::class);
    }

    public function tags()
    {
        return $this->belongsToMany(Tag::class);
    }
}

Enforcing Invariants

Invariant: Business rule that must always be true

php
public function updatePrice(Money $newPrice): void
{
    // Invariant: Price must be greater than cost
    if ($newPrice->toFloat() < $this->cost_price->toFloat()) {
        throw new \DomainException('Selling price cannot be less than cost price');
    }

    $oldPrice = $this->selling_price;
    $this->selling_price = $newPrice;

    $this->recordEvent(
        ProductPriceChanged::create($this->id, $oldPrice, $newPrice)
    );
}

PurchaseOrder Aggregate

Structure

php
<?php

namespace App\Modules\Operations\Domain\Models;

final class PurchaseOrder extends BaseEntity // Aggregate Root
{
    // PO controls its line items
    protected $fillable = [
        'po_number',
        'supplier_id',
        'warehouse_id',
        'status',
        'total_amount',
        // ...
    ];

    public static function create(array $data, array $items): self
    {
        // Validation
        if (empty($items)) {
            throw new \DomainException('Purchase order must have at least one item');
        }

        // Create PO
        $po = new self($data);
        $po->save();

        // Add items (controlled by aggregate)
        foreach ($items as $itemData) {
            $po->addItem($itemData);
        }

        // Calculate total
        $po->recalculateTotal();

        $po->recordEvent(PurchaseOrderCreated::fromPurchaseOrder($po));

        return $po;
    }

    public function addItem(array $itemData): void
    {
        // Business rule: can only add items in draft
        if ($this->status !== PurchaseOrderStatus::DRAFT) {
            throw new \DomainException('Can only add items to draft purchase orders');
        }

        $this->items()->create($itemData);
        $this->recalculateTotal();
    }

    public function approve(): void
    {
        // Business rule: only pending can be approved
        if ($this->status !== PurchaseOrderStatus::PENDING_APPROVAL) {
            throw new \DomainException('Only pending orders can be approved');
        }

        $this->status = PurchaseOrderStatus::APPROVED;
        $this->approved_at = now();

        $this->recordEvent(PurchaseOrderApproved::create($this->id));
    }

    public function receive(array $receivedItems): void
    {
        // Business rule: only acknowledged orders can receive
        if (!$this->canReceiveItems()) {
            throw new \DomainException('Order cannot receive items in current status');
        }

        foreach ($receivedItems as $receivedItem) {
            $item = $this->items()->findOrFail($receivedItem['purchase_order_item_id']);
            $item->receive($receivedItem['quantity_received']);
        }

        // Update status based on received quantities
        $this->updateStatusAfterReceiving();

        $this->recordEvent(
            PurchaseOrderReceived::create($this->id, $receivedItems)
        );
    }

    private function recalculateTotal(): void
    {
        $this->total_amount = $this->items->sum(fn($item) =>
            $item->quantity_ordered * $item->unit_price
        );
    }

    private function updateStatusAfterReceiving(): void
    {
        $allReceived = $this->items->every(fn($item) =>
            $item->quantity_received >= $item->quantity_ordered
        );

        $this->status = $allReceived
            ? PurchaseOrderStatus::RECEIVED
            : PurchaseOrderStatus::PARTIALLY_RECEIVED;
    }

    public function items()
    {
        return $this->hasMany(PurchaseOrderItem::class);
    }
}

Aggregate Design Rules

1. Small Aggregates

✅ DO: Keep aggregates small

php
// Good: Product doesn't include all variants
Product
└── ProductAttributes

// Bad: Product includes everything
Product
├── ProductVariants (100s of entities)
├── InventoryRecords (across warehouses)
└── StockMovements (thousands of records)

Make variants separate aggregates or reference by ID.

2. Reference by ID

✅ DO: Reference other aggregates by ID

php
class Product
{
    private int $categoryId;      // ✅ Reference by ID
    private int $supplierId;      // ✅ Reference by ID
}

❌ DON'T: Load entire aggregates

php
class Product
{
    private Category $category;   // ❌ Don't embed aggregate
    private Supplier $supplier;   // ❌ Don't embed aggregate
}

3. Consistency Boundaries

✅ DO: One aggregate per transaction

php
// ✅ Good: Single aggregate
$product = $productRepository->find($id);
$product->activate();
$productRepository->save($product);

❌ DON'T: Modify multiple aggregates

php
// ❌ Bad: Multiple aggregates in one transaction
$product = $productRepository->find($productId);
$category = $categoryRepository->find($categoryId);

$product->activate();
$category->addProduct($product);

$productRepository->save($product);
$categoryRepository->save($category);

Use domain events for cross-aggregate coordination.

4. Invariant Enforcement

✅ DO: Enforce invariants in aggregate root

php
public function receive(int $quantity): void
{
    // Invariant: can't receive more than ordered
    if ($this->quantity_received + $quantity > $this->quantity_ordered) {
        throw new \DomainException('Cannot receive more than ordered');
    }

    $this->quantity_received += $quantity;
}

Eventual Consistency

For cross-aggregate operations, use domain events:

php
// When product is activated
public function activate(): void
{
    $this->status = ProductStatus::ACTIVE;

    // Event for other aggregates
    $this->recordEvent(ProductActivated::fromProduct($this));
}

// Listener updates search index (separate aggregate)
class UpdateSearchIndexOnProductActivated
{
    public function handle(ProductActivated $event): void
    {
        SearchIndex::updateProduct($event->productId);
    }
}

Testing Aggregates

php
public function test_product_activation(): void
{
    $product = Product::factory()->create([
        'status' => ProductStatus::DRAFT,
    ]);

    $product->activate();

    $this->assertEquals(ProductStatus::ACTIVE, $product->status);
    $this->assertNotNull($product->activated_at);
}

public function test_cannot_activate_discontinued_product(): void
{
    $product = Product::factory()->create([
        'status' => ProductStatus::DISCONTINUED,
    ]);

    $this->expectException(\DomainException::class);
    $product->activate();
}

public function test_records_product_activated_event(): void
{
    $product = Product::factory()->create([
        'status' => ProductStatus::DRAFT,
    ]);

    $product->activate();

    $events = $product->releaseEvents();
    $this->assertCount(1, $events);
    $this->assertInstanceOf(ProductActivated::class, $events[0]);
}

Best Practices

DO:

  • ✅ Keep aggregates small
  • ✅ One aggregate root per aggregate
  • ✅ Reference other aggregates by ID
  • ✅ Enforce invariants in root
  • ✅ Record domain events in root
  • ✅ Use eventual consistency for cross-aggregate operations

DON'T:

  • ❌ Create large aggregates with many entities
  • ❌ Modify multiple aggregates in single transaction
  • ❌ Allow direct access to internal entities
  • ❌ Skip invariant enforcement
  • ❌ Use immediate consistency across aggregates

Examples in Operations Module

app/Modules/Operations/Domain/Models/
├── Product.php (Aggregate Root)
├── PurchaseOrder.php (Aggregate Root)
├── Inventory.php (Aggregate Root)
└── StockMovement.php (Aggregate Root - immutable)

Documentation for SynthesQ CRM/ERP Platform