Write a Saga
Derived page. The behaviour described here is specified by the
sagascapability underopenspec/specs/. That specification is the source; this page explains and illustrates it. Where the two disagree, the specification is right and this page is a bug.
A saga (a.k.a. process manager) reacts to events by issuing more commands. Stratara registers sagas via AddSagasFromAssemblyContaining<T>() + the SagaWorker.
The contract
ISaga (Stratara.Sagas.Abstractions) is an empty marker, exactly like IProjection. The
runtime reflects over your class for HandleAsync methods whose first parameter is the event payload
or an IEvent<TEvent>, and routes matching events to them. The difference from a projection is
semantic: a projection updates a read model; a saga issues more commands.
A minimal saga
using JetBrains.Annotations;
using Stratara.Abstractions.EventSourcing;
using Stratara.Sagas.Abstractions;
public sealed class TransferSaga(
ICommandOutboxDispatcher dispatcher,
IAccountQueryStore accounts) : ISaga
{
[UsedImplicitly]
private async Task HandleAsync(IEvent<TransferRequested> @event, CancellationToken ct)
{
var transfer = @event.Data;
var sourceBalance = await accounts.GetBalanceAsync(transfer.FromAccountId, ct);
if (sourceBalance < transfer.Amount)
{
return; // validation failed — the saga emits no commands, the transfer never happens
}
await dispatcher.EnqueueCommandAsync(new WithdrawCommand(transfer.FromAccountId, transfer.Amount), ct);
await dispatcher.EnqueueCommandAsync(new DepositCommand(transfer.ToAccountId, transfer.Amount), ct);
}
}
Write one HandleAsync(IEvent<TEvent>, CancellationToken) per event you react to — the payload is
@event.Data. Handlers may be private; mark them [UsedImplicitly] so analyzers don't flag them.
Commands go out through ICommandOutboxDispatcher.EnqueueCommandAsync.
Payload or envelope
The handler above takes the enveloped event. A saga that needs only the event can take the payload instead:
using JetBrains.Annotations;
using Stratara.Sagas.Abstractions;
public sealed class WelcomeBonusSaga(ICommandOutboxDispatcher dispatcher) : ISaga
{
private const decimal WelcomeBonus = 10m;
[UsedImplicitly]
private async Task HandleAsync(AccountOpened opened, CancellationToken ct) =>
await dispatcher.EnqueueCommandAsync(new DepositCommand(opened.AccountId, WelcomeBonus), ct);
}
When a relevant event arrives, the handler taking its payload is invoked; where no payload handler
matches, the one taking IEvent<TEvent> is invoked instead — reach for that when you need the stream,
the version or the owning tenant. Declare one or the other per event, not both.
Register
builder.AddSagaWorkerServices(); // the worker host composite
builder.Services.AddSagasFromAssemblyContaining<TransferSaga>();
AddSagaWorkerServices() (from Stratara.EventSourcing.WorkerDefaults) brings the hosted
SagaWorker and its dependencies; AddSagasFromAssemblyContaining<T>() registers your sagas.
Events no saga handles are not read. The saga worker and, on the Orleans execution model, the saga
readers decide from an event's type, after upcasting, whether any saga in the host handles it, and
leave every other event unread — not resolved, not decrypted, so its type need not be registered. An
event whose type does not resolve but carries the name of a type a saga handles is still read, and
fails as an unregistered type does. A stateful process decides from the event itself, so its reader
reads every event whose type resolves; one whose type does not resolve cannot be one it handles, and
is skipped with warning 102_005, once per host and event type — register the type, or add an
upcaster if the process should see it.
Idempotency
Sagas must be idempotent — at-least-once delivery means the bus can replay the same event after a broker reconnect, and it redelivers a bundle whose handler threw (up to MessageRetry:MaxDeliveryAttempts times, then it is dead-lettered). Because a redelivery re-runs HandleAsync, guard the enqueue:
- State tracking in your own read-store —
HasTransferBeenStarted(transferId)before enqueueing, so a replay is a no-op. - Deterministic command identity — derive the down-stream command's own key from the source event so a duplicate enqueue collapses at the handler rather than moving money twice.
Order is per aggregate, and per process
The saga worker opens several consumers on its subscription — one per processor unless
Sagas:DegreeOfParallelism says otherwise — so consecutive bundles land on different consumers and
are not dispatched in publication order. What it guarantees: bundles about one aggregate reach
sagas one at a time within a process. Across two processes on the same subscription there is no
such promise.
A saga that reads a view before it dispatches can therefore see a follow-up fact before the view has
the entity it is about. Throw PrecedingFactMissingException(streamId, eventTypeName) where the
lookup comes back empty: the worker retries the bundle — six attempts, about three seconds in all,
with the aggregate lock released in between — and fails it only when the retries run out. Every
other exception fails the bundle on the first attempt.
A saga has no replay, so a failed bundle must not vanish — and it does not: the transport delivers
it again, up to MessageRetry:MaxDeliveryAttempts times (a concurrency conflict from the command
the saga issues up to MessageRetry:MaxConflictRequeues times), and then moves it to the saga
subscription's dead-letter destination, where an operator returns it once the cause is fixed. The
process resumes from there. See
When a handler cannot take a message.
A host that needs strict order sets Sagas:DegreeOfParallelism to 1; a value that is not a
positive number means one consumer per processor.
Watch it run
The saga worker measures itself on the Stratara.Service meter: saga.inflight is the number of
bundles being processed right now, saga.events.processed counts the events handed to sagas, tagged
with event.type and outcome, and saga.bundle.duration records how long each bundle took, in
milliseconds, tagged with outcome. The outcome is success or failure, so a failing saga shows up
as a rising failure series rather than only as a slower one; an in-flight count that keeps climbing
means the saga lane is not keeping up. What to subscribe to and how to export it is in
Observe the Framework.
A process's timeout runs under the session it was started with
On the Orleans execution model a stateful process — a SagaProcess<TState> — receives every fact under the
session the fact was recorded under, set before its state is read. A timeout has no fact: it runs under the
session the process's state stream was created with, and the framework reads that stream's first entry to find
it before any session is in place. A host that routes its connection per tenant must therefore answer an
absent tenant with a connection that reaches the state streams, or the process's timeouts fail.
A timeout may schedule the process's next timeout from its own handling, for the same purpose and even the same due time; the new timeout is kept and fires. A timeout whose handling is interrupted because its silo stops fires again on another silo, with the state as recorded.
Compensation is your job
Stratara does not provide a two-phase commit. If the WithdrawCommand succeeds and the DepositCommand fails (the destination account was closed mid-transfer), the saga's down-stream listener has to emit a compensating RefundCommand against the source account.
The pattern: the saga listens for both WithdrawSucceeded and DepositFailed. On DepositFailed, it issues RefundCommand. Stratara just gives you the wiring; the choreography is yours.
Anti-patterns
- Don't expect a result from an outbox command.
dispatcher.EnqueueCommandAsync(…)returns once the command is accepted — published, or stored for the outbox worker — not once it runs — you get the envelope id, not the outcome. If you need a synchronous result, dispatch throughIMediator.HandleAsync(…)instead (but you give up the outbox's at-least-once delivery). - Don't query write-store state from the saga. Query a projection or a read-store. The saga is a read-side actor that produces write-side effects — keep its reads on the read side.