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.

An aggregate is the consistency boundary in your domain model. In Eventuous, every aggregate inherits from Aggregate<T> where T is a State<T> record that carries all mutable data. The aggregate class itself holds only the event-application logic and business invariants — it never stores raw field values. When a domain method runs, it records what happened by emitting an event; the event is both appended to the list of pending changes and immediately applied to produce a new State snapshot.

Class signature

public abstract class Aggregate<T> where T : State<T>, new()
The single type parameter T must be a concrete record that derives from State<T>. Because T has a new() constraint, Eventuous can construct the initial empty state without any factory or DI involvement.

Key properties

PropertyTypeDescription
StateTThe current aggregate state (read-only from outside)
Originalobject[]Events loaded from the store; empty for a brand-new aggregate
ChangesIReadOnlyCollection<object>Pending new events produced in the current operation
CurrentIEnumerable<object>Original concatenated with Changes — the full event sequence
OriginalVersionlongVersion at load time; -1 if the aggregate has never been persisted
CurrentVersionlongOriginalVersion + Changes.Count — increases with every Apply call
OriginalVersion is what Eventuous passes to the event store for optimistic concurrency: if another writer has appended events since this aggregate was loaded, the store will reject the append with a concurrency conflict.

Protected methods

Apply<TEvent>(TEvent evt)

protected (T PreviousState, T CurrentState) Apply<TEvent>(TEvent evt)
    where TEvent : class
Call Apply inside every domain method to record that something happened. It does three things atomically:
  1. Calls AddChange(evt) to queue the event in Changes.
  2. Captures the current State as PreviousState.
  3. Calls State.When(evt) to produce the next immutable state snapshot.
The returned tuple lets you react to the state transition inline (for example, to conditionally emit a follow-up event), without storing intermediate state in a local variable.

AddChange(object evt)

protected void AddChange(object evt)
Appends a raw event object to Changes without transitioning state. Prefer Apply<TEvent> for domain methods; AddChange is a lower-level escape hatch for cases where you have already transitioned state yourself.

Load(long version, IEnumerable<object?> events)

public void Load(long version, IEnumerable<object?> events)
Rehydrates the aggregate from a persisted event stream. Eventuous calls this internally when loading an aggregate from the event store — you rarely call it yourself. It:
  1. Filters out any null events (unknown or undeserializable event types).
  2. Sets OriginalVersion to version.
  3. Folds all events through State.When() to build the current state.
  4. Stores the raw events in Original.
After Load, Changes is empty and CurrentVersion == OriginalVersion.

EnsureDoesntExist() and EnsureExists()

protected void EnsureDoesntExist(Func<Exception>? getException = null)
protected void EnsureExists(Func<Exception>? getException = null)
Guard methods for invariant enforcement. Call EnsureDoesntExist() at the start of a creation method — it throws DomainException (or your custom exception) if CurrentVersion >= 0, which means the aggregate has already been persisted. Call EnsureExists() before any update method — it throws if CurrentVersion < 0. Both accept an optional getException factory so you can supply a domain-specific exception instead of the generic DomainException:
EnsureDoesntExist(() => new BookingAlreadyExistsException(bookingId));

ClearChanges()

public void ClearChanges()
Empties the Changes list without affecting State or Original. This is useful in unit tests where you want to inspect the initial post-BookRoom state, then clear pending changes before exercising a second command.

Full example — Booking aggregate

The following aggregate is taken directly from the Eventuous sample application. Every public method corresponds to a single business operation; each operation validates an invariant, then records the outcome as a domain event via Apply.
using Eventuous;
using static Bookings.Domain.Bookings.BookingEvents;
using static Bookings.Domain.Services;

namespace Bookings.Domain.Bookings;

public class Booking : Aggregate<BookingState> {
    public async Task BookRoom(
            string          guestId,
            RoomId          roomId,
            StayPeriod      period,
            Money           price,
            Money           prepaid,
            DateTimeOffset  bookedAt,
            IsRoomAvailable isRoomAvailable
        ) {
        EnsureDoesntExist();
        await EnsureRoomAvailable(roomId, period, isRoomAvailable);

        var outstanding = price - prepaid;

        Apply(new V1.RoomBooked(
            guestId, roomId, period.CheckIn, period.CheckOut,
            price.Amount, prepaid.Amount, outstanding.Amount,
            price.Currency, bookedAt));

        MarkFullyPaidIfNecessary(bookedAt);
    }

    public void RecordPayment(
            Money          paid,
            string         paymentId,
            string         paidBy,
            DateTimeOffset paidAt) {
        EnsureExists();

        if (State.HasPaymentBeenRegistered(paymentId)) return;

        var outstanding = State.Outstanding - paid;

        Apply(new V1.PaymentRecorded(
            paid.Amount, outstanding.Amount, paid.Currency,
            paymentId, paidBy, paidAt));

        MarkFullyPaidIfNecessary(paidAt);
        MarkOverpaid(paidAt);
    }

    void MarkFullyPaidIfNecessary(DateTimeOffset when) {
        if (State.Outstanding.Amount <= 0)
            Apply(new V1.BookingFullyPaid(when));
    }

    void MarkOverpaid(DateTimeOffset when) {
        if (State.Outstanding.Amount < 0)
            Apply(new V1.BookingOverpaid(when));
    }

    static async Task EnsureRoomAvailable(
            RoomId          roomId,
            StayPeriod      period,
            IsRoomAvailable isRoomAvailable) {
        var roomAvailable = await isRoomAvailable(roomId, period);
        if (!roomAvailable) throw new DomainException("Room not available");
    }
}

The Apply pattern

The design principle at work here is:
  1. Every public method encodes a decision. BookRoom checks availability and computes the outstanding balance; it does not store those values in fields.
  2. Apply records the outcome. The event carries all the facts that resulted from the decision. The event is both the persistence artifact and the state-transition trigger.
  3. State is read back through State. After Apply(new V1.RoomBooked(...)) returns, State.Outstanding already reflects the new value because BookingState has an On<V1.RoomBooked> handler. The next line MarkFullyPaidIfNecessary can immediately interrogate State.Outstanding.Amount to decide whether to emit a BookingFullyPaid event.
  4. Aggregates are never mutated directly. There are no settable properties on Booking itself. All writable data lives in the BookingState record, which is replaced (not mutated) on every Apply.
This separation keeps your aggregate classes small and focused on decisions, while your State<T> records handle data.
If you need to read the previous state alongside the new one — for example, to detect a transition from “not paid” to “fully paid” — destructure the tuple returned by Apply:
var (prev, curr) = Apply(new V1.PaymentRecorded(...));
if (!prev.Paid && curr.Paid) { /* first time fully paid */ }

Build docs developers (and LLMs) love