Skip to main content
Table of Contents

Write a Saga

Derived page. The behaviour described here is specified by the sagas capability under openspec/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 through IMediator.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.