# 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 _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 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 _uncommittedEvents = new(); public IReadOnlyList GetUncommittedEvents() => _uncommittedEvents; public void ClearUncommittedEvents() => _uncommittedEvents.Clear(); protected void ApplyEvent(object @event) { Apply(@event); _uncommittedEvents.Add(@event); } public void LoadFromHistory(IEnumerable 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 _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 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 where T : AggregateRoot, new() { Task LoadAsync(string aggregateId, CancellationToken ct = default); Task SaveAsync(T aggregate, CancellationToken ct = default); } public class EventSourcedRepository : IAggregateRepository where T : AggregateRoot, new() { private readonly IEventStreamStore _eventStore; public EventSourcedRepository(IEventStreamStore eventStore) { _eventStore = eventStore; } public async Task LoadAsync(string aggregateId, CancellationToken ct = default) { var aggregate = new T(); var events = new List(); 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 { private readonly IAggregateRepository _repository; public AddItemToCartCommandHandler(IAggregateRepository 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)