Skip to content

Aggregates

An aggregate is a consistency boundary — a cluster of domain objects that must stay consistent together. In event sourcing, the aggregate is the thing that decides whether a command is allowed, and if so, what events to emit.

What makes a good aggregate

Vaughn Vernon's four rules for aggregate design are worth memorizing:

  1. Protect true invariants in consistency boundaries. An invariant that must be immediately consistent belongs inside one aggregate. If it can be eventually consistent, it belongs in a separate aggregate connected through events.

  2. Design small aggregates. Start with a single entity. Add children only when consistency demands it. Smaller aggregates mean less contention, better performance, and simpler code.

  3. Reference other aggregates by identity. Don't hold direct references to other aggregates. Use IDs. This keeps aggregates independent and prevents accidental coupling.

  4. Use eventual consistency outside the boundary. Not everything needs to be immediately consistent. When an order is placed, the inventory doesn't need to update in the same transaction — it needs to update eventually, which events handle naturally.

Think boundaries, not entities

The aggregate is not a database table or an ORM entity. It's an answer to the question: what must be consistent in a single transaction?

A BookAggregate doesn't model "a book." It models the consistency boundary around borrowing — ensuring a book can't be borrowed by two people simultaneously.

Anatomy of a Nagare aggregate

Every aggregate has three types and two registration methods:

csharp
//                                      ┌── Commands this aggregate handles
//                                      │              ┌── Events this aggregate emits
//                                      │              │            ┌── State this aggregate maintains
public class BookAggregate : Aggregate<BookCommand, BookEvent, BookState>
{
    private readonly EventHandlers<BookEvent, BookState> _events = ...;
    private readonly CommandHandlers<BookCommand, BookEvent, BookState> _commands = ...;

    // Return pre-built event handlers
    protected override EventHandlers<BookEvent, BookState> RegisterEventHandlers() => _events;

    // Return command handlers — can vary by state
    protected override CommandHandlers<BookCommand, BookEvent, BookState>
        RegisterCommandHandlers() => _commands;
}

This maps directly to the Decider pattern:

Decider functionNagare implementation
decide(state, command) → eventsRegisterCommandHandlers
evolve(state, event) → stateRegisterEventHandlers
initialState() → stateBookState.Default

The Then DSL

Command handlers return decisions through the Then DSL. Every path through a command handler must end in one of these:

Persist events

The command is accepted and produces events:

csharp
.On<AddBook>((state, cmd) =>
    state.Exists
        ? Then.Reject("Book already exists")
        : Then.Persist(new BookAdded(cmd.Title, cmd.Author, cmd.Isbn)))

Persist multiple events

When a single command produces multiple events:

csharp
.On<CheckoutCart>((state, cmd) =>
    Then.PersistAll([
        new CartCheckedOut(cmd.PaymentMethod),
        new PaymentInitiated(state.Total, cmd.PaymentMethod)
    ]))

Reject a command

The command violates a business rule:

csharp
.On<ShipOrder>((state, cmd) =>
    !state.IsPlaced    ? Then.Reject("Order not placed")
    : state.IsCancelled ? Then.Reject("Order is cancelled")
    : state.IsShipped   ? Then.Reject("Already shipped")
    : Then.Persist(new OrderShipped(cmd.TrackingNumber)))

The conditional chain reads naturally: check each invariant in order, reject with a clear reason, or proceed. This pattern scales well — each business rule is a single line.

Typed rejections

When consumers need to distinguish different failure types programmatically:

csharp
.On<PlaceOrder>((state, cmd) =>
    state.Stock < cmd.Quantity
        ? Then.Reject(new InsufficientStockException(cmd.Sku, cmd.Quantity, state.Stock))
        : Then.Persist(new OrderPlaced(cmd.Sku, cmd.Quantity, state.Stock)))

Accept without persisting (idempotency)

When a command would be a no-op because the state already reflects it:

csharp
.On<ReportLost>((state, _) =>
    !state.Exists  ? Then.Reject("Book does not exist")
    : state.IsLost ? Then.Accept()   // already lost — idempotent
    : Then.Persist(new BookLost(DateTimeOffset.UtcNow)))

