Aggregates: Consistency Boundaries
Aggregates are the most important tactical pattern in DDD. They define consistency boundaries - clusters of entities and value objects that must be consistent as a unit. Get aggregate design right, and your system is robust. Get it wrong, and you'll fight consistency bugs forever.
TL;DR
| Concept | Definition |
|---|---|
| Aggregate | Cluster of entities/VOs treated as a single unit |
| Aggregate Root | The single entry point to the aggregate |
| Invariant | Business rule that must always be true |
| Consistency Boundary | Changes within aggregate are atomic |
The Consistency Problem
Without aggregates, you get inconsistent state:
// ❌ Without aggregates - inconsistent state possible
public class OrderService
{
public void AddItem(Guid orderId, Guid productId, int quantity)
{
var order = _orderRepo.GetById(orderId);
var orderLine = new OrderLine(productId, quantity);
_orderLineRepo.Add(orderLine); // Saved
order.Total += orderLine.Price; // What if this fails?
_orderRepo.Save(order); // Not saved yet!
// Order lines exist but total is wrong!
}
}
The Aggregate Solution
// ✅ With aggregates - consistency guaranteed
public class Order : AggregateRoot<OrderId>
{
private readonly List<OrderLine> _lines = new();
public Money Total { get; private set; }
public void AddLine(ProductId productId, int quantity, Money unitPrice)
{
// All changes happen together
var line = new OrderLine(productId, quantity, unitPrice);
_lines.Add(line);
Total = Total.Add(line.LineTotal);
// Invariant: Total always equals sum of lines
}
}
// Repository saves the entire aggregate atomically
await _orderRepository.Save(order);
Aggregate Anatomy
Key Rules
- Single Root: One entity is the aggregate root
- External Access: Outside code only references the root
- Internal Navigation: Root controls access to internals
- Atomic Persistence: Entire aggregate saved as unit
- Reference by ID: Other aggregates reference by ID, not object
Designing Aggregates
Rule 1: Protect Invariants
An invariant is a business rule that must always be true:
public class Order : AggregateRoot<OrderId>
{
private readonly List<OrderLine> _lines = new();
public OrderStatus Status { get; private set; }
// INVARIANT: Cannot add items to shipped order
public void AddLine(ProductId productId, int quantity, Money unitPrice)
{
if (Status == OrderStatus.Shipped)
throw new OrderAlreadyShippedException();
var line = new OrderLine(productId, quantity, unitPrice);
_lines.Add(line);
RecalculateTotal();
}
// INVARIANT: Cannot ship order with no items
public void Ship()
{
if (!_lines.Any())
throw new CannotShipEmptyOrderException();
Status = OrderStatus.Shipped;
AddDomainEvent(new OrderShipped(Id));
}
}
Rule 2: Keep Aggregates Small
Large aggregates cause problems:
// ❌ Too large - Customer contains everything
public class Customer : AggregateRoot<CustomerId>
{
public List<Order> Orders { get; } // Could be millions!
public List<Address> Addresses { get; }
public List<PaymentMethod> PaymentMethods { get; }
}
// ✅ Right size - Customer is focused
public class Customer : AggregateRoot<CustomerId>
{
public CustomerName Name { get; }
public EmailAddress Email { get; }
public Address DefaultShippingAddress { get; }
}
// Orders are separate aggregate, reference Customer by ID
public class Order : AggregateRoot<OrderId>
{
public CustomerId CustomerId { get; } // Reference by ID
}
Rule 3: Reference Other Aggregates by ID
// ❌ Object reference - creates coupling
public class Order : AggregateRoot<OrderId>
{
public Customer Customer { get; } // Full object
}
// ✅ ID reference - loose coupling
public class Order : AggregateRoot<OrderId>
{
public CustomerId CustomerId { get; } // Just the ID
}
Rule 4: Use Eventual Consistency Between Aggregates
// ❌ Transaction spanning multiple aggregates
public void PlaceOrder(Order order)
{
using var transaction = _db.BeginTransaction();
_orderRepo.Save(order);
_inventoryRepo.Reserve(order.Lines); // Different aggregate!
transaction.Commit();
}
// ✅ Eventual consistency via domain events
public class Order : AggregateRoot<OrderId>
{
public void Place()
{
Status = OrderStatus.Placed;
// Emit event - other aggregates react eventually
AddDomainEvent(new OrderPlaced(Id, Lines));
}
}
Complete Example: Order Aggregate
public class Order : AggregateRoot<OrderId>
{
public CustomerId CustomerId { get; }
public Address ShippingAddress { get; private set; }
public OrderStatus Status { get; private set; }
public Money Total { get; private set; }
private readonly List<OrderLine> _lines = new();
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
public static Order Create(CustomerId customerId, Address shippingAddress)
{
var order = new Order(OrderId.New(), customerId, shippingAddress);
order.AddDomainEvent(new OrderCreated(order.Id, customerId));
return order;
}
public void AddLine(ProductId productId, int quantity, Money unitPrice)
{
EnsureNotShipped();
if (quantity <= 0) throw new InvalidQuantityException(quantity);
_lines.Add(new OrderLine(productId, quantity, unitPrice));
RecalculateTotal();
}
public void Place()
{
if (Status != OrderStatus.Draft)
throw new InvalidOrderStateException(Status, "Place");
if (!_lines.Any())
throw new CannotPlaceEmptyOrderException();
Status = OrderStatus.Placed;
AddDomainEvent(new OrderPlaced(Id, CustomerId, Lines.ToList(), Total));
}
public void Ship(TrackingNumber trackingNumber)
{
if (Status != OrderStatus.Placed)
throw new InvalidOrderStateException(Status, "Ship");
Status = OrderStatus.Shipped;
AddDomainEvent(new OrderShipped(Id, trackingNumber));
}
private void EnsureNotShipped()
{
if (Status == OrderStatus.Shipped)
throw new OrderAlreadyShippedException();
}
private void RecalculateTotal()
{
Total = _lines.Select(l => l.LineTotal)
.Aggregate(Money.Zero, (sum, price) => sum.Add(price));
}
}
Quick Reference Card
┌─────────────────────────────────────────────────────────┐
│ AGGREGATE QUICK REFERENCE │
├─────────────────────────────────────────────────────────┤
│ │
│ DEFINITION │
│ Cluster of entities/VOs with consistency boundary │
│ │
│ RULES │
│ 1. Single aggregate root (entry point) │
│ 2. External access only through root │
│ 3. Reference other aggregates by ID │
│ 4. Save entire aggregate atomically │
│ 5. Eventual consistency between aggregates │
│ │
│ DESIGN PRINCIPLES │
│ • Keep aggregates small │
│ • Protect invariants │
│ • Model true business rules │
│ • Prefer eventual consistency │
│ │
└─────────────────────────────────────────────────────────┘