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:
| Parameter | Meaning |
|---|
TAggregate | Your aggregate root class (e.g. Booking) |
TState | The immutable state record the aggregate carries |
TId | The 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
)
| Parameter | Purpose |
|---|
reader | Source for loading aggregate history; defaults to null (must be set per-handler) |
writer | Destination for persisting new events; defaults to null |
factoryRegistry | Overrides how aggregate instances are constructed; falls back to the global registry |
streamNameMap | Controls stream naming conventions; falls back to new StreamNameMap() |
typeMap | Maps CLR event types to event-type names; falls back to TypeMap.Instance |
amendEvent | Global 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)
| Value | Meaning |
|---|
ExpectedState.New | Stream must not exist yet; a fresh aggregate is created |
ExpectedState.Existing | Stream must already exist; throws if not found |
ExpectedState.Any | Stream may or may not exist; the aggregate is loaded if present, created if not |
ExpectedState.Unknown | No 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:
| Method | Purpose |
|---|
.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).