Articles · Sagas & Workflows ·016

State-Machine Sagas

Runnable sample on GitHub

Sample: samples/016-state-machine-sagas — the order saga, declaratively. dotnet test. No database.

Prerequisite: 015 — Sagas.

Overview

Article 015 authored a saga imperatively — handler methods that mutate state and call RequestTimeout/Complete. Relay also offers a declarative style: StateMachineSaga<TState>, where you name the states, the correlated events, and the timeouts, then describe the transitions with a fluent DSL (Initially / During(state, …) / When(event).Then(…).Schedule(…). TransitionTo(state).Finalize()). The saga’s behaviour is identical to the imperative form — the framework’s own tests assert that — but the process graph is explicit on the page: which events are valid in which states, and what each does. This article shows the DSL and when to prefer it.

Why this exists

An imperative saga’s state graph is implicit — you reconstruct it by reading the handler methods and the ifs inside them. For a simple saga that’s fine; for a process with several states and rules about which events are valid where (“payment is only accepted while Submitted, not after Shipped”), the implicit graph becomes hard to see and easy to get wrong. The declarative DSL makes the graph the code: During(Submitted, When(Paid)…) says, unmistakably, “a payment is handled only in the Submitted state.” A reviewer sees the whole machine — states, valid transitions, terminal states — without simulating the handlers in their head. It also catches a class of bug structurally: an event with no transition in the current state is simply ignored, rather than silently mis-handled by an imperative method that didn’t guard its state.

When to use this

  • Sagas with several meaningful states where “which events are valid in which state” is itself a business rule worth making explicit (order lifecycle, subscription lifecycle, an approval workflow).
  • Sagas reviewed by non-authors (or your future self): the DSL reads as a specification of the process.
  • Processes you’ll evolve: adding a state or a transition is a localized DSL edit, and the graph stays legible.

Prefer the imperative style (article 015) when the saga is small and essentially linear (start → maybe-timeout → complete), where the DSL’s ceremony outweighs its clarity. The two are interchangeable; pick per saga by how much the explicit state graph helps.

When not to use this

  • Trivial, linear sagas. Two states and a timeout read fine imperatively; the DSL adds nouns (MachineState, Event<T>, Timeout<T>) for little gain.
  • When you don’t actually have states. If the saga never has “valid only in state X” rules — every event is handled the same regardless of where it is — there’s no state machine to express; imperative is simpler.
  • Everything in When not to use for sagas generally (article 015) — a single reaction, aggregate invariants, synchronous sequences — applies here too. The DSL is an authoring choice within the saga decision, not a separate one.

The cost is a second authoring vocabulary to learn and the indirection of declaring states/events as members; for a saga that warrants it, that cost buys a reviewable process graph.

Concepts

MachineState. A named state (new(nameof(Submitted))). The saga persists its CurrentState.

Event<TEvent> and Timeout<TTimeout>. Declared as members, with the event’s correlation (new(e => e.OrderId)). They’re the symbols the transitions reference.

DefineStateMachine(). Where the graph is built:

  • Initially(When(ev)…) — the start trigger(s) (a new saga).
  • During(state, When(ev)…, …) — transitions valid only in that state.
  • DuringAny(When(ev)…) — transitions valid in any state (e.g. a global failure).

Activities chained on When(...): .Then(action) (mutate state / side effects), .Schedule(timeout, delay, payloadFactory), .CancelTimeouts(), .Publish(factory), .Send(factory), .Compensate(...) (article 017), .TransitionTo(state), .Finalize() (complete).

Equivalence. The declarative and imperative engines produce the same observable outcome (status, persisted state, scheduled/cancelled timeouts) for the same scenario — proven by the framework’s tests.

Architecture

stateDiagram-v2
    [*] --> Submitted: OrderPlaced / set state, Schedule(Expiry), 
    Submitted --> [*]: PaymentReceived / set Paid, CancelTimeouts, Finalize
    Submitted --> [*]: Expiry (timeout) / Finalize

The DSL is this diagram, in code. Initially is the arrow from the start; During(Submitted, …) are the arrows out of Submitted.

Building it step by step

The sample is samples/016-state-machine-sagas.

1. Declare states, events, timeouts as members

public sealed class OrderStateMachine : StateMachineSaga<OrderState>
{
    public MachineState Submitted { get; } = new(nameof(Submitted));
    public Event<OrderPlaced>     Placed { get; } = new(e => e.OrderId);
    public Event<PaymentReceived> Paid   { get; } = new(e => e.OrderId);
    public Timeout<OrderExpired>  Expiry { get; } = new();

2. Describe the transitions

    protected override void DefineStateMachine()
    {
        Initially(
            When(Placed)
                .Then(e => State.OrderId = e.OrderId)
                .Schedule(Expiry, TimeSpan.FromHours(1), e => new OrderExpired { OrderId = e.OrderId })
                .TransitionTo(Submitted));

        During(Submitted,
            When(Paid).Then(e => State.Paid = true).CancelTimeouts().Finalize(),
            When(Expiry).Finalize());
    }
}

