Skip to main content

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

ConceptDefinition
AggregateCluster of entities/VOs treated as a single unit
Aggregate RootThe single entry point to the aggregate
InvariantBusiness rule that must always be true
Consistency BoundaryChanges 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

  1. Single Root: One entity is the aggregate root
  2. External Access: Outside code only references the root
  3. Internal Navigation: Root controls access to internals
  4. Atomic Persistence: Entire aggregate saved as unit
  5. 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 │
│ │
└─────────────────────────────────────────────────────────┘