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
)
| Parameter | Purpose |
|---|
reader | Used to load existing event streams |
writer | Used to persist new events |
typeMap | Maps CLR event types to type-name strings; falls back to TypeMap.Instance |
amendEvent | Global 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)
| Value | Meaning |
|---|
ExpectedState.New | Stream must not yet exist; state and events will be empty |
ExpectedState.Existing | Stream must already exist; throws if not found |
ExpectedState.Any | Stream 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:
| Method | Purpose |
|---|
.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:
| Concern | Aggregate CommandService<TAggregate, TState, TId> | Functional CommandService<TState> |
|---|
| Domain complexity | Complex invariants, rich behaviour | Simple state transitions, append-only logic |
| Ceremony | Explicit aggregate class with domain methods | No aggregate class; pure functions only |
| Identity type | Strongly-typed Id subclass | Raw StreamName derived from the command |
| Handler signature | (TAggregate, TCommand) — OO method call | (TState, object[], TCommand) — functional |
| Testing style | Aggregate spec tests (Given/When/Then) | Pure function assertions |
| When to prefer | Multiple collaborating invariants on one entity | Lightweight 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.