Read top to bottom: an OrderPlaced starts the saga, sets state, schedules the expiry timeout, and moves to Submitted; while Submitted, a payment finalizes it (cancelling the timeout) and the expiry finalizes it otherwise. That’s the whole machine.

3. Register (production) — identical to article 015

services.AddRelaySagas();
services.AddRelaySagasEfCore();
services.AddRelaySaga<OrderStateMachine, OrderState>();

Complete source code

File Contents
OrderStateMachine.cs The declarative saga
OrderStateMachineTests.cs Start (records CurrentState), complete, ignore, timeout

Running the example

dotnet test samples/016-state-machine-sagas/Sagas.StateMachine.Tests

Testing

The declarative saga is tested exactly like the imperative one (same coordinator, same assertions), plus the explicit CurrentState:

await coordinator.DeliverAsync(typeof(OrderStateMachine), new OrderPlaced { OrderId = id }, default);
saga.CurrentState.Should().Be("Submitted");   // the state graph is observable

// PaymentReceived is only valid During(Submitted); with no live saga it is not a start trigger → ignored
await coordinator.DeliverAsync(typeof(OrderStateMachine), new PaymentReceived { OrderId = Guid.NewGuid() }, default);
repo.All.Should().BeEmpty();

That second test captures the DSL’s structural benefit: an event with no transition in the current state does nothing, by construction — you don’t have to remember to guard it.

Production considerations

  • Same runtime as imperative. Persistence (relay_sagas, optimistic concurrency), reliable messaging, timeouts, idempotency — all identical to article 015. The DSL is an authoring layer over the same engine.
  • Keep states meaningful. Name states after business phases (Submitted, Shipped), not internal flags. The value is a graph a stakeholder could read.
  • Use DuringAny for cross-cutting transitions. A global failure/cancel that applies in any state belongs in DuringAny, not duplicated in every During.
  • Mind unhandled events. “Ignored because no transition” is correct if intended; if an event should have been handled and silently wasn’t, the bug is a missing transition. Review the graph against the expected events per state.
  • Migrating state shapes. As with any saga, evolving TState (or the state set) for in-flight sagas needs care; prefer additive changes and consider draining before a breaking change.

Common mistakes

  • Reaching for the DSL on a trivial saga. Two states don’t need a declared machine; imperative is clearer. Use the DSL where the graph earns its keep.
  • Forgetting TransitionTo/Finalize. A transition that doesn’t move state or finalize leaves the saga where it was — sometimes intended, often a bug. Be explicit about where each When leaves the saga.
  • Putting heavy logic in .Then. Activities should be small state mutations and message sends; complex branching suggests the process model needs more states, not a fatter .Then.
  • Assuming declarative changes the semantics. It doesn’t — same correlation, persistence, timeouts, delivery guarantees. If you expect different behaviour from the DSL, you’ve misread it.

Tradeoffs

Benefits. The state graph is explicit and reviewable; “valid only in state X” is structural, not a remembered guard; evolving the process is a localized edit; and it’s the same proven engine as imperative.

Costs. A second authoring vocabulary; member declarations for states/events/timeouts; and ceremony that’s pure overhead for trivial sagas.

Alternatives

  • Imperative sagas (article 015). The other authoring style — leaner for small/linear sagas, less legible for state-rich ones. Same engine; choose per saga.
  • External workflow engines (Temporal, Camunda). Their own (often code-first or BPMN) modelling of state machines, with heavier infrastructure. Better for very complex or human-centric workflows; Relay’s DSL is the in-process, natively-integrated option.

Objectively: prefer the DSL when the explicit state graph aids comprehension and evolution; prefer imperative when the saga is small enough that the graph is obvious anyway.

Lessons from production systems

  • The graph-on-the-page pays off at review time. Teams report that state-machine sagas are far easier for someone other than the author to review and modify, because the valid transitions are right there — no need to simulate handler methods mentally.
  • “Ignored, no transition” prevents a real bug class. Imperative handlers that forget to guard their state mis-handle events arriving in the wrong phase; the DSL makes the wrong-phase case a structural no-op. Teams that adopt it stop seeing “payment applied to an already-shipped order” bugs.
  • Over-DSLing trivial sagas annoys people. The flip side: forcing the DSL onto a two-line saga adds noise. The mature teams use both styles and pick by saga complexity.

Should you use this?

Situation Recommendation
Saga with several states and “valid in state X” rules Strong fit — the graph is the point
Process reviewed/evolved by non-authors Strong fit
Trivial, linear saga Prefer imperative (article 015)
No real states (every event handled the same) Prefer imperative
Very complex / human-in-the-loop workflows Consider a workflow engine

Next steps

Both saga styles can record and trigger compensation — the way to undo completed steps when a later one fails, giving you a distributed transaction without two-phase commit. The next article focuses on that pattern: the routing slip.

➡️ 017 — Routing Slips & Compensation