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.

The aggregate CommandService<TAggregate, TState, TId> is the central application-layer building block in Eventuous. It receives an incoming command, loads the target aggregate from the event store, delegates execution to your domain logic, then persists any new events — all with optimistic-concurrency protection baked in. You never write the load-execute-persist loop yourself; you only describe how each command type should be handled.

Class signature

public abstract class CommandService<TAggregate, TState, TId>
    where TAggregate : Aggregate<TState>
    where TState     : State<TState>, new()
    where TId        : Id
The three type parameters tie together your DDD model:
ParameterMeaning
TAggregateYour aggregate root class (e.g. Booking)
TStateThe immutable state record the aggregate carries
TIdThe strongly-typed identity (e.g. BookingId)

Constructors

The primary constructor accepts separate reader and writer abstractions, giving you full control over where events are read from versus written to:
protected CommandService(
    IEventReader?             reader,
    IEventWriter?             writer,
    AggregateFactoryRegistry? factoryRegistry = null,
    StreamNameMap?            streamNameMap   = null,
    ITypeMapper?              typeMap         = null,
    AmendEvent?               amendEvent      = null
)
A convenience overload accepts a single IEventStore, which implements both IEventReader and IEventWriter:
protected CommandService(
    IEventStore?              store,
    AggregateFactoryRegistry? factoryRegistry = null,
    StreamNameMap?            streamNameMap   = null,
    ITypeMapper?              typeMap         = null,
    AmendEvent?               amendEvent      = null
)
ParameterPurpose
readerSource for loading aggregate history; defaults to null (must be set per-handler)
writerDestination for persisting new events; defaults to null
factoryRegistryOverrides how aggregate instances are constructed; falls back to the global registry
streamNameMapControls stream naming conventions; falls back to new StreamNameMap()
typeMapMaps CLR event types to event-type names; falls back to TypeMap.Instance
amendEventGlobal hook to enrich every NewStreamEvent before it is stored

Deriving your service

Create a concrete class that inherits CommandService<TAggregate, TState, TId> and registers all command handlers inside the constructor using the On<TCommand>() fluent API:
public class BookingsCommandService
    : CommandService<Booking, BookingState, BookingId>
{
    public BookingsCommandService(IEventStore store, Services.IsRoomAvailable isRoomAvailable)
        : base(store)
    {
        // handlers registered here
    }
}

Registering command handlers

Call On<TCommand>() for every command type your service handles. It returns a fluent builder that must be completed in order.

Step 1 — InState(ExpectedState)

Declares the expected state of the aggregate stream before the command is applied:
On<BookRoom>().InState(ExpectedState.New)
ValueMeaning
ExpectedState.NewStream must not exist yet; a fresh aggregate is created
ExpectedState.ExistingStream must already exist; throws if not found
ExpectedState.AnyStream may or may not exist; the aggregate is loaded if present, created if not
ExpectedState.UnknownNo stream state check; the aggregate is passed as null to the handler

Step 2 — GetId / GetIdAsync

Extracts the aggregate identity from the incoming command:
.GetId(cmd => new BookingId(cmd.BookingId))

// async variant
.GetIdAsync(async (cmd, ct) => await ResolveIdAsync(cmd, ct))

Step 3 — Act / ActAsync

Calls your domain method on the loaded aggregate:
// synchronous
.Act((booking, cmd) =>
    booking.RecordPayment(new Money(cmd.PaidAmount, cmd.Currency), cmd.PaymentId, cmd.PaidBy, DateTimeOffset.Now))

// asynchronous
.ActAsync(async (booking, cmd, ct) =>
    await booking.BookRoom(cmd.GuestId, new(cmd.RoomId), ...))

Optional builder steps

After GetId, you may optionally override storage resolution or enrich events on a per-handler basis:
MethodPurpose
.ResolveStore(Func<TCommand, IEventStore>)Per-command event store (sets both reader and writer)
.ResolveReader(Func<TCommand, IEventReader>)Per-command event reader override
.ResolveWriter(Func<TCommand, IEventWriter>)Per-command event writer override
.AmendEvent(AmendEvent<TCommand> amendEvent)Enriches each produced NewStreamEvent before storage
.AmendAppend(AmendAppend<TCommand> amendAppend)Modifies the entire ProposedAppend batch before storage

Full example — BookingsCommandService

The following service is taken directly from the Eventuous sample application:
using Bookings.Domain;
using Bookings.Domain.Bookings;
using Eventuous;
using NodaTime;
using static Bookings.Application.BookingCommands;

namespace Bookings.Application;

public class BookingsCommandService
    : CommandService<Booking, BookingState, BookingId>
{
    public BookingsCommandService(
        IEventStore store,
        Services.IsRoomAvailable isRoomAvailable) : base(store)
    {
        On<BookRoom>()
            .InState(ExpectedState.New)
            .GetId(cmd => new BookingId(cmd.BookingId))
            .ActAsync(
                (booking, cmd, _) => booking.BookRoom(
                    cmd.GuestId,
                    new(cmd.RoomId),
                    new StayPeriod(
                        LocalDate.FromDateTime(cmd.CheckInDate),
                        LocalDate.FromDateTime(cmd.CheckOutDate)),
                    new Money(cmd.BookingPrice, cmd.Currency),
                    new Money(cmd.PrepaidAmount, cmd.Currency),
                    DateTimeOffset.Now,
                    isRoomAvailable
                ));

        On<RecordPayment>()
            .InState(ExpectedState.Existing)
            .GetId(cmd => new BookingId(cmd.BookingId))
            .Act((booking, cmd) =>
                booking.RecordPayment(
                    new Money(cmd.PaidAmount, cmd.Currency),
                    cmd.PaymentId,
                    cmd.PaidBy,
                    DateTimeOffset.Now));
    }
}
The commands themselves are plain records:
public static class BookingCommands
{
    public record BookRoom(
        string         BookingId,
        string         GuestId,
        string         RoomId,
        DateTime       CheckInDate,
        DateTime       CheckOutDate,
        float          BookingPrice,
        float          PrepaidAmount,
        string         Currency,
        DateTimeOffset BookingDate
    );

    public record RecordPayment(
        string BookingId,
        float  PaidAmount,
        string Currency,
        string PaymentId,
        string PaidBy
    );
}

Handling commands

Call Handle from your API layer (controller, minimal-API endpoint, gRPC handler, etc.):
Task<Result<TState>> Handle<TCommand>(TCommand command, CancellationToken cancellationToken)
The method returns a Result<TState> discriminated union — either a success carrying the new state and emitted events, or an error carrying the exception.

Interfaces

CommandService<TAggregate, TState, TId> implements two interfaces you can program against:
// Aggregate-specific interface
ICommandService<TAggregate, TState, TId>

// Narrower interface — useful when only the state type matters
ICommandService<TState>
Both expose the same Handle<TCommand> method. Depend on ICommandService<TState> at the API boundary when you don’t need the full aggregate type visible.

Dependency injection

Register the service with the Eventuous DI extension from Eventuous.Extensions.DependencyInjection:
builder.Services.AddCommandService<BookingsCommandService, BookingState>();
This registers BookingsCommandService as a singleton and also registers ICommandService<BookingState> that resolves to the same instance (wrapped in a tracing decorator when diagnostics are enabled).

Build docs developers (and LLMs) love