Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/Eventuous/eventuous/llms.txt

Use this file to discover all available pages before exploring further.

Aggregate state in Eventuous is fully immutable and always derived from events. Rather than letting an aggregate mutate its own fields, you define a separate State<T> record that knows how to rebuild itself from the event stream. Every time an event is applied — either during rehydration from the store or as a new change within a command — the current state record is replaced by a new instance returned from the appropriate handler. Nothing is ever mutated in place.

Class signature

public abstract record State<T> where T : State<T>
Because State<T> is an abstract record, you get value equality, non-destructive mutation via with expressions, and a clean inheritance model out of the box. The where T : State<T> (CRTP) constraint allows the base class to return strongly-typed instances from its methods without unsafe casts in your code.

Registering event handlers with On<TEvent>()

protected void On<TEvent>(Func<T, TEvent, T> handle)
Call On<TEvent> in the constructor of your state class to register a handler for a specific event type. The handler receives the current state instance and the event, and must return a new state instance — typically built with a with expression:
On<V1.RoomBooked>((state, evt) => state with {
    RoomId = new(evt.RoomId),
    GuestId = evt.GuestId
});
If you call On<TEvent> twice for the same event type, a DuplicateTypeException<TEvent> is thrown immediately at startup — making misconfiguration a hard failure rather than a silent bug.

Dispatching events with When(object @event)

public virtual T When(object @event)
When is called by the aggregate (via Apply) whenever an event needs to be applied. It looks up the registered handler for @event.GetType() and invokes it. If no handler is registered for the given event type, When returns the current instance unchanged — meaning unknown event types are safely ignored during rehydration of old streams.

Introspection with GetRegisteredEventTypes()

public ICollection<Type> GetRegisteredEventTypes()
Returns all Type objects that have registered handlers in this state class. Eventuous uses this internally for diagnostic tooling such as Eventuous Spyglass, which calls GetRegisteredEventTypes() at startup to enumerate the events each state handles. You can also call it in tests to assert that a state class covers all expected event types.

The State<T, TId> variant

public abstract record State<T, TId> : State<T>
    where T  : State<T>
    where TId : Id
{
    public TId Id { get; internal set; } = null!;
}
When you derive from State<T, TId>, the state record automatically gains a typed Id property. The aggregate infrastructure sets Id when the aggregate is loaded from a named event stream, so you never need to carry the identity through the events themselves. Use this variant when you need the aggregate identity to be available inside state queries or read-model projections that receive a state snapshot.

Full example — BookingState

The BookingState record below handles every event that the Booking aggregate can produce. Each On<> handler uses a with expression to return a new record with only the changed properties updated.
using System.Collections.Immutable;
using Eventuous;
using static Bookings.Domain.Bookings.BookingEvents;

namespace Bookings.Domain.Bookings;

public record BookingState : State<BookingState> {
    public string     GuestId     { get; init; } = null!;
    public RoomId     RoomId      { get; init; } = null!;
    public StayPeriod Period      { get; init; } = null!;
    public Money      Price       { get; init; } = null!;
    public Money      Outstanding { get; init; } = null!;
    public bool       Paid        { get; init; }

    public ImmutableArray<PaymentRecord> Payments { get; init; }
        = ImmutableArray<PaymentRecord>.Empty;

    internal bool HasPaymentBeenRegistered(string paymentId)
        => Payments.Any(x => x.PaymentId == paymentId);

    public BookingState() {
        On<V1.RoomBooked>(HandleBooked);
        On<V1.PaymentRecorded>(HandlePayment);
        On<V1.BookingFullyPaid>((state, _) => state with { Paid = true });
    }

    static BookingState HandlePayment(BookingState state, V1.PaymentRecorded e)
        => state with {
            Outstanding = new() { Amount = e.Outstanding, Currency = e.Currency },
            Payments    = state.Payments.Add(new(e.PaymentId, new(e.PaidAmount, e.Currency)))
        };

    static BookingState HandleBooked(BookingState state, V1.RoomBooked booked)
        => state with {
            RoomId      = new(booked.RoomId),
            Period      = new(booked.CheckInDate, booked.CheckOutDate),
            GuestId     = booked.GuestId,
            Price       = new() { Amount = booked.BookingPrice,      Currency = booked.Currency },
            Outstanding = new() { Amount = booked.OutstandingAmount, Currency = booked.Currency }
        };
}

Key design rules

  • Always return a new instance. Because State<T> is a record, the with expression is the idiomatic way to produce an updated copy. Never mutate any property inside a handler.
  • Handlers are registered in the constructor. This is the only place to call On<TEvent>. The handler dictionary is built once and never modified.
  • Unknown events are ignored. If an event type has no handler, When returns this unchanged. This lets old aggregates rehydrate gracefully from streams that contain events introduced by a newer version of the application.
  • Keep state handlers pure. Handlers receive the current state and the event; they should not call services, query databases, or produce side effects. All business decisions live in the aggregate class; state records only transform data.
Handlers don’t have to be lambdas. The BookingState example above delegates to static methods (HandleBooked, HandlePayment). Static methods are easier to test in isolation and avoid accidental capture of outer variables.

Build docs developers (and LLMs) love