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

13 KiB

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:

// ✅ 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:

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

// 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

// 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:

// ✅ 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:

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

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:

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

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

See Also