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.

State is protected on Aggregate<TCommand, TEvent, TState> for exactly this reason — production code physically cannot read it. The only legitimate consumer is the test harness, which uses reflection to inspect state for assertions:

csharp
var harness = AggregateTestHarness<BookAggregate, ...>.For()
    .Given(new BookAdded("Dune", "Herbert"))
    .When(new BorrowBook("alice"));

harness.State.Should().Be(...)   // test-only: harness reads protected State via reflection

If you find yourself wanting State outside a test, it's a sign that something else is needed. 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.

When you need immediate consistency after a write (the user just borrowed a book and wants a confirmation page), return what they need from the command response itself, sourced from the command input plus the reply:

csharp
app.MapPost("/books/{id}/borrow", async (string id, BorrowRequest req,
    IAggregateRepository<BookAggregate, ...> repo) =>
{
    var aggregate = await repo.Load(new AggregateId(id));
    var reply = await aggregate.Ask(new BorrowBook(req.BorrowerId));

    return reply.Match(
        onAccepted: () => Results.Ok(new { Status = "borrowed", BookId = id, BorrowerId = req.BorrowerId }),
        onRejected: ex => Results.Conflict(new { Error = ex.Message }),
        onIgnored: ()   => Results.Ok(new { Status = "already borrowed" }));
});

For everything else, query the read model. The CQRS split: commands go through aggregates, queries go through projections. See the recipes guide for more read-after-write patterns (poll-with-version, optimistic UI, push notifications).

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 deleted, the aggregate rebuilds from events.

Keeping snapshots valid as state evolves ​

A snapshot is the output of your event handlers at the time it was written. Nagare restores it without re-checking it, so a snapshot that the current code would not have produced loads silently as wrong state. It doesn't fail — the aggregate just decides on the wrong facts. Every change to a state type therefore has to answer: would an old snapshot still load as exactly what a replay produces today?

Nagare has event upcasters but no state upcaster. State migrates in one of three ways:

ChangeWhat to do
Add a member whose correct value for existing aggregates is its default (a new flag that starts false, a new list that starts empty)Make it optional or give it a default. Old snapshots load with the default, which is what a replay would compute.
Remove a memberNothing. The serializer ignores the extra JSON property.
Change an event handler (the fold), add a member derived from past events, rename a member, or change a member's typeBump [SnapshotVersion].
Reshape the JSON while keeping the meaningEither bump [SnapshotVersion], or put a [JsonConverter] on the state type that reads both shapes.

A default only counts if the serializer sees it. A positional record parameter of a reference type can't default to [] in C#, and one declared = null loads as null from an old snapshot, while a replay starts from Default and yields an empty list. Declare such a member as an init property with an initializer, outside the positional parameters:

csharp
public record BookState(bool Exists, string? Title /* … */) : IAggregateState<BookState>
{
    public ImmutableList<string> Reservations { get; init; } = [];
    public static BookState Default => new(false, null /* … */);
}

The derived-member row is the trap. Adding int TimesBorrowed = 0 looks optional, but for an existing book a replay counts the past BookBorrowed events and an old snapshot says 0. Optional only works when the default is the replayed value.

Bumping the version is the simple, always-correct answer because snapshots are disposable:

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

Snapshots are stored under the state's name plus its version (BookState@v2). After the bump, earlier snapshots are invisible: each aggregate replays its full stream on its next load and snapshots again under the new version. Two things follow:

  • The first load after deploying costs a full replay per aggregate. For very long streams, plan the deploy for a quiet period.
  • Rollback is safe. The old code still reads its own version's snapshots, which the new code never overwrites; events written since are replayed on top.
  • A rolled-back version number is burned. Its snapshots stay in the table. If v2 was ever deployed against a database, even briefly and even if the commit was reverted, the next fold change goes to v3. Reusing v2 would restore snapshots written by the abandoned fold.

A state without the attribute keeps its plain name as the key, so adding [SnapshotVersion(1)] to an existing state also discards its snapshots once. Versions start at 1 and only go up. Pick one above every number that has ever reached the database, not just above the one on your branch.

A [JsonConverter] migration keeps existing snapshots and avoids the replay, at the cost of code you must test against real old payloads. Prefer the version bump unless replay cost rules it out.

Snapshots are keyed by the state type's Name, not its namespace. Two aggregates whose states share a name must not share a snapshot table.

Metadata ​

Attach tracing context to commands for observability:

csharp
var metadata = new EventMetadata(
    CorrelationId: requestId,
    CausationId: $"http:{Request.Path}",
    ActorId: 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.

流れ — flow.