Articles · Sagas & Workflows ·015

Sagas

Runnable sample on GitHub

Sample: samples/015-sagas — an order saga driven in-memory. dotnet test. No database.

Prerequisites: 003 — Domain Events, 014 — Reliable Messaging.

Overview

A command changes one aggregate; reliable messaging lets services react to each other. But some processes are longer than a single reaction — they span multiple steps, multiple services, and time: an order is placed, then awaits payment, then (if paid) ships, or (if not paid in time) is cancelled. Coordinating that — reacting to several events over a lifetime, keeping state between them, and acting on timeouts — is a saga (a.k.a. process manager). A saga is a small state machine that is started by one event, handles further correlated events, requests and cancels timeouts, and sends commands / publishes events in response — all on the reliable-messaging foundation. This article covers the imperative authoring style; article 016 covers the declarative DSL.

Why this exists

The alternative to a saga is orchestration logic smeared across handlers with no home for the process. Where does “wait for payment, but only for an hour, then cancel” live? Not in the order aggregate (that’s one consistency boundary; the process spans several). Not in a single command handler (it’s not a single reaction; it unfolds over events and time). Without a saga, teams hack it: a status column polled by a cron job, scattered “if paid and shipped then…” checks, a timeout implemented as a background sweep that re-queries everything. The process becomes implicit, untestable, and fragile.

A saga makes the process explicit and stateful. It has its own persisted state (separate from any aggregate), a clear correlation (which order is this event about?), and a defined reaction to each event and timeout. It is started by an event, advanced by subsequent events, and can schedule a timeout to act when something doesn’t happen (the hardest case — you can’t react to a non-event without a timer). And because it runs on reliable messaging, the events that drive it and the commands it sends are delivered durably. The saga is the home for “what happens across steps.”

When to use this

Use a saga when a process spans multiple events/services over time and needs to coordinate them:

  • Order fulfillment: placed → await payment (timeout → cancel) → reserve stock → ship → notify. Each step is a different service; the saga is the conductor.
  • Payment + provisioning: charge, then provision access; if provisioning fails, refund. Two services, a success path and a compensating failure path.
  • Booking / travel: reserve flight, hotel, car; if any fails, release the others (the routing-slip special case, article 017).
  • Onboarding / multi-step workflows: a sequence of steps across services with deadlines and cancellation.
  • Anything with a “wait for X, but only until a deadline” rule — sagas own timeouts.

The signal: you’re coordinating more than one service/event over time, and there’s state to keep and a deadline to honor. That’s a process, and a process wants a saga.

When not to use this

  • A single reaction. “When an order is confirmed, update a read model” is a domain event (article 003), not a saga. One event in, one effect out, no state, no time — no saga.
  • Logic that belongs in an aggregate. Invariants within one consistency boundary are the aggregate’s job (article 002). A saga coordinates across boundaries; it doesn’t replace them.
  • A synchronous sequence in one service. If the steps are all local and immediate, a transaction or a single handler is simpler. Sagas are for distributed, eventual, timed coordination.
  • Trivial two-step flows you’ll never extend. The saga machinery (state, correlation, persistence, timeouts) is overhead for something a domain event and a follow-up command handle.

The costs: a saga is stateful infrastructure (persisted state, correlation, a coordinator, timeout scheduling — PostgreSQL in production); it’s eventually consistent (it reacts to events as they arrive); and modelling the process well (states, transitions, what each timeout means) is real design work that’s easy to get subtly wrong.

Concepts

Saga & state. Saga<TState> where TState : ISagaState holds the data carried between messages. The state is persisted (serialized) by the coordinator after each message.

Correlation. Each handler declares how to extract the correlation key from its event (e => e.OrderId). The coordinator uses it to find the existing saga (or, for a start trigger, create one). One live saga per correlation key (per tenant).

Start vs handle. StartedBy<TEvent>(correlate, handler) creates a new saga when that event arrives; Handle<TEvent>(correlate, handler) routes the event to the existing saga (and is ignored if none exists, by default).

Timeouts. RequestTimeout(delay, payload) schedules a future message to itself (via the scheduler, article 018); CancelTimeouts() cancels pending ones; OnTimeout<TTimeout>(handler) reacts when one fires. Timeouts are how a saga acts on something not happening.

Side effects. Send(command) and Publish(event) issue reliable messages; the coordinator flushes them atomically with the state save. Complete() ends the saga.