This is critical for at-least-once delivery. If the same command arrives twice (network retry, duplicate message), the aggregate accepts it gracefully without duplicating events.

Ignore or reject by default

For commands that should be silently dropped or always refused:

csharp
.Ignore<LegacyCommand>()
.Reject<DeprecatedCommand>("This command is no longer supported")

Event handlers

Event handlers are pure functions: (state, event) → newState. They contain no business logic, no side effects, no I/O. They simply record what the event means for the state.

csharp
protected override EventHandlers<BookEvent, BookState> RegisterEventHandlers() =>
    Events
        .On<BookAdded>((state, e) =>
            state with { Exists = true, Title = e.Title })
        .On<BookBorrowed>((state, e) =>
            state with { IsBorrowed = true, BorrowerId = e.BorrowerId })
        .On<BookReturned>((state, _) =>
            state with { IsBorrowed = false, BorrowerId = null })
        .On<BookLost>((state, _) =>
            state with { IsLost = true, IsBorrowed = false, BorrowerId = null })
        .Build();

The with expression creates a new state record with the specified fields changed — immutable, predictable, easy to test.

Every event type must have a handler

Nagare validates at startup that every [JsonDerivedType] in your event hierarchy has a corresponding handler. Missing handlers throw MissingEventHandlerException — you'll know at startup, not at runtime.

Use .Ignore<T>() for events that don't affect state.

State-based command handlers

For complex aggregates with many states, RegisterCommandHandlers can return different handler sets based on current state. This is the Behavior pattern — the state selects which commands are valid:

csharp
private readonly CommandHandlers<...> _scheduled =
    Commands
        .On<Cancel>((state, cmd) => Then.Persist(new Cancelled(cmd.ReasonId)))
        .On<Reschedule>((state, cmd) => Then.Persist(new Rescheduled(cmd.Start, cmd.End)))
        .Build();

private readonly CommandHandlers<...> _cancelled =
    Commands
        .On<Cancel>((state, cmd) => Then.Accept()) // idempotent
        .On<Reinstate>((state, cmd) => Then.Persist(new Reinstated()))
        .Build();

protected override CommandHandlers<...> RegisterCommandHandlers() =>
    State.Status switch
    {
        "Scheduled" => _scheduled,
        "Cancelled" => _cancelled,
        _           => throw new InvalidOperationException(...)
    };

Each state is a self-contained handler set. The same command type can have different behavior per state — no dictionary collision. Open a state's handler set and you see everything that state can do.

Splitting large aggregates across files

When an aggregate grows to 20+ handlers, a single file becomes hard to navigate. Use partial classes to split by state, giving each state its own file:

AppointmentAggregate.cs              ← registration + event handlers
AppointmentAggregate.Scheduled.cs    ← command handlers for "Scheduled" state
AppointmentAggregate.Cancelled.cs    ← command handlers for "Cancelled" state
AppointmentAggregate.Unscheduled.cs  ← command handlers for "Unscheduled" state
csharp
// AppointmentAggregate.Scheduled.cs
public partial class AppointmentAggregate
{
    private readonly CommandHandlers<AppointmentCommand, AppointmentEvent, AppointmentState> _scheduled =
        Commands
            .On<Cancel>((state, cmd) => Then.Persist(new Cancelled(cmd.ReasonId, cmd.ReceivedAt)))
            .On<Reschedule>((state, cmd) => Then.Persist(new Rescheduled(cmd.Start, cmd.End)))
            .On<Unschedule>((state, cmd) => Then.Persist(new Unscheduled()))
            .Build();
}
csharp
// AppointmentAggregate.cs
public partial class AppointmentAggregate
    : Aggregate<AppointmentCommand, AppointmentEvent, AppointmentState>
{
    private readonly EventHandlers<...> _events = ...;

    protected override EventHandlers<...> RegisterEventHandlers() => _events;

    protected override CommandHandlers<...> RegisterCommandHandlers() =>
        State.Status switch
        {
            "Scheduled"   => _scheduled,
            "Cancelled"   => _cancelled,
            "Unscheduled" => _unscheduled,
            _             => throw new InvalidOperationException(...)
        };
}

