Skip to main content

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​

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​

PropertyTypeDescription
IdTIdUnique identifier for the aggregate
VersionlongCurrent version (event count)
ETagstring?Concurrency token for optimistic locking
AggregateTypestringType name for serialization

Key Methods​

MethodPurpose
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;

[MessageName("Contoso.Orders.OrderCreated")]
public record OrderCreated(Guid OrderId, string CustomerId) : DomainEvent;

[MessageName("Contoso.Orders.OrderLineAdded")]
public record OrderLineAdded(Guid OrderId, string ProductId, int Quantity, decimal UnitPrice) : DomainEvent;

[MessageName("Contoso.Orders.OrderSubmitted")]
public record OrderSubmitted(Guid OrderId) : DomainEvent;

[MessageName] declares the permanent name the event is stored and transmitted under. It is required on every registered event type, and it is the one identity used by the event store, the outbox MessageType, and the CloudEvents type. See Stable Message Names.

The DomainEvent abstract record automatically provides:

  • EventId - UUID v7 for time-ordered uniqueness
  • OccurredAt - UTC timestamp (auto-generated)
  • Metadata - Dictionary for cross-cutting concerns

A domain event carries only its own business data. The aggregate id is supplied to the event store when appending or loading events; the stream version is assigned by the store (StoredEvent.Version / HistoricEvent), never on the event payload.

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 bool ApplyEventInternal(IDomainEvent @event) => @event switch
{
OrderCreated e => Apply(e),
OrderLineAdded e => Apply(e),
OrderSubmitted e => Apply(e),
_ => false
};

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<HistoricEvent> events)
{
var aggregate = Create(id);
aggregate.LoadFromHistory(events);
return aggregate;
}

protected override bool ApplyEventInternal(IDomainEvent @event) => @event switch
{
OrderCreated e => Apply(e),
OrderShipped e => Apply(e),
_ => false
};

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​

FeatureAggregateRoot<TKey>AggregateRoot<TAggregate, TKey> (CRTP)
DI registrationAddRepository<OrderAggregate, Guid>()AddRepository<OrderAggregate, Guid>() with type inference
Factory methodsManualBuilt-in Create(TKey) + FromEvents(TKey, events)
IAggregateRoot<TAggregate, TKey>Must implement separatelyAutomatic
Snapshot compatibility✅✅
Existing code impactN/ANone — AggregateRoot<TKey> still works
When to Use CRTP

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​

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