Write a Command Handler
Derived page. The behaviour described here is specified by the
mediator-dispatchandevent-sourcing-storecapabilities underopenspec/specs/. Those specifications are the source; this page explains and illustrates them. Where a specification and this page disagree, the specification is right and this page is a bug.
A command handler implements ICommandHandler<TCommand> (no result) or IQueryHandler<TCommand, TResult> (the unified interface that also handles ICommand<T>). Stratara discovers them via AddCommandHandlersFromAssemblyContaining<T>().
Choose the command shape
| Scenario | Marker | Routing |
|---|---|---|
| Mutation without result, fire-and-forget | ICommand |
ICommandOutboxDispatcher (async via outbox) |
| Mutation with synchronous result (e.g. setup, "create-and-return-id" flows) | ICommand<TResult> |
IMediator (in-process) |
| Mutation that destroys infrastructure (drop outbox, recreate write-store) | ICommand<TResult> |
IMediator (must stay in-process) |
| Mutation that signals process-local state | ICommand |
IMediator (in-process) |
| Read | IQuery<TResult> |
IMediator |
See Routing Conventions for the full table.
Define the command + handler
using Stratara.Abstractions.Mediator;
public sealed record DepositCommand(Guid AccountId, decimal Amount) : ICommand;
public sealed class DepositHandler(IAccountRepository repo) : ICommandHandler<DepositCommand>
{
public async Task HandleAsync(DepositCommand cmd, CancellationToken ct)
{
var account = await repo.GetAsync(cmd.AccountId, ct);
account.Deposit(cmd.Amount);
await repo.SaveAsync(account, ct);
}
}
Register
services.AddCommandHandlersFromAssemblyContaining<DepositHandler>();
That's it — the handler is now resolved per-scope and dispatched whenever mediator.HandleAsync(new DepositCommand(...)) is called.
A handler that appends events needs command auditing
Every recorded event carries the identity of the command that caused it, and the command-audit pipeline is what supplies it. A host whose handlers append events registers it:
services.AddCommandAuditing();
It lives in Stratara.EventSourcing.Pipeline.CommandAudit and AddCommandServices() does not
register it for you, because where it sits in the pipeline is yours to decide — a host composing the
Orleans execution model keeps the aggregate-grain behaviour innermost, so the order matters.
Without it, SaveChangesAsync refuses the append before anything is written, with a message naming
this registration. A flow that appends without a command having preceded it — a timer, a sweep —
sets a causation id on the session it builds instead.
Which tenant owns the events you append
A handler that writes events appends them through IEventSource and commits them with
SaveChangesAsync. Every event is recorded under the tenant that owns it — and that tenant is not
simply the one in the caller's session. The store takes, for each event, the first of these that
names a tenant:
- the subject you stated for that event with
AppendOnBehalfOfAsync, or for the stream's first event withCreateOnBehalfOfAsync; - the owner already resolved for the same stream earlier in the same batch;
- the owner recorded on the stream's first event — its tenant, and its user where one was recorded;
- the
TenantIdthe event carries itself, when it implementsIAggregateCreationEvent; - the tenant in the session, together with the session's data-owner user.
If none of them yields a tenant, the append fails with a message that names the event, the stream, and the three ways to supply an owner: an explicit subject, a creation event, or a session tenant.
The stream comes before the session on purpose. A privileged operator whose session names another tenant cannot silently re-home an existing aggregate by appending to it — the stream keeps the owner it was created with, for every aggregate, whether or not the aggregate exposes a tenant property of its own. The user is part of that owner: a stream whose first event was recorded for a user gives every later event the same user, whichever user the session names, and a stream whose first event names no user is given none. A creation event carries a tenant and no user, so a stream it creates has no user. An aggregate whose events carried different owners could not be fully erased, because each tenant's or user's erasure reaches only its own entries, and once one of those keys was shredded it could not be rehydrated at all.
So a new aggregate states its owner on its first event:
public sealed record AccountOpenedInTenant(Guid AccountId, Guid TenantId, decimal InitialBalance)
: IAggregateCreationEvent;
public sealed record OpenAccountInTenant(Guid AccountId, Guid TenantId, decimal InitialBalance) : ICommand;
public sealed class OpenAccountInTenantHandler(IEventSource events) : ICommandHandler<OpenAccountInTenant>
{
public async Task HandleAsync(OpenAccountInTenant cmd, CancellationToken ct)
{
await events.CreateAsync<Account>(
cmd.AccountId,
new AccountOpenedInTenant(cmd.AccountId, cmd.TenantId, cmd.InitialBalance),
ct);
await events.SaveChangesAsync(ct);
}
}
Every later append to that stream lands in the same tenant, whoever's session makes it.
When the event does not carry the owner — an account an operator opens in a customer's tenant, a platform-owned record created under any session — state it at creation instead:
await events.CreateOnBehalfOfAsync<Account>(
accountId,
new AccountOpened(accountId, initialBalance),
new EventSubject(customerTenantId),
ct);
CreateOnBehalfOfAsync fails like CreateAsync when the stream exists, and like AppendOnBehalfOfAsync
when the subject names no tenant. The stated owner is the stream's owner from then on.
When the session would decide a new stream's owner
The fifth candidate is the one that goes wrong quietly. A first event that states no owner and carries none takes the session's tenant — right when a tenant's user creates a record in their own tenant, wrong when an operator creates one for somebody else, and nothing fails either way. A host can decide:
{
"EventSourcing": {
"NewStreamOwnerFromSession": "Warn"
}
}
| Value | A new stream's first event that would take its owner from the session |
|---|---|
Allow (default) |
is recorded for the session's tenant, as before |
Warn |
is recorded for the session's tenant, and 102_007 (Warning) names the stream, the event type and the tenant — once per stream and batch, again when a failed save is retried |
Refuse |
fails before anything is staged, naming the stream, the event type and the ways to state an owner |
The setting touches nothing else: an owner stated with CreateOnBehalfOfAsync or AppendOnBehalfOfAsync,
a creation event that carries a tenant, and every append to a stream that already exists behave as
without it. Run Warn in a lower environment until the log stays quiet, then Refuse.
Appending on behalf of another subject
When an event genuinely belongs to someone other than the resolved owner, say so — never obtain it
by leaving something out. AppendOnBehalfOfAsync takes an EventSubject (a tenant id and an
optional user id) and overrides every other source, for that one event only:
public sealed record CorrectBalance(Guid AccountId, Guid OwningTenantId, decimal Amount) : ICommand;
public sealed class CorrectBalanceHandler(IEventSource events) : ICommandHandler<CorrectBalance>
{
public async Task HandleAsync(CorrectBalance cmd, CancellationToken ct)
{
await events.AppendOnBehalfOfAsync<Account>(
cmd.AccountId,
new AmountDeposited(cmd.AccountId, cmd.Amount),
new EventSubject(cmd.OwningTenantId),
ct);
await events.SaveChangesAsync(ct);
}
}
The actor recorded with the event is still the caller's session; only the owner changes. A subject whose tenant id is empty fails the append at once — the message names the event and the stream, nothing is recorded, and the store does not fall back to the stream, the event or the session, because stating a subject already said that none of them applies.
Whose key a command's protected fields are under
A command can carry [EncryptData] fields like any other serialized type. The framework serializes
a command in two places: the copy that travels to a handler that runs later — the outbox, the bus,
the Orleans record — and the copy the command audit records. By default both are encrypted for the
session the command was dispatched under: the session's data-owner tenant, together with the
acting user in the travelling copy and with the session's data-owner user in the audit record.
That is right when the session's tenant is the tenant the data belongs to, and wrong in exactly the
flows the session has two identities for. A session with no tenant — an anonymous endpoint, or a
saga reacting to a fact one recorded, which runs under that fact's session — cannot encrypt a
TenantScoped field at all outside development. A session with another tenant encrypts it under
that tenant's key: nothing fails, and erasing the real owner leaves the audit record readable.
Only the command knows whose data it carries, so let it say so. INamesDataOwner is to a command
what IAggregateCreationEvent is to a stream's first event:
public sealed record TrackPageVisit(
Guid TenantId,
[property: EncryptData(DataSensitivityLevel.TenantScoped)] string? Address)
: ICommand, INamesDataOwner
{
Guid INamesDataOwner.DataOwnerTenantId => TenantId;
}
Implement it explicitly, as above, so nothing is added to the command's JSON. DataOwnerUserId is
optional and defaults to no user; implement it too when the data belongs to one user of the tenant.
What follows from the declaration:
- Both copies are written for the named owner — the same tenant and the same user, whatever session dispatched the command. The audit record names that owner as its data owner and the session's actor as its actor, so a tenant's view of the command log holds the commands that carried its data, whoever issued them.
- The session is untouched. The handler runs under the dispatching session; authorization, tenant isolation and correlation read it as before.
- It authorizes nothing. Nothing compares the named owner against the session. Whether the
caller may act on that owner's data is decided as for any other command — by the stages you
registered and by your handler. A command that also implements
ITenantScopedRequestis still held to the session by the isolation guard; the two declarations are separate statements. - The owner is taken as stated. Nothing checks that the named tenant exists, and the key store creates key material for a tenant the first time a field is encrypted for it. Name an owner your code resolved from something it trusts — the stream it loaded, a credential it verified, the event a saga reacts to — and never one copied from a request body. An endpoint that passes a caller's identifier straight through lets that caller mint keys, and, with auditing registered ahead of authorization, leave records in the command log of whichever tenant it names.
- An empty owner is refused. A command that implements the declaration and names
Guid.Emptyas its tenant — or as its user — fails with anArgumentExceptionnaming its type, where it is recorded and where it is dispatched, and nothing is recorded or sent. It does not fall back to the session. ReturnnullfromDataOwnerUserIdfor data that belongs to the tenant alone. - The command can outlive its owner. A command still waiting when its owner is erased is
delivered, and its protected fields read as absent — see
Unreadable encrypted fields degrade rather than fail.
Give those fields a type that can be absent, as
Addressis above, and let the handler treat an absent one as "the owner is gone". The audit record stays as the evidence that the command was issued; its protected fields are unreadable from then on. A command with[EncryptData]on the class is encrypted as one unit and cannot be rebuilt once its key is gone: it is retried and then kept, and its handler does not run. Mark fields, not the class, on a command that should still be handled after its owner is erased.
Rolling upgrade. A host on a release before
INamesDataOwnercannot read the owner a command carries. It does not run the handler with the fields emptied: underStrictbus-envelope integrity the signature does not verify, and otherwise the read fails authentication, so the message is retried and then kept. Upgrade every host that handles commands — command workers and silos — before any host dispatches a command that names an owner. The simplest order is two deployments: the framework release first, the declarations after. A command that names no owner is stored, published and signed exactly as before, so the release itself needs no ordering.
When two writers race on one stream
SaveChangesAsync writes the whole batch or none of it. If another writer has already written the
versions this batch is appending, the save throws ConcurrencyException
(Stratara.Abstractions.EventSourcing), whose StreamId and AggregateTypeName name the stream
and the aggregate type that lost the race. The staged batch is discarded — every stream in it —
so a retry starts from a fresh read of the aggregate, not from the events that just failed.
The exception is the same on every database provider the framework ships a store registration for:
a version collision the database refuses through its uniqueness constraint is a concurrency conflict
on PostgreSQL and on the SQLite store the test-support package registers alike. A save that fails
for any other reason propagates unchanged and is never presented as a conflict. Catch
ConcurrencyException and nothing broader: a conflict is worth retrying, anything else is not.
Who retries depends on how the command was dispatched:
- Through
ICommandOutboxDispatcher— nothing to write. The command worker treats the conflict as a retry rather than a failure and redelivers the command, up toMessageRetry:MaxConflictRequeuestimes (default 100); the handler runs again and re-reads. See When a handler cannot take a message. - Through
IMediator, in process — the exception reaches the caller, who decides:
app.MapPost("/accounts/{accountId:guid}/deposits", async (Guid accountId, decimal amount, IMediator mediator, CancellationToken ct) =>
{
try
{
await mediator.HandleAsync(new DepositCommand(accountId, amount), ct);
return Results.NoContent();
}
catch (ConcurrencyException conflict)
{
return Results.Conflict(new { conflict.StreamId, conflict.AggregateTypeName });
}
});
Each conflict is also counted, on event_source.append.conflicts, tagged with aggregate.type and
bucket.id — the partition the stream fell in — so contention on one aggregate type or one hot
partition shows up before it shows up as latency.
A write between your read and your append
AppendAsync numbers the event after whatever the stream holds when you append, not after what
you read. A handler that rebuilds an aggregate, decides on it, and then appends therefore does not
conflict with a write that landed in between: its event is simply recorded after that write. Most of
the time that is harmless. It is not when the write in between changed what the decision rests on —
the entry was deleted, the order was cancelled — because the handler's fact is then recorded after a
fact that contradicts it. Re-reading just before the append narrows the window; it does not close it,
because the save that follows still takes time.
Two ways close it:
Name the version you decided on.
AppendAtVersionAsync<TAggregate>(streamId, expectedVersion, @event)andAppendRangeAtVersionAsync<TAggregate>(streamId, expectedVersion, events)number the events fromexpectedVersion + 1. If the stream has moved past it,SaveChangesAsyncthrows the sameConcurrencyExceptionas above and records nothing. Read the version withGetCurrentVersionAsyncbefore you rebuild the aggregate, so a write in between surfaces as a conflict rather than slipping through. A version the stream has not reached yet is refused at the append withArgumentOutOfRangeException.Let the host apply it to every read-then-write handler. Set the option, and an append to a stream that the handler rebuilt through
IAggregationService.AggregateAsyncin the same scope is made on the condition of the version that rebuild saw — without touching a handler:{ "EventSourcing": { "AppendAgainstAggregatedVersion": true } }AddEventSourcing()reads the section. The condition is the version of the first rebuild of a stream in the scope — a second rebuild of the same stream, by a validator or a helper, does not move it. A rebuild bounded withtoVersionsets no condition. A rebuild that found no stream sets the condition that the stream still does not exist, so a handler whoseAppendAsyncwrites the first event conflicts with whoever created it first;CreateAsyncrefuses an existing stream — or one already staged in the batch — withInvalidOperationException. A successful save ends the condition on the streams it wrote and keeps it on those the handler read but has not written; a failed save ends it everywhere, so a handler that runs again reads again.
What the option costs: a handler that appends to a stream another writer touches concurrently now gets a
conflict where it used to succeed. Through ICommandOutboxDispatcher that is a redelivery and a fresh
read; through IMediator the caller sees the ConcurrencyException. The option is off by default.
When a save committed but could not publish
SaveChangesAsync commits the events and then hands their bundle on to the outbox. On a host without
durable bundles, that handover can fail after the commit, when both the bus and the outbox's own table
are unavailable. The save then throws CommittedEventsNotPublishedException, which names the committed
streams, whatever ended the handover — a cancellation included. The events are recorded; do not
append them again. Readers that consume bundles did not receive them: a projection is repaired by a
replay, but a saga that reacts to bundles has missed them, and nothing repairs that — which is why a
host that cannot lose a bundle stores bundles with the commit. Such a host does not raise the
exception at all: its bundle is recorded in the same transaction as the events, and a handover that
fails afterwards loses nothing. Nothing in the framework runs the work again because of it: the
RabbitMQ and Azure Service Bus transports acknowledge the message and log an error (108_113); on the
Orleans execution model a recorded command is completed (117_127), a store reader counts the entry
as applied (117_128) and a durable timer counts as fired (117_129), each logged as an error; and
the retrying pipelines do not retry it.
Mandatory hygiene
- Max 7 constructor parameters (this counts as one). If you need more, group them in a
sealed recordparameter object. - No magic numbers — name your constants.
- No manual retry loops — pull a Polly pipeline from
Stratara.ResilienceviaResiliencePipelineProvider<string>. - No
Stopwatch— useActivitySource.StartActivity()for timing.
Logging
Source-generated only for new code. Per-package extension class under Diagnostics/Extensions/Logger*Extensions.cs:
public static partial class LoggerAccountExtensions
{
[LoggerMessage(
EventId = LogEvents.MyApp.DepositApplied,
Level = LogLevel.Information,
Message = "Deposited {Amount} into account {AccountId}.")]
public static partial void LogDepositApplied(this ILogger logger, decimal amount, Guid accountId);
}
Then in the handler:
logger.LogDepositApplied(cmd.Amount, cmd.AccountId);
Never call logger.LogInformation(...) directly. If you need expensive arguments (string-join, LINQ projection), wrap them in a small struct with ToString() — the source-gen formatter will defer evaluation until the channel is enabled. See Stratara.Shared.Diagnostics.Extensions.DistinctEventTypeNames for the canonical pattern.
Tracing
Every dispatch is wrapped in a span named Handle <RequestType> — you never emit one yourself. The
spans come from the Stratara.Application activity source, the same one every other framework
trace uses, so a host that subscribes to that source sees each command and query as it passes
through the pipeline. Nothing has to be registered for this: AddMediator() and
AddAuthorizingMediator<T>() work on their own. A host that registers its own OpenTelemetry
Tracer keeps it, and the mediator emits through that instead.