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
CustomerIdinstead ofCustomerobject- 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:
- What must be consistent at all times?
- What changes together?
- What is the transactional boundary?
- 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);
}Related Concepts
- Entities - Building blocks of aggregates
- Value Objects - Can be part of aggregates
- Repositories - Persist aggregate roots
- Domain Events - Coordinate between aggregates
- Domain Driven Design - Overall pattern context