Class Aggregate<TId>
- Namespace
- Trellis
- Assembly
- Trellis.DomainDrivenDesign.dll
Base class for aggregate roots in Domain-Driven Design. An aggregate is a cluster of domain objects (entities and value objects) that form a consistency boundary. The aggregate root is the only entry point for modifications and ensures all invariants within the boundary are maintained.
public abstract class Aggregate<TId> : Entity<TId>, IAggregate, IChangeTracking where TId : notnull
Type Parameters
TIdThe type of the aggregate root's unique identifier. Must be non-nullable.
- Inheritance
-
Entity<TId>Aggregate<TId>
- Implements
- Inherited Members
- Extension Methods
Examples
Simple aggregate with validation and domain events:
public class Order : Aggregate<OrderId>
{
private readonly List<OrderLine> _lines = [];
public CustomerId CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
public Money Total { get; private set; }
public DateTime CreatedAt { get; }
public DateTime? SubmittedAt { get; private set; }
// Internal entities are protected and accessed through methods
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
private Order(OrderId id, CustomerId customerId)
: base(id)
{
CustomerId = customerId;
Status = OrderStatus.Draft;
Total = Money.Zero;
CreatedAt = DateTime.UtcNow;
}
public static Result<Order> Create(CustomerId customerId) =>
customerId.ToResult(Error.Validation("Customer ID required"))
.Map(id => new Order(OrderId.NewUnique(), id));
// All modifications go through methods that enforce invariants
public Result<Order> AddLine(ProductId productId, int quantity, Money unitPrice) =>
this.ToResult()
.Ensure(_ => Status == OrderStatus.Draft,
Error.Validation("Cannot modify submitted order"))
.Ensure(_ => quantity > 0,
Error.Validation("Quantity must be positive"))
.Ensure(_ => _lines.Count < 100,
Error.Validation("Order cannot have more than 100 lines"))
.Tap(_ =>
{
var line = new OrderLine(productId, quantity, unitPrice);
_lines.Add(line);
Total = Total.Add(unitPrice.Multiply(quantity));
// Raise domain event
DomainEvents.Add(new OrderLineAddedEvent(Id, productId, quantity));
});
public Result<Order> Submit() =>
this.ToResult()
.Ensure(_ => Status == OrderStatus.Draft,
Error.Validation("Order already submitted"))
.Ensure(_ => _lines.Count > 0,
Error.Validation("Cannot submit empty order"))
.Tap(_ =>
{
Status = OrderStatus.Submitted;
SubmittedAt = DateTime.UtcNow;
// Raise domain event
DomainEvents.Add(new OrderSubmittedEvent(Id, CustomerId, Total, SubmittedAt.Value));
});
}
// Internal entity - never exposed outside the aggregate
internal class OrderLine : Entity<Guid>
{
public ProductId ProductId { get; }
public int Quantity { get; }
public Money UnitPrice { get; }
internal OrderLine(ProductId productId, int quantity, Money unitPrice)
: base(Guid.NewGuid())
{
ProductId = productId;
Quantity = quantity;
UnitPrice = unitPrice;
}
}
Repository pattern with aggregate persistence and event publishing:
public class OrderRepository
{
private readonly IDbContext _dbContext;
private readonly IEventBus _eventBus;
public async Task<Result> SaveAsync(Order order, CancellationToken ct)
{
// 1. Save aggregate to database
_dbContext.Orders.Update(order);
await _dbContext.SaveChangesAsync(ct);
// 2. Publish uncommitted events
var events = order.UncommittedEvents();
foreach (var domainEvent in events)
{
await _eventBus.PublishAsync(domainEvent, ct);
}
// 3. Mark changes as committed
order.AcceptChanges();
return Result.Success();
}
}
// Usage in an application service
public async Task<Result> SubmitOrderAsync(OrderId orderId, CancellationToken ct)
{
var order = await _orderRepository.GetAsync(orderId, ct);
return await order
.Bind(o => o.Submit())
.BindAsync(o => _orderRepository.SaveAsync(o, ct));
}
Remarks
Aggregates are the fundamental building blocks for maintaining consistency in DDD. Key characteristics:
- Consistency boundary: All business rules and invariants within the aggregate are enforced
- Single entry point: External objects can only modify the aggregate through the aggregate root
- Transaction boundary: Changes to an aggregate are typically saved atomically
- Event source: Aggregates raise domain events to communicate state changes
- Lifecycle: The root controls the lifecycle of all entities within the aggregate
Design principles for aggregates:
- Keep them small: Only include entities that must change together
- Reference by ID: Use IDs to reference other aggregates, not object references
- Enforce invariants: All business rules must be maintained after each operation
- Eventual consistency: Use domain events for cross-aggregate consistency
- Protect internals: Internal entities should not be exposed outside the aggregate
This base class combines:
- Entity<TId>: Provides identity-based equality
- IAggregate: Provides domain event tracking
- IChangeTracking: Provides change tracking support
Constructors
Aggregate(TId)
Initializes a new instance of the Aggregate<TId> class with the specified identifier.
protected Aggregate(TId id)
Parameters
idTIdThe unique identifier for this aggregate root. Must not be null or default.
Remarks
This constructor should be called by derived classes to set the aggregate's identity. Aggregates typically have a private constructor and a static factory method (e.g., Create) that performs validation and returns a Result.
Properties
DomainEvents
Gets the list of domain events that have been raised but not yet committed.
protected List<IDomainEvent> DomainEvents { get; }
Property Value
- List<IDomainEvent>
A mutable list of domain events. Add events to this list when state changes occur.
Remarks
This property is protected to allow derived classes to add events using:
DomainEvents.Add(new SomethingHappenedEvent(...));
Events should be added within methods that change state, typically inside Tap or Map operations to ensure they're only added when the operation succeeds.
IsChanged
Gets a value indicating whether the aggregate has uncommitted changes.
[JsonIgnore]
public virtual bool IsChanged { get; }
Property Value
- bool
trueif there are uncommitted domain events; otherwise,false.
Remarks
This property implements IsChanged. The default implementation returns true if there are any uncommitted domain events.
Override this property if your aggregate needs custom change tracking logic (e.g., tracking property changes independently of domain events).
The [JsonIgnore] attribute prevents this property from being serialized,
as it represents transient state that shouldn't be persisted.
Methods
AcceptChanges()
Marks all changes as committed and clears the list of uncommitted domain events.
public void AcceptChanges()
Examples
// In a repository or unit of work
public async Task<Result> SaveAsync(Order order, CancellationToken ct)
{
using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
try
{
// 1. Save aggregate
_dbContext.Orders.Update(order);
await _dbContext.SaveChangesAsync(ct);
// 2. Publish events
foreach (var evt in order.UncommittedEvents())
{
await _eventBus.PublishAsync(evt, ct);
}
// 3. Only after successful publish
order.AcceptChanges();
await transaction.CommitAsync(ct);
return Result.Success();
}
catch (Exception ex)
{
await transaction.RollbackAsync(ct);
return Error.Unexpected(ex.Message);
}
}
Remarks
This method implements AcceptChanges(). It should be called after successfully persisting the aggregate and publishing its domain events.
The method:
- Clears the DomainEvents list
- Causes IsChanged to return
false - Prepares the aggregate for the next set of changes
Important: Only call this method after events have been successfully published. If publishing fails, events should remain uncommitted so they can be retried.
UncommittedEvents()
Gets all domain events that have been raised since the last call to AcceptChanges().
public IReadOnlyList<IDomainEvent> UncommittedEvents()
Returns
- IReadOnlyList<IDomainEvent>
A read-only list of uncommitted domain events. Returns an empty list if no events have been raised.
Remarks
This method is typically called by the repository or unit of work pattern after successfully persisting the aggregate to retrieve events for publishing.
Typical workflow:
// 1. Execute domain operation
var result = order.Submit();
// 2. Save to database
await repository.SaveAsync(order);
// 3. Publish events
foreach (var evt in order.UncommittedEvents())
{
await eventBus.PublishAsync(evt);
}
// 4. Clear events
order.AcceptChanges();