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.

Eventuous also provides a lightweight, functional variant of the command service that skips the aggregate class entirely. Instead of loading an Aggregate<TState> object and calling methods on it, CommandService<TState> works with raw state and the array of previously-persisted events. Your handler is a pure function that receives (TState currentState, object[] originalEvents, TCommand command) and returns a sequence of new events. This makes the functional style a natural fit for domains with straightforward state-transition logic where the ceremony of an aggregate class would add no real benefit.

Class signature

public abstract class CommandService<TState>
    where TState : State<TState>, new()
There is only one type parameter: the state type that carries your domain data. No aggregate class, no identity type.

Constructor

protected CommandService(
    IEventReader  reader,
    IEventWriter  writer,
    ITypeMapper?  typeMap     = null,
    AmendEvent?   amendEvent  = null
)
A convenience overload accepts a single IEventStore (which implements both interfaces):
protected CommandService(
    IEventStore   store,
    ITypeMapper?  typeMap    = null,
    AmendEvent?   amendEvent = null
)
ParameterPurpose
readerUsed to load existing event streams
writerUsed to persist new events
typeMapMaps CLR event types to type-name strings; falls back to TypeMap.Instance
amendEventGlobal hook to enrich every NewStreamEvent before storage

Deriving your service

Inherit from CommandService<TState> and register handlers in the constructor:
public class PaymentCommandService : CommandService<PaymentState>
{
    public PaymentCommandService(IEventStore store) : base(store)
    {
        // handlers registered here
    }
}

Registering command handlers

The same On<TCommand>() entry point is used, but the fluent builder differs from the aggregate variant in two key places: stream naming (instead of typed identity) and the handler signature.

Step 1 — InState(ExpectedState)

Declares the expected state of the target stream before the command is handled:
On<InitiatePayment>().InState(ExpectedState.New)
ValueMeaning
ExpectedState.NewStream must not yet exist; state and events will be empty
ExpectedState.ExistingStream must already exist; throws if not found
ExpectedState.AnyStream may or may not exist; state is loaded when present, empty otherwise

Step 2 — GetStream / GetStreamAsync

Instead of extracting a typed identity, you provide the raw StreamName directly from the command. This is the functional service’s equivalent of GetId:
.GetStream(cmd => new StreamName($"Payment-{cmd.PaymentId}"))

// or use the built-in helper
.GetStream(cmd => StreamName.ForState<PaymentState>(cmd.PaymentId))

// async variant
.GetStreamAsync(async (cmd, ct) => await ResolveStreamAsync(cmd, ct))

Step 3 — Act / ActAsync

The handler receives the current state, the original raw events already in the stream, and the command. It returns NewEvents — a sequence of new domain event objects to append:
// synchronous
.Act((state, originalEvents, cmd) =>
    new[] { new PaymentInitiated(cmd.PaymentId, cmd.Amount, cmd.Currency) })

// asynchronous
.ActAsync(async (state, originalEvents, cmd, ct) =>
{
    await SomeAsyncCheck(state, ct);
    return new[] { new PaymentInitiated(cmd.PaymentId, cmd.Amount, cmd.Currency) };
})
For ExpectedState.New handlers you can omit the state and event parameters entirely:
On<InitiatePayment>()
    .InState(ExpectedState.New)
    .GetStream(cmd => StreamName.ForState<PaymentState>(cmd.PaymentId))
    .Act(cmd => new[] { new PaymentInitiated(cmd.PaymentId, cmd.Amount, cmd.Currency) })

Optional builder steps

The same per-handler storage and amendment options are available as in the aggregate service:
MethodPurpose
.ResolveStore(Func<TCommand, IEventStore>)Per-command event store (reader + writer)
.ResolveReader(Func<TCommand, IEventReader>)Per-command reader override
.ResolveWriter(Func<TCommand, IEventWriter>)Per-command writer override
.AmendEvent(AmendEvent<TCommand>)Enriches each produced NewStreamEvent before storage
.AmendAppend(AmendAppend<TCommand>)Modifies the ProposedAppend batch before storage

Full example — PaymentCommandService

using Eventuous;

namespace Payments.Application;

// State carries the domain data
public record PaymentState : State<PaymentState>
{
    public string   PaymentId { get; init; } = "";
    public decimal  Amount    { get; init; }
    public string   Currency  { get; init; } = "";
    public bool     Confirmed { get; init; }

    public override PaymentState When(object evt) => evt switch
    {
        PaymentInitiated e => this with
        {
            PaymentId = e.PaymentId,
            Amount    = e.Amount,
            Currency  = e.Currency
        },
        PaymentConfirmed e => this with { Confirmed = true },
        _                  => this
    };
}

// Domain events
public record PaymentInitiated(string PaymentId, decimal Amount, string Currency);
public record PaymentConfirmed(string PaymentId);

// Commands
public record InitiatePayment(string PaymentId, decimal Amount, string Currency);
public record ConfirmPayment(string PaymentId);

// Functional command service — no aggregate class required
public class PaymentCommandService : CommandService<PaymentState>
{
    public PaymentCommandService(IEventStore store) : base(store)
    {
        On<InitiatePayment>()
            .InState(ExpectedState.New)
            .GetStream(cmd => StreamName.ForState<PaymentState>(cmd.PaymentId))
            .Act(cmd => new[] { new PaymentInitiated(cmd.PaymentId, cmd.Amount, cmd.Currency) });

        On<ConfirmPayment>()
            .InState(ExpectedState.Existing)
            .GetStream(cmd => StreamName.ForState<PaymentState>(cmd.PaymentId))
            .Act((state, _, cmd) =>
            {
                if (state.Confirmed)
                    throw new InvalidOperationException("Payment already confirmed");

                return new[] { new PaymentConfirmed(cmd.PaymentId) };
            });
    }
}

Handling commands

The public API is identical to the aggregate service:
Task<Result<TState>> Handle<TCommand>(TCommand command, CancellationToken cancellationToken)
The return value is a Result<TState> — either Ok with the updated state and emitted events, or Error with the captured exception.

Aggregate style vs functional style

Both command service variants are first-class citizens in Eventuous. Choose based on your domain’s needs:
ConcernAggregate CommandService<TAggregate, TState, TId>Functional CommandService<TState>
Domain complexityComplex invariants, rich behaviourSimple state transitions, append-only logic
CeremonyExplicit aggregate class with domain methodsNo aggregate class; pure functions only
Identity typeStrongly-typed Id subclassRaw StreamName derived from the command
Handler signature(TAggregate, TCommand) — OO method call(TState, object[], TCommand) — functional
Testing styleAggregate spec tests (Given/When/Then)Pure function assertions
When to preferMultiple collaborating invariants on one entityLightweight services, CQRS write models
If your domain logic is straightforward and you want less boilerplate, reach for the functional service. When your aggregate needs to enforce multiple interlocking rules or carries significant behaviour, the aggregate style keeps that logic encapsulated where it belongs.

Build docs developers (and LLMs) love