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:
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:
// ✅ Single aggregate = single transaction
$product = Product::find(1);
$product->activate();
$product->save();
// ❌ Don't modify multiple aggregates in single method
// Each aggregate = separate transactionProduct Aggregate
Structure
<?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
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
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
// 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
class Product
{
private int $categoryId; // ✅ Reference by ID
private int $supplierId; // ✅ Reference by ID
}❌ DON'T: Load entire aggregates
class Product
{
private Category $category; // ❌ Don't embed aggregate
private Supplier $supplier; // ❌ Don't embed aggregate
}3. Consistency Boundaries
✅ DO: One aggregate per transaction
// ✅ Good: Single aggregate
$product = $productRepository->find($id);
$product->activate();
$productRepository->save($product);❌ DON'T: Modify multiple aggregates
// ❌ 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
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:
// 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
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
Related Patterns
- Domain Events - Cross-aggregate communication
- Repository Pattern - Aggregate persistence
- CQRS Pattern - Commands operate on aggregates
- Value Objects - VOs used in 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)