Aggregates
Aggregates are the primary building blocks for domain modeling. An aggregate is a cluster of domain objects that can be treated as a single unit for data changes. Every aggregate has a root entity (the Aggregate Root) that controls all access to objects within the aggregate.
Before You Start
- .NET 10.0
- Install the required package:
dotnet add package Excalibur.Domain
- Familiarity with domain modeling concepts and event sourcing
The AggregateRoot Base Class
Excalibur provides AggregateRoot<TId> as the base class for all aggregates:
using Excalibur.Domain.Model;
public class Order : AggregateRoot<Guid>
{
// Your aggregate implementation
}
Key Properties
| Property | Type | Description |
|---|---|---|
Id | TId | Unique identifier for the aggregate |
Version | long | Current version (event count) |
ETag | string? | Concurrency token for optimistic locking |
AggregateType | string | Type name for serialization |
Key Methods
| Method | Purpose |
|---|---|
RaiseEvent(event) | Record a new domain event |
GetUncommittedEvents() | Get events pending persistence |
MarkEventsAsCommitted() | Clear uncommitted events after save |
LoadFromHistory(events) | Rehydrate from stored events |
CreateSnapshot() | Create state snapshot (optional) |
Creating Aggregates
Constructor Pattern
Always emit events in constructors rather than setting state directly:
public class Order : AggregateRoot<Guid>
{
public string CustomerId { get; private set; } = string.Empty;
public OrderStatus Status { get; private set; }
// Parameterless constructor for rehydration
public Order() { }
// Domain constructor emits creation event
public Order(Guid id, string customerId)
{
if (id == Guid.Empty)
throw new ArgumentException("Order ID cannot be empty", nameof(id));
if (string.IsNullOrWhiteSpace(customerId))
throw new ArgumentException("Customer ID is required", nameof(customerId));
RaiseEvent(new OrderCreated(id, customerId));
}
}
Domain Events
Define events as immutable records extending DomainEvent:
using Excalibur.Dispatch;
public record OrderCreated(Guid OrderId, string CustomerId) : DomainEvent
{
public override string AggregateId => OrderId.ToString();
}
public record OrderLineAdded(Guid OrderId, string ProductId, int Quantity, decimal UnitPrice) : DomainEvent
{
public override string AggregateId => OrderId.ToString();
}
public record OrderSubmitted(Guid OrderId) : DomainEvent
{
public override string AggregateId => OrderId.ToString();
}
The DomainEvent abstract record automatically provides:
EventId- UUID v7 for time-ordered uniquenessAggregateId- String identifier of the owning aggregateVersion- Aggregate version for orderingOccurredAt- UTC timestamp (auto-generated)EventType- Type name for serializationMetadata- Dictionary for cross-cutting concerns
Event Application
Excalibur uses the RaiseEvent/Apply pattern with pattern matching for event application, providing near-zero overhead (under 10ns per event). For a detailed explanation of why this pattern was chosen over alternatives like When/Apply, see the Event Application Pattern guide.
protected override void ApplyEventInternal(IDomainEvent @event) => _ = @event switch
{
OrderCreated e => Apply(e),
OrderLineAdded e => Apply(e),
OrderSubmitted e => Apply(e),
_ => throw new InvalidOperationException($"Unknown event: {@event.GetType().Name}")
};
private bool Apply(OrderCreated e)
{
Id = e.OrderId;
CustomerId = e.CustomerId;
Status = OrderStatus.Draft;
return true;
}
private bool Apply(OrderLineAdded e)
{
_lines.Add(new OrderLine(e.ProductId, e.Quantity, e.UnitPrice));
Total = _lines.Sum(l => l.Quantity * l.UnitPrice);
return true;
}
private bool Apply(OrderSubmitted e)
{
Status = OrderStatus.Submitted;
return true;
}
Enforcing Invariants
Aggregates are the natural place to enforce business rules:
public void AddLine(string productId, int quantity, decimal unitPrice)
{
// Invariant: Can only modify draft orders
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Can only add lines to draft orders");
// Invariant: Positive quantities
if (quantity <= 0)
throw new ArgumentException("Quantity must be positive", nameof(quantity));
// Invariant: Maximum lines limit
if (_lines.Count >= 100)
throw new InvalidOperationException("Order cannot have more than 100 lines");
// Invariant: No duplicate products
if (_lines.Any(l => l.ProductId == productId))
throw new InvalidOperationException($"Product {productId} already in order");
// All validations passed - emit event
RaiseEvent(new OrderLineAdded(Id, productId, quantity, unitPrice));
}
ID Types
Excalibur supports multiple ID types:
String Keys (Default)
public class Customer : AggregateRoot
{
// Id is string by default
}
GUID Keys
public class Order : AggregateRoot<Guid>
{
// Id is Guid
}
Strongly-Typed IDs
For maximum type safety, use custom ID types:
// Define the ID type
public readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
public override string ToString() => Value.ToString();
}
// Use in aggregate
public class Order : AggregateRoot<OrderId>
{
public Order(OrderId id, string customerId)
{
RaiseEvent(new OrderCreated(id, customerId));
}
}
// Usage
var orderId = OrderId.New();
var order = new Order(orderId, "CUST-123");
CRTP Convenience Base Class
For aggregates that want zero-argument repository registration and built-in factory methods, use the CRTP (Curiously Recurring Template Pattern) base class:
public class OrderAggregate : AggregateRoot<OrderAggregate, Guid>
{
public string CustomerId { get; private set; } = string.Empty;
public OrderStatus Status { get; private set; }
// CRTP requires parameterless constructor via new() constraint
public OrderAggregate() { }
// Static factory methods satisfy IAggregateRoot<TAggregate, TKey>
public static OrderAggregate Create(Guid id) => new() { Id = id };
public static OrderAggregate FromEvents(Guid id, IEnumerable<IDomainEvent> events)
{
var aggregate = Create(id);
aggregate.LoadFromHistory(events);
return aggregate;
}
protected override void ApplyEventInternal(IDomainEvent @event) => _ = @event switch
{
OrderCreated e => Apply(e),
OrderShipped e => Apply(e),
_ => throw new InvalidOperationException($"Unknown event: {@event.GetType().Name}")
};
private bool Apply(OrderCreated e) { Id = e.OrderId; CustomerId = e.CustomerId; Status = OrderStatus.Draft; return true; }
private bool Apply(OrderShipped _) { Status = OrderStatus.Shipped; return true; }
}
Benefits of the CRTP Base Class
| Feature | AggregateRoot<TKey> | AggregateRoot<TAggregate, TKey> (CRTP) |
|---|---|---|
| DI registration | AddRepository<OrderAggregate, Guid>() | AddRepository<OrderAggregate, Guid>() with type inference |
| Factory methods | Manual | Built-in Create(TKey) + FromEvents(TKey, events) |
IAggregateRoot<TAggregate, TKey> | Must implement separately | Automatic |
| Snapshot compatibility | ✅ | ✅ |
| Existing code impact | N/A | None — AggregateRoot<TKey> still works |
Use CRTP when you want the framework to create aggregate instances (e.g., during rehydration from events or snapshot loading). If your aggregates use domain constructors with business parameters, the standard AggregateRoot<TKey> base class is sufficient.
Working with Uncommitted Events
After modifying an aggregate, uncommitted events are available for persistence:
// Create and modify aggregate
var order = new Order(Guid.NewGuid(), "CUST-123");
order.AddLine("PROD-1", 2, 29.99m);
order.Submit();
// Get events to persist
var events = order.GetUncommittedEvents();
// Returns: [OrderCreated, OrderLineAdded, OrderSubmitted]
// After saving to event store
order.MarkEventsAsCommitted();
// Events cleared, version incremented
Rehydrating from History
Load an aggregate from stored events:
// Events from event store
var events = new IDomainEvent[]
{
new OrderCreated(orderId, "CUST-123", createdAt),
new OrderLineAdded(orderId, "PROD-1", 2, 29.99m),
new OrderSubmitted(orderId, submittedAt)
};
// Create empty aggregate and load history
var order = new Order();
order.LoadFromHistory(events);
// State is now:
// order.Status == OrderStatus.Submitted
// order.Lines.Count == 1
// order.Version == 3
Design Guidelines
Keep Aggregates Small
Include only what's needed for invariant enforcement:
// Good: Order contains only what it needs
public class Order : AggregateRoot<Guid>
{
public string CustomerId { get; private set; } // Reference by ID
private readonly List<OrderLine> _lines = new(); // Part of order invariants
}
// Avoid: Don't embed entire related aggregates
public class Order : AggregateRoot<Guid>
{
public Customer Customer { get; } // Wrong: Customer is its own aggregate
}
Reference Other Aggregates by ID
public class Order : AggregateRoot<Guid>
{
public string CustomerId { get; private set; } // ID reference
public Guid ShippingAddressId { get; private set; } // ID reference
// Load related data through services when needed
}
Protect Internal State
Use private setters and expose read-only collections:
public class Order : AggregateRoot<Guid>
{
private readonly List<OrderLine> _lines = new();
// Expose as read-only
public IReadOnlyList<OrderLine> Lines => _lines;
// All modifications through methods
public void AddLine(string productId, int quantity, decimal unitPrice)
{
// Validation and event emission
}
}
Next Steps
- Entities - Model objects with identity within aggregates
- Value Objects - Model immutable concepts
- Event Sourcing - Persist aggregate state as events
See Also
- Entities - Model objects with identity that live within aggregate boundaries
- Value Objects - Immutable domain concepts compared by their attributes
- Event Sourcing Aggregates - Persisting and loading aggregates with event sourcing