Table of Contents

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

TId

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

Constructors

Aggregate(TId)

Initializes a new instance of the Aggregate<TId> class with the specified identifier.

protected Aggregate(TId id)

Parameters

id TId

The 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

true if 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();