Explain the concept of Aggregates in DDD and how they maintain consistency.
Quick Answer
An Aggregate is a cluster of related objects treated as a single unit for data changes, with one Aggregate Root as the only entry point. The root enforces the aggregate's invariants and external code may only reference the root, so all changes go through it — making the aggregate the transactional consistency boundary. Keep aggregates small, reference other aggregates by ID, and update one aggregate per transaction.
Detailed Answer
Aggregates are one of the most important tactical patterns in Domain-Driven Design. They define consistency boundaries and ensure that business rules are maintained within a cluster of related objects.
What are Aggregates?
An Aggregate is a cluster of related objects that are treated as a unit for the purpose of data changes. It has one Aggregate Root that serves as the entry point and controls access to all objects within the aggregate.
Key Characteristics:
- Consistency Boundary: All business rules within an aggregate are enforced
- Single Entry Point: Only the aggregate root can be referenced from outside
- Transactional Consistency: Changes to an aggregate are atomic
- Invariant Enforcement: Business rules are maintained within the aggregate
Example: Order Aggregate
public class Order : AggregateRoot<OrderId>
{
public OrderId Id { get; private set; }
public CustomerId CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
public Money Total { get; private set; }
public DateTime CreatedAt { get; private set; }
// Private collection - only accessible through aggregate root
private readonly List<OrderItem> _items = new();
public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
public Order(OrderId id, CustomerId customerId) : base(id)
{
Id = id;
CustomerId = customerId ?? throw new ArgumentNullException(nameof(customerId));
Status = OrderStatus.Draft;
Total = new Money(0, "USD");
CreatedAt = DateTime.UtcNow;
}
// Business operations that maintain invariants
public void AddItem(ProductId productId, Quantity quantity, Money unitPrice)
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Cannot add items to a confirmed order");
if (quantity.Value <= 0)
throw new ArgumentException("Quantity must be positive");
var existingItem = _items.FirstOrDefault(i => i.ProductId == productId);
if (existingItem != null)
{
existingItem.IncreaseQuantity(quantity);
}
else
{
_items.Add(new OrderItem(productId, quantity, unitPrice));
}
RecalculateTotal();
}
public void RemoveItem(ProductId productId)
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Cannot remove items from a confirmed order");
var item = _items.FirstOrDefault(i => i.ProductId == productId);
if (item != null)
{
_items.Remove(item);
RecalculateTotal();
}
}
public void Confirm()
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Order is not in draft status");
if (_items.Count == 0)
throw new InvalidOperationException("Cannot confirm an empty order");
// Business rule: Minimum order amount
if (Total.Amount < 10)
throw new InvalidOperationException("Minimum order amount is $10");
Status = OrderStatus.Confirmed;
AddDomainEvent(new OrderConfirmedEvent(Id, CustomerId, Total));
}
public void Cancel()
{
if (Status == OrderStatus.Shipped)
throw new InvalidOperationException("Cannot cancel a shipped order");
Status = OrderStatus.Cancelled;
AddDomainEvent(new OrderCancelledEvent(Id, CustomerId));
}
public void Ship()
{
if (Status != OrderStatus.Confirmed)
throw new InvalidOperationException("Only confirmed orders can be shipped");
Status = OrderStatus.Shipped;
AddDomainEvent(new OrderShippedEvent(Id, CustomerId));
}
// Private method to maintain consistency
private void RecalculateTotal()
{
Total = _items.Aggregate(new Money(0, "USD"), (sum, item) => sum + item.Total);
}
}
// OrderItem is part of the Order aggregate
public class OrderItem : Entity<OrderItemId>
{
public ProductId ProductId { get; private set; }
public Quantity Quantity { get; private set; }
public Money UnitPrice { get; private set; }
public Money Total => UnitPrice * Quantity.Value;
public OrderItem(ProductId productId, Quantity quantity, Money unitPrice)
: base(new OrderItemId(Guid.NewGuid()))
{
ProductId = productId ?? throw new ArgumentNullException(nameof(productId));
Quantity = quantity ?? throw new ArgumentNullException(nameof(quantity));
UnitPrice = unitPrice ?? throw new ArgumentNullException(nameof(unitPrice));
}
public void IncreaseQuantity(Quantity additionalQuantity)
{
if (additionalQuantity.Value <= 0)
throw new ArgumentException("Additional quantity must be positive");
Quantity = new Quantity(Quantity.Value + additionalQuantity.Value);
}
public void UpdateQuantity(Quantity newQuantity)
{
if (newQuantity.Value <= 0)
throw new ArgumentException("Quantity must be positive");
Quantity = newQuantity;
}
}
Consistency Rules and Invariants:
public class Order : AggregateRoot<OrderId>
{
// Invariant: Order total must always equal sum of item totals
private void RecalculateTotal()
{
Total = _items.Aggregate(new Money(0, "USD"), (sum, item) => sum + item.Total);
}
// Invariant: Cannot add items to confirmed orders
public void AddItem(ProductId productId, Quantity quantity, Money unitPrice)
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Cannot add items to a confirmed order");
// ... rest of implementation
}
// Invariant: Cannot confirm empty orders
public void Confirm()
{
if (_items.Count == 0)
throw new InvalidOperationException("Cannot confirm an empty order");
// ... rest of implementation
}
// Invariant: Order total must be positive
private void ValidateTotal()
{
if (Total.Amount < 0)
throw new InvalidOperationException("Order total cannot be negative");
}
}
Aggregate Boundaries:
// ✅ GOOD: Order and OrderItem are in the same aggregate
public class Order : AggregateRoot<OrderId>
{
private readonly List<OrderItem> _items = new();
// OrderItem is part of Order aggregate
}
// ❌ BAD: Order and Customer in the same aggregate
public class Order : AggregateRoot<OrderId>
{
public Customer Customer { get; set; } // Customer should be separate aggregate
}
// ✅ GOOD: Order references Customer by ID
public class Order : AggregateRoot<OrderId>
{
public CustomerId CustomerId { get; private set; } // Reference to another aggregate
}
Repository Pattern with Aggregates:
public interface IOrderRepository
{
Task<Order> GetByIdAsync(OrderId id);
Task SaveAsync(Order order);
Task DeleteAsync(OrderId id);
}
public class OrderRepository : IOrderRepository
{
private readonly ApplicationDbContext _context;
public async Task<Order> GetByIdAsync(OrderId id)
{
var orderEntity = await _context.Orders
.Include(o => o.Items) // Load entire aggregate
.FirstOrDefaultAsync(o => o.Id == id.Value);
if (orderEntity == null)
return null;
return MapToDomain(orderEntity);
}
public async Task SaveAsync(Order order)
{
var orderEntity = MapToEntity(order);
// Save entire aggregate as a unit
if (_context.Entry(orderEntity).State == EntityState.Detached)
_context.Orders.Add(orderEntity);
else
_context.Orders.Update(orderEntity);
await _context.SaveChangesAsync();
}
private Order MapToDomain(OrderEntity entity)
{
var order = new Order(new OrderId(entity.Id), new CustomerId(entity.CustomerId));
foreach (var itemEntity in entity.Items)
{
order.AddItem(
new ProductId(itemEntity.ProductId),
new Quantity(itemEntity.Quantity),
new Money(itemEntity.UnitPrice, "USD")
);
}
return order;
}
}
Domain Events in Aggregates:
public class Order : AggregateRoot<OrderId>
{
public void Confirm()
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Order is not in draft status");
Status = OrderStatus.Confirmed;
// Publish domain event
AddDomainEvent(new OrderConfirmedEvent(Id, CustomerId, Total));
}
public void Cancel()
{
if (Status == OrderStatus.Shipped)
throw new InvalidOperationException("Cannot cancel a shipped order");
Status = OrderStatus.Cancelled;
// Publish domain event
AddDomainEvent(new OrderCancelledEvent(Id, CustomerId));
}
}
// Domain event
public class OrderConfirmedEvent : DomainEvent
{
public OrderId OrderId { get; }
public CustomerId CustomerId { get; }
public Money Total { get; }
public DateTime ConfirmedAt { get; }
public OrderConfirmedEvent(OrderId orderId, CustomerId customerId, Money total)
{
OrderId = orderId;
CustomerId = customerId;
Total = total;
ConfirmedAt = DateTime.UtcNow;
}
}
Best Practices for Aggregates:
- Keep Aggregates Small: Small aggregates are easier to understand and maintain
- Single Responsibility: Each aggregate should have one clear responsibility
- Consistency Boundaries: Define clear boundaries for business rules
- Reference by ID: Reference other aggregates by ID, not by object
- Eventual Consistency: Use domain events for cross-aggregate communication
// ✅ GOOD: Small, focused aggregate
public class Order : AggregateRoot<OrderId>
{
// Only order-related business logic
public void AddItem(ProductId productId, Quantity quantity, Money unitPrice) { }
public void Confirm() { }
public void Cancel() { }
}
// ❌ BAD: Large aggregate with too many responsibilities
public class Order : AggregateRoot<OrderId>
{
public void AddItem(ProductId productId, Quantity quantity, Money unitPrice) { }
public void Confirm() { }
public void Cancel() { }
public void ProcessPayment() { } // Should be separate aggregate
public void UpdateInventory() { } // Should be separate aggregate
public void SendNotification() { } // Should be separate aggregate
}
Key Benefits:
- Consistency: Business rules are enforced within the aggregate
- Encapsulation: Internal state is protected
- Performance: Can load entire aggregate in one transaction
- Simplicity: Clear boundaries make the system easier to understand
- Maintainability: Changes to business rules are localized
Aggregates are essential for maintaining data consistency and enforcing business rules in complex domains. They provide a clear structure for organizing related objects and ensure that the system remains in a valid state at all times.