Each file reads as a complete description of what a state can do. The main file is just wiring.

State design

State should be minimal — only what command handlers need to make decisions.

csharp
public record BookState(
    bool Exists,
    string? Title,
    string? BorrowerId,
    bool IsBorrowed,
    bool IsLost) : IAggregateState<BookState>
{
    public static BookState Default => new(false, null, null, false, false);
}

Include fields that command handlers check (IsBorrowed, IsLost, Exists).

Exclude fields that are only needed for read models (Author, Isbn, BorrowedAt). Those belong in projections.

The test: if you remove a field and no command handler breaks, the field shouldn't be in state.

Reply handling

Command execution returns IReply, which you can inspect with pattern matching:

csharp
var reply = await aggregate.Ask(new PlaceOrder("SKU-1", 2, 29.99m));

var message = reply.Match(
    onAccepted: () => "Order placed!",
    onRejected: ex => $"Failed: {ex.Message}",
    onIgnored: () => "No action taken");

// Convenience methods
reply.ThrowIfRejected();
Exception? error = reply.RejectionOrDefault();

Why aggregates don't expose state

It's tempting to add a GetBook command that returns the aggregate's state directly. Nagare doesn't support this, and that's deliberate.

Aggregates exist to enforce business rules and persist events. They are the write side. Reading is the job of projections and read models.

If you expose aggregate state through a query command, you're coupling your read path to the write model. The aggregate's state is minimal on purpose: it holds only what command handlers need to make decisions. It doesn't contain the fields your UI needs (full author bio, cover image URL, borrow history). Projections do.

You also lose the ability to scale reads and writes independently. An aggregate loads its event stream, replays events, and rebuilds state on every access. A read model is a pre-built document sitting in a table, ready to serve.

When you need immediate consistency after a write (the user just borrowed a book and wants confirmation), read from the aggregate's reply or load the aggregate directly:

csharp
var aggregate = await repo.Load(new AggregateId(bookId));
var state = aggregate.State;  // immediate, consistent

For everything else, use a read model. That's the CQRS split: commands go through aggregates, queries go through projections.

Snapshots

For aggregates with long event streams, replaying thousands of events on every load becomes expensive. Snapshots short-circuit this by periodically saving the current state:

csharp
protected override ISnapshotPolicy<BookState, BookEvent> SnapshotPolicy =>
    SnapshotPolicy.EveryNVersions<BookState, BookEvent>(50);

With this policy, Nagare saves a snapshot every 50 events. On load, it restores the latest snapshot and replays only the events after it.

Snapshots are an optimization, not a requirement. They don't change semantics — the events remain the source of truth. If a snapshot is corrupted or deleted, the aggregate rebuilds from events.

Metadata

Attach tracing context to commands for observability:

csharp
var metadata = new EventMetadata(
    CorrelationId: requestId,
    CausationId: $"http:{Request.Path}",
    UserId: currentUser.Id);

await aggregate.Ask(new PlaceOrder(...), metadata);

Metadata is persisted alongside events and available in projections via EventEnvelope<TEvent>.Metadata. This gives you full causation chains — from the HTTP request that triggered the command, through the events it produced, to the projections that consumed them.

Design guidelines

  1. One aggregate per consistency boundary. If two things must be consistent in the same transaction, they belong in the same aggregate. If they can be eventually consistent, separate them.

  2. Keep command handlers readable. Each handler should read like a business rule: "if this, reject because that; otherwise, persist this event." If a handler is hard to read, the aggregate is too complex.

  3. Keep state minimal. State exists to serve command handlers. If a field isn't checked in any command handler, remove it.

  4. Make idempotency explicit. Use Then.Accept() for commands that are already satisfied. This prevents duplicate events in at-least-once delivery scenarios.

  5. Fail fast. Check invariants at the top of the handler and reject early. The happy path should be the last line.


Next: Commands & Events — naming conventions that last forever.

流れ — flow.