dotnet-cqrs/docs/tutorials/event-sourcing/02-aggregate-design.md

473 lines
13 KiB
Markdown

# Designing Aggregates
Learn how to design aggregates for event sourcing with Svrnty.CQRS.
## What is an Aggregate?
An **aggregate** is a cluster of domain objects that are treated as a single unit for data changes. In event sourcing, an aggregate:
- Enforces business rules and invariants
- Produces events when its state changes
- Rebuilds its state by replaying events
- Has a unique identifier (aggregate ID)
- Has a clear boundary (aggregate root)
## Aggregate Design Principles
### 1. Single Aggregate Root
Each aggregate has one root entity that controls access to all other entities within the aggregate:
```csharp
// ✅ Good: Order is the aggregate root
public class Order
{
public string Id { get; private set; }
private readonly List<OrderLine> _lines = new();
// External access only through root
public void AddLine(string productId, int quantity, decimal price)
{
_lines.Add(new OrderLine(productId, quantity, price));
}
}
public class OrderLine // Not publicly accessible
{
internal OrderLine(string productId, int quantity, decimal price) { }
}
// ❌ Bad: Direct access to child entities
public class Order
{
public List<OrderLine> Lines { get; set; } // Public setter
}
```
### 2. Enforce Invariants
Aggregates validate business rules before producing events:
```csharp
public class BankAccount
{
public string Id { get; private set; }
public decimal Balance { get; private set; }
private decimal _dailyWithdrawalLimit = 1000m;
private decimal _todayWithdrawn = 0m;
public void Withdraw(decimal amount)
{
// Enforce invariants
if (amount <= 0)
throw new InvalidOperationException("Amount must be positive");
if (Balance < amount)
throw new InvalidOperationException("Insufficient funds");
if (_todayWithdrawn + amount > _dailyWithdrawalLimit)
throw new InvalidOperationException("Daily withdrawal limit exceeded");
// Produce event only if rules pass
ApplyEvent(new MoneyWithdrawnEvent
{
AccountId = Id,
Amount = amount,
WithdrawnAt = DateTimeOffset.UtcNow
});
}
private void Apply(MoneyWithdrawnEvent e)
{
Balance -= e.Amount;
_todayWithdrawn += e.Amount;
}
}
```
### 3. Small Aggregate Boundaries
Keep aggregates focused and small:
**Good: Focused aggregates**
```csharp
// Order aggregate manages order lifecycle
public class Order
{
public void Place() { }
public void Ship() { }
public void Cancel() { }
}
// Separate aggregate for inventory
public class InventoryItem
{
public void Reserve(int quantity) { }
public void Release(int quantity) { }
}
```
**Bad: God aggregate**
```csharp
// Aggregate tries to manage too much
public class OrderSystem
{
public void PlaceOrder() { }
public void ManageInventory() { }
public void ProcessPayment() { }
public void SendEmail() { } // Too broad!
}
```
### 4. Reference by ID
Aggregates reference other aggregates by ID, not by direct reference:
```csharp
// ✅ Good: Reference by ID
public class Order
{
public string CustomerId { get; private set; } // ID reference
public void AssignToCustomer(string customerId)
{
CustomerId = customerId;
}
}
// ❌ Bad: Direct reference to another aggregate
public class Order
{
public Customer Customer { get; set; } // Don't hold full aggregate
}
```
## Aggregate Pattern
Here's a complete aggregate pattern for event sourcing:
```csharp
public abstract class AggregateRoot
{
public string Id { get; protected set; } = string.Empty;
public long Version { get; private set; } = 0;
private readonly List<object> _uncommittedEvents = new();
public IReadOnlyList<object> GetUncommittedEvents() => _uncommittedEvents;
public void ClearUncommittedEvents() => _uncommittedEvents.Clear();
protected void ApplyEvent(object @event)
{
Apply(@event);
_uncommittedEvents.Add(@event);
}
public void LoadFromHistory(IEnumerable<object> events)
{
foreach (var @event in events)
{
Apply(@event);
Version++;
}
}
protected abstract void Apply(object @event);
}
```
## Example: Shopping Cart Aggregate
```csharp
public class ShoppingCart : AggregateRoot
{
private readonly Dictionary<string, CartItem> _items = new();
public string CustomerId { get; private set; } = string.Empty;
public ShoppingCartStatus Status { get; private set; }
// Commands
public void Create(string cartId, string customerId)
{
if (!string.IsNullOrEmpty(Id))
throw new InvalidOperationException("Cart already created");
ApplyEvent(new ShoppingCartCreatedEvent
{
CartId = cartId,
CustomerId = customerId,
CreatedAt = DateTimeOffset.UtcNow
});
}
public void AddItem(string productId, string productName, decimal price, int quantity)
{
if (Status != ShoppingCartStatus.Active)
throw new InvalidOperationException("Cart is not active");
if (quantity <= 0)
throw new ArgumentException("Quantity must be positive");
if (_items.ContainsKey(productId))
{
var currentQty = _items[productId].Quantity;
ApplyEvent(new ItemQuantityChangedEvent
{
CartId = Id,
ProductId = productId,
OldQuantity = currentQty,
NewQuantity = currentQty + quantity,
ChangedAt = DateTimeOffset.UtcNow
});
}
else
{
ApplyEvent(new ItemAddedEvent
{
CartId = Id,
ProductId = productId,
ProductName = productName,
Price = price,
Quantity = quantity,
AddedAt = DateTimeOffset.UtcNow
});
}
}
public void RemoveItem(string productId)
{
if (Status != ShoppingCartStatus.Active)
throw new InvalidOperationException("Cart is not active");
if (!_items.ContainsKey(productId))
throw new InvalidOperationException("Item not in cart");
ApplyEvent(new ItemRemovedEvent
{
CartId = Id,
ProductId = productId,
RemovedAt = DateTimeOffset.UtcNow
});
}
public void Checkout()
{
if (Status != ShoppingCartStatus.Active)
throw new InvalidOperationException("Cart is not active");
if (_items.Count == 0)
throw new InvalidOperationException("Cannot checkout empty cart");
ApplyEvent(new ShoppingCartCheckedOutEvent
{
CartId = Id,
Items = _items.Values.ToList(),
TotalAmount = _items.Values.Sum(i => i.Price * i.Quantity),
CheckedOutAt = DateTimeOffset.UtcNow
});
}
// Event application
protected override void Apply(object @event)
{
switch (@event)
{
case ShoppingCartCreatedEvent e:
Id = e.CartId;
CustomerId = e.CustomerId;
Status = ShoppingCartStatus.Active;
break;
case ItemAddedEvent e:
_items[e.ProductId] = new CartItem
{
ProductId = e.ProductId,
ProductName = e.ProductName,
Price = e.Price,
Quantity = e.Quantity
};
break;
case ItemQuantityChangedEvent e:
if (_items.TryGetValue(e.ProductId, out var item))
{
_items[e.ProductId] = item with { Quantity = e.NewQuantity };
}
break;
case ItemRemovedEvent e:
_items.Remove(e.ProductId);
break;
case ShoppingCartCheckedOutEvent e:
Status = ShoppingCartStatus.CheckedOut;
break;
}
}
}
public enum ShoppingCartStatus
{
Active,
CheckedOut,
Abandoned
}
public record CartItem
{
public string ProductId { get; init; } = string.Empty;
public string ProductName { get; init; } = string.Empty;
public decimal Price { get; init; }
public int Quantity { get; init; }
}
// Events
public record ShoppingCartCreatedEvent
{
public string CartId { get; init; } = string.Empty;
public string CustomerId { get; init; } = string.Empty;
public DateTimeOffset CreatedAt { get; init; }
}
public record ItemAddedEvent
{
public string CartId { get; init; } = string.Empty;
public string ProductId { get; init; } = string.Empty;
public string ProductName { get; init; } = string.Empty;
public decimal Price { get; init; }
public int Quantity { get; init; }
public DateTimeOffset AddedAt { get; init; }
}
public record ItemQuantityChangedEvent
{
public string CartId { get; init; } = string.Empty;
public string ProductId { get; init; } = string.Empty;
public int OldQuantity { get; init; }
public int NewQuantity { get; init; }
public DateTimeOffset ChangedAt { get; init; }
}
public record ItemRemovedEvent
{
public string CartId { get; init; } = string.Empty;
public string ProductId { get; init; } = string.Empty;
public DateTimeOffset RemovedAt { get; init; }
}
public record ShoppingCartCheckedOutEvent
{
public string CartId { get; init; } = string.Empty;
public List<CartItem> Items { get; init; } = new();
public decimal TotalAmount { get; init; }
public DateTimeOffset CheckedOutAt { get; init; }
}
```
## Repository Pattern
Implement a repository to load and save aggregates:
```csharp
public interface IAggregateRepository<T> where T : AggregateRoot, new()
{
Task<T> LoadAsync(string aggregateId, CancellationToken ct = default);
Task SaveAsync(T aggregate, CancellationToken ct = default);
}
public class EventSourcedRepository<T> : IAggregateRepository<T> where T : AggregateRoot, new()
{
private readonly IEventStreamStore _eventStore;
public EventSourcedRepository(IEventStreamStore eventStore)
{
_eventStore = eventStore;
}
public async Task<T> LoadAsync(string aggregateId, CancellationToken ct = default)
{
var aggregate = new T();
var events = new List<object>();
await foreach (var storedEvent in _eventStore.ReadStreamAsync(aggregateId, cancellationToken: ct))
{
events.Add(storedEvent.Data);
}
if (events.Count == 0)
throw new AggregateNotFoundException(aggregateId);
aggregate.LoadFromHistory(events);
return aggregate;
}
public async Task SaveAsync(T aggregate, CancellationToken ct = default)
{
foreach (var @event in aggregate.GetUncommittedEvents())
{
await _eventStore.AppendAsync(aggregate.Id, @event, ct);
}
aggregate.ClearUncommittedEvents();
}
}
```
## Command Handler with Aggregate
```csharp
public class AddItemToCartCommandHandler : ICommandHandler<AddItemToCartCommand>
{
private readonly IAggregateRepository<ShoppingCart> _repository;
public AddItemToCartCommandHandler(IAggregateRepository<ShoppingCart> repository)
{
_repository = repository;
}
public async Task HandleAsync(AddItemToCartCommand command, CancellationToken ct)
{
// Load aggregate from event store
var cart = await _repository.LoadAsync(command.CartId, ct);
// Execute command
cart.AddItem(
command.ProductId,
command.ProductName,
command.Price,
command.Quantity
);
// Save new events
await _repository.SaveAsync(cart, ct);
}
}
```
## Best Practices
**DO:**
- Keep aggregates small and focused
- Enforce invariants before producing events
- Use meaningful event names (past tense)
- Reference other aggregates by ID
- Make events immutable
**DON'T:**
- Create god aggregates that do everything
- Allow direct access to child entities
- Hold references to other aggregates
- Put logic in event handlers (only state changes)
- Produce events without validating
## Next Steps
- [03-events-and-workflows.md](03-events-and-workflows.md) - Event design and workflow patterns
- [04-projections.md](04-projections.md) - Build read models from events
- [05-snapshots.md](05-snapshots.md) - Optimize with snapshots
## See Also
- [Event Streaming Fundamentals](../../event-streaming/fundamentals/getting-started.md)
- [Events and Workflows](../../event-streaming/fundamentals/events-and-workflows.md)
- [Command Design](../../best-practices/command-design.md)