The coordinator. ISagaCoordinator.DeliverAsync(sagaType, event) loads/creates the saga, runs the handler (buffering side effects), persists the new state with optimistic concurrency, and flushes the buffered timeouts/commands/events. DeliverTimeoutAsync(...) does the same for a fired timeout.

Architecture

sequenceDiagram
    participant Ev as Integration event (via inbox)
    participant Co as SagaCoordinator
    participant Repo as Saga store (relay_sagas)
    participant Sch as Scheduler (timeouts)
    Ev->>Co: DeliverAsync(OrderSaga, OrderPlaced)
    Co->>Repo: find by (type, correlation, tenant) — none → create
    Co->>Co: run OnPlaced (buffers RequestTimeout)
    Co->>Repo: persist state (optimistic version)
    Co->>Sch: schedule the buffered timeout
    Note over Co,Sch: PaymentReceived later → load, OnPaid, CancelTimeouts, Complete

Building it step by step

The sample is samples/015-sagas.

1. State and messages

public sealed class OrderState : ISagaState { public Guid OrderId { get; set; } public bool Paid { get; set; } }
public sealed record OrderPlaced     : IntegrationEvent { public Guid OrderId { get; init; } }
public sealed record PaymentReceived : IntegrationEvent { public Guid OrderId { get; init; } }
public sealed class  OrderExpired    { public Guid OrderId { get; set; } } // a timeout payload

2. The saga — start, handle, timeout

public sealed class OrderSaga : Saga<OrderState>
{
    protected override void Configure(ISagaConfigurator<OrderState> cfg) => cfg
        .StartedBy<OrderPlaced>(e => e.OrderId, OnPlaced)
        .Handle<PaymentReceived>(e => e.OrderId, OnPaid)
        .OnTimeout<OrderExpired>(OnExpired);

    private Task OnPlaced(OrderPlaced e, CancellationToken ct)
    {
        State.OrderId = e.OrderId;
        RequestTimeout(TimeSpan.FromHours(1), new OrderExpired { OrderId = e.OrderId }); // act if payment is late
        return Task.CompletedTask;
    }
    private Task OnPaid(PaymentReceived e, CancellationToken ct) { State.Paid = true; CancelTimeouts(); Complete(); return Task.CompletedTask; }
    private Task OnExpired(OrderExpired t, CancellationToken ct) { Complete(); return Task.CompletedTask; } // a real saga: Publish(OrderCancelled)
}

3. Register it (production)

services.AddRelaySagas();
services.AddRelaySagasEfCore();               // PostgreSQL persistence (relay_sagas)
services.AddRelaySaga<OrderSaga, OrderState>();

The sample swaps this for an in-memory harness (the framework’s own unit-test pattern) so it runs without a database, while exercising the real SagaCoordinator.

Complete source code

File Contents
OrderSaga.cs The saga, its state, and its messages
SagaHarness.cs In-memory coordinator + fakes
OrderSagaTests.cs Start, complete, ignore, timeout

Running the example

dotnet test samples/015-sagas/Sagas.Sample.Tests

Testing

A saga’s behaviour is observable through its persisted state and its scheduled/cancelled timeouts — which is exactly what the tests assert, driving the real coordinator:

await coordinator.DeliverAsync(typeof(OrderSaga), new OrderPlaced { OrderId = id }, default);
saga.Status.Should().Be(SagaStatus.Active);
scheduler.Scheduled.Should().ContainSingle(m => m.Kind == ScheduledDeliveryKind.SagaTimeout);

await coordinator.DeliverAsync(typeof(OrderSaga), new PaymentReceived { OrderId = id }, default);
saga.Status.Should().Be(SagaStatus.Completed);
scheduler.CancelledCorrelations.Should().Contain(sagaId);

Because the coordinator is real (only persistence/scheduling are faked), these tests pin the actual correlation, start-vs-handle routing, timeout scheduling/cancellation, and completion — the things a saga must get right.

Production considerations

  • Persist with EF Core + run on reliable messaging. AddRelaySagasEfCore stores sagas in relay_sagas with an optimistic-concurrency version; the events that drive the saga arrive through the inbox (article 013) and the commands/timeouts it sends go through the outbox/scheduler. The saga is only as reliable as the messaging under it.
  • Correlation must be stable and unique. The correlation key identifies the saga instance; choose something present on every message in the process (the order id). A wrong/missing key means events can’t find their saga.
  • Decide the missing-message policy. By default a non-start message for no existing saga is ignored (SagaMissingPolicy.Ignore); switch to Fault if a message arriving before its start trigger should be retried rather than dropped (e.g. out-of-order delivery you must not lose).
  • Make handlers idempotent. At-least-once delivery means a saga handler can run twice for the same event; the optimistic-concurrency version and idempotent logic keep that safe.
  • Time out deliberately. The timeout is how you handle “X didn’t happen.” Set durations from the business SLA, and remember a saga holds state until it completes — long-lived sagas accumulate.
  • Avoid fat sagas. A saga coordinates; it shouldn’t contain business rules that belong in aggregates, nor balloon into a god-process. If it’s doing too much, the process model is probably wrong.

Common mistakes

  • Putting aggregate invariants in the saga. The saga coordinates across boundaries; it doesn’t enforce one aggregate’s rules. Keep invariants in the aggregate (article 002).
  • Using a saga for a single reaction. That’s a domain event. Sagas are for multi-step, timed processes.
  • Forgetting the timeout for “didn’t happen” cases. You cannot react to a non-event without a timer. If the process has a deadline, it needs RequestTimeout.
  • Unstable correlation. Correlating on something not present (or not unique) on every message breaks routing. Use a stable business id.
  • Synchronous thinking. A saga is eventually consistent — it reacts as events arrive. Expecting it to complete “immediately” after the first event misunderstands the model.
  • Never completing. A saga that never calls Complete() (or times out) lives forever, accumulating state. Every path should end.

Tradeoffs

Benefits. The process becomes an explicit, testable, stateful object; “wait until a deadline” is first-class (timeouts); coordination across services is reliable (built on outbox/inbox); and the saga keeps orchestration out of aggregates and handlers.

Costs. Stateful infrastructure (persistence, correlation, coordinator, scheduler); eventual consistency; and the genuine difficulty of modelling a process correctly (states, transitions, timeouts, idempotency).

Alternatives

  • Orchestration in a single service/handler (no saga). Fine for a short, local, synchronous sequence; it falls apart for distributed, timed, multi-event processes — which is exactly when you’d reach for a saga.
  • An external workflow engine (Temporal, Camunda, Step Functions). Powerful, durable workflow orchestration with their own programming models. Excellent for complex, long-running, human-in-the-loop workflows; heavier to operate and a separate system. Relay’s saga integrates natively with your messaging and persistence; choose by complexity and whether you want another platform.
  • Choreography (services react to events with no central coordinator). Each service reacts to events and emits its own, with no saga. Maximally decoupled, but the process is implicit and emergent — hard to see, change, or reason about end-to-end. A saga is orchestration: a visible coordinator. Use choreography for simple fan-out; a saga when the process needs a conductor.

Objectively: sagas win for distributed, stateful, timed processes; simpler designs win for single reactions, and dedicated workflow engines win for very complex, long-lived, human-centric workflows.

Lessons from production systems

  • The implicit process is the one that breaks. Teams that hack multi-step coordination into status columns and cron sweeps end up unable to answer “what state is this order in and why?” Making the process an explicit saga is what restores that visibility.
  • Timeouts are the feature people forget and then desperately need. “We need to cancel orders that aren’t paid within an hour” arrives after launch; teams without a timeout mechanism bolt on a fragile sweep. Sagas have it built in — model deadlines from day one.
  • Correlation bugs are the subtle ones. An event that can’t find its saga (wrong key, missing field) silently does nothing. Choosing and validating a stable correlation key prevents a class of “the saga just didn’t advance” mysteries.
  • Sagas that never complete are a slow leak. A forgotten terminal path leaves sagas Active forever, growing the table and the mental model. Review every saga for “how does each path end?”

Should you use this?

Situation Recommendation
Multi-step process across services, over time Strong fit — the core use
“Wait for X, but only until a deadline” Strong fit — timeouts
Distributed success/failure with compensation Strong fit (routing slips, article 017)
A single reaction to one event No — use a domain event (article 003)
Invariants within one aggregate No — that’s the aggregate
Very complex, long-lived, human-in-the-loop workflows Consider a dedicated workflow engine

Next steps

This saga was authored imperatively (handler methods). The next article expresses the same saga declaratively as a state machine — naming the states and transitions so the process graph is explicit and reviewable — and shows the framework proving the two styles are behaviourally identical.

➡️ 016 — State-Machine Sagas