473 lines
13 KiB
Markdown
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)
|