Aggregates are clusters of entities and value objects treated as a single unit for data changes, with one entity designated as the aggregate root.

Core Characteristics

Aggregate Root

  • One entity serves as the entry point
  • External objects can only reference the root
  • Root enforces invariants for entire aggregate

Consistency Boundary

  • All rules enforced within aggregate
  • Transactions don’t span aggregates
  • Eventual consistency between aggregates

Single Unit of Persistence

  • Loaded and saved as a whole
  • No partial aggregate states
  • Repository works with roots only

Example: Order Aggregate

public class Order : AggregateRoot<OrderId>  // Aggregate Root
{
    // Id is inherited from Entity<OrderId> via AggregateRoot
    private List<OrderLine> _lines = new();  // Child entities
    public Address ShippingAddress { get; private set; }  // Value object
    public OrderStatus Status { get; private set; }
 
    // Only way to modify lines is through root
    public void AddItem(ProductId productId, int quantity, Money price)
    {
        if (Status != OrderStatus.Draft)
            throw new InvalidOperationException("Cannot modify submitted order");
 
        var line = _lines.FirstOrDefault(l => l.ProductId == productId);
        if (line != null)
            line.IncreaseQuantity(quantity);
        else
            _lines.Add(new OrderLine(productId, quantity, price));
 
        // Invariant: Order total must be > 0
        if (CalculateTotal() <= 0)
            throw new InvalidOperationException("Order total must be positive");
    }
 
    public void Submit()
    {
        if (!_lines.Any())
            throw new InvalidOperationException("Cannot submit empty order");
 
        Status = OrderStatus.Submitted;
        // Collected on the aggregate; dispatched after a successful save
        AddDomainEvent(new OrderSubmitted(Id));
    }
}
 
public class OrderLine  // Child entity, not a root
{
    public ProductId ProductId { get; private set; }
    public int Quantity { get; private set; }
    public Money UnitPrice { get; private set; }
 
    // Internal - only Order can call this
    internal void IncreaseQuantity(int amount)
    {
        Quantity += amount;
    }
}

Design Principles

Small Aggregates

  • Prefer smaller aggregates over larger ones
  • Include only what must be consistent
  • Reduces contention and improves performance

Reference by ID

  • External references use IDs, not object references
  • CustomerId instead of Customer object
  • Prevents large object graphs

Invariant Enforcement

  • Root protects business rules
  • No way to violate rules from outside
  • Consistency guaranteed within boundary

Identifying Aggregate Boundaries

Questions to Ask:

  1. What must be consistent at all times?
  2. What changes together?
  3. What is the transactional boundary?
  4. What is the unit of retrieval?

Example: Blog System

Good Aggregate Boundaries:

Aggregate: BlogPost (root)
  - PostId
  - Title, Content
  - Author (by reference: AuthorId)
  - Comments (list of Comment entities)
  - Tags (list of Tag value objects)

Aggregate: Author (root)
  - AuthorId
  - Name, Bio
  - Posts (by reference: List<PostId>)

Poor Aggregate Boundary:

Aggregate: Blog (root)  // Too large!
  - All Authors (full objects)
  - All Posts (full objects)
    - All Comments (full objects)
  - All Tags (full objects)
// Would load entire blog on every operation!

Aggregate Patterns

Aggregate Root as Gateway

// Good: Through root
order.AddItem(productId, quantity, price);
 
// Bad: Direct access
order.Lines.Add(new OrderLine(...));  // Bypasses validation!

Internal Constructors for Children

public class OrderLine
{
    // Internal: only Order aggregate can create
    internal OrderLine(ProductId productId, int quantity, Money price)
    {
        ProductId = productId;
        Quantity = quantity;
        UnitPrice = price;
    }
}

Invariant Protection

public class ShoppingCart  // Root
{
    private const int MaxItems = 100;
    private List<CartItem> _items = new();
 
    public void AddItem(ProductId productId, int quantity)
    {
        // Invariant: Max 100 items
        if (_items.Sum(i => i.Quantity) + quantity > MaxItems)
            throw new InvalidOperationException("Cart cannot exceed 100 items");
 
        // Invariant: Quantity must be positive
        if (quantity <= 0)
            throw new ArgumentException("Quantity must be positive");
 
        _items.Add(new CartItem(productId, quantity));
    }
}

Transaction Boundaries

One Aggregate Per Transaction

// Good: Single aggregate
public void PlaceOrder(OrderId orderId)
{
    var order = _orderRepository.GetById(orderId);
    order.Submit();
    _orderRepository.Save(order);
}
 
// Bad: Multiple aggregates in one transaction
public void ProcessPayment(OrderId orderId, PaymentId paymentId)
{
    var order = _orderRepository.GetById(orderId);
    var payment = _paymentRepository.GetById(paymentId);
 
    order.MarkAsPaid();
    payment.Complete();
 
    // Two aggregates = potential consistency issues!
    _orderRepository.Save(order);
    _paymentRepository.Save(payment);
}

Use Domain Events for Cross-Aggregate Changes

// Good: Eventual consistency via events
public void PlaceOrder(OrderId orderId)
{
    var order = _orderRepository.GetById(orderId);
    order.Submit();   // collects OrderSubmitted on the aggregate
 
    // Save dispatches the collected event after the commit, which
    // triggers inventory reduction in a separate transaction.
    _orderRepository.Save(order);
}
 
// Separate handler updates different aggregate
public class OrderSubmittedHandler
{
    public void Handle(OrderSubmitted @event)
    {
        // Different transaction, different aggregate
        var inventory = _inventoryRepository.GetByOrderId(@event.OrderId);
        inventory.ReserveItems(@event.Items);
        _inventoryRepository.Save(inventory);
    }
}

Common Aggregate Examples

E-Commerce

  • Order: OrderLines, ShippingAddress, BillingAddress
  • Product: ProductImages, Variants, Reviews
  • ShoppingCart: CartItems

Banking

  • Account: Transactions (recent), Balance
  • Customer: ContactInfo, Addresses
  • Loan: Payments, Terms

Project Management

  • Project: Tasks, Members (by reference)
  • Task: Comments, Checklist Items
  • Team: Members, Roles

Anti-Patterns

God Aggregate

// Bad: Everything in one aggregate
public class Company  // Too large!
{
    public List<Department> Departments { get; set; }
    public List<Employee> Employees { get; set; }
    public List<Project> Projects { get; set; }
    public List<Customer> Customers { get; set; }
    // Hundreds of thousands of objects!
}

Aggregate Bypass

// Bad: Direct modification of children
var orderLine = order.Lines.First();
orderLine.Quantity = 5;  // Bypassed Order validation!
 
// Good: Through aggregate root
order.UpdateLineQuantity(lineId, 5);

Multi-Aggregate Transactions

// Bad: Modifying multiple aggregates
public void TransferMoney(AccountId from, AccountId to, Money amount)
{
    var fromAccount = _repo.GetById(from);
    var toAccount = _repo.GetById(to);
 
    fromAccount.Withdraw(amount);  // Aggregate 1
    toAccount.Deposit(amount);     // Aggregate 2
 
    // Both in same transaction = coupling!
    _repo.Save(fromAccount);
    _repo.Save(toAccount);
}
 
// Good: Use saga or process manager
public void TransferMoney(AccountId from, AccountId to, Money amount)
{
    // The MoneyTransfer aggregate records TransferInitiated on creation;
    // Save dispatches it after the commit, and handlers run the debit and
    // credit as separate transactions.
    var transfer = new MoneyTransfer(from, to, amount);
    _transferRepository.Save(transfer);
}

References