Header menu logo FCQRS

Write a saga

This recipe coordinates publication across two aggregates. A document asks to publish under a slug; the aggregate identified by that slug owns the uniqueness rule. The saga reserves the slug, then reports one publication result to the originating document.

Read Sagas first if you need the ground-up explanation of transitions, SagaStartingEvent, the starter handshake, and recovery re-drive.

Motivation: Use a saga here because the publication rule crosses two independent owners and the conversation must survive a process restart.

Write the state table first

Current state

Incoming event

Next state

Command issued

not started

PublicationRequested

ReservingSlug

Reserve to slug

ReservingSlug

SlugReserved

ReportingResult Published

FinishPublication Published to document

ReservingSlug

SlugUnavailable

ReportingResult Rejected

FinishPublication Rejected to document

ReportingResult result

PublicationFinished result

Done

none; stop saga

The implementation has one function for the first three columns and another for the last column.

Motivation: Writing the table first exposes missing outcomes and accidental loops before routing, persistence, or language syntax can hide them.

Map incoming events to persisted states

handleEvent receives events from every participant as obj. Match the typed envelope and current saga state together. The state is None when the starting event first reaches user code.

open FCQRS.Common
open FCQRS.FSharp

type State =
    | ReservingSlug of DocumentId * string
    | ReportingResult of Document.PublicationResult
    | Done

let private (|DocumentEvent|_|) (message: obj) =
    match message with
    | :? Event<Document.Event> as event -> Some event.EventDetails
    | _ -> None

let private (|SlugEvent|_|) (message: obj) =
    match message with
    | :? Event<Slug.Event> as event -> Some event.EventDetails
    | _ -> None

let private handleEvent message sagaState =
    match message, sagaState.State with
    | DocumentEvent(Document.PublicationRequested(docId, slug)), None ->
        ReservingSlug(docId, slug) |> StateChangedEvent
    | SlugEvent(Slug.SlugReserved _), Some(ReservingSlug _) ->
        ReportingResult Document.Published |> StateChangedEvent
    | SlugEvent(Slug.SlugUnavailable _), Some(ReservingSlug _) ->
        ReportingResult Document.Rejected |> StateChangedEvent
    | DocumentEvent(Document.PublicationFinished(_, _, result)),
        Some(ReportingResult expected) when result = expected ->
        Done |> StateChangedEvent
    | _ -> UnhandledEvent

StateChangedEvent next stores the next saga state. UnhandledEvent rejects an event that does not belong in the current state. Do not issue commands from this function; state persistence must complete first.

Map persisted states to commands

applySideEffects runs after the state is stored and again after recovery. Return commands plus the transition FCQRS should make after issuing them.

let private applySideEffects documentFactory slugFactory sagaState _recovering =
    match sagaState.State with
    | ReservingSlug(docId, slug) ->
        Stay, [ toAggregate slugFactory slug (Slug.Reserve docId) ]
    | ReportingResult result ->
        Stay, [ toOriginator documentFactory (Document.FinishPublication result) ]
    | Done ->
        StopSaga, []

The command helpers select a target:

The returned saga transition means:

Give every wait a deadline

A waiting state can hang forever for two reasons: the reply command produced no event (the target decided IgnoreEvent), or a message was lost between nodes. StayExpecting declares what the wait expects and what happens when it does not arrive:

| ReservingSlug(docId, slug) ->
    expecting (TimeSpan.FromSeconds 30.0) (FixedInterval(TimeSpan.FromSeconds 5.0))
        [ toAggregate slugFactory slug (Slug.Reserve docId) ],
    []
| PublicationFailed ->
    Stay, [ toOriginator documentFactory (Document.FinishPublication Document.Rejected) ]
PublicationState.ReservingSlug state => new()
{
    Transition = Stay(),
    Expect = Expectations.Create(
        [SagaCommands.ToAggregate(_slugs, state.Slug, new SlugCommand.Reserve(state.DocumentId))],
        deadline: TimeSpan.FromSeconds(30),
        retryEvery: RetrySchedules.Fixed(TimeSpan.FromSeconds(5)))
},
PublicationState.PublicationFailed => new()
{
    Transition = Stay(),
    Commands = [SagaCommands.ToOriginator(
        _documents, new DocumentCommand.FinishPublication(PublicationResult.Rejected))]
},

The framework sends the expectation's commands on state entry, re-sends exactly those commands on the schedule while no state transition is persisted, and past the deadline delivers an ExpectationExhausted message to handleEvent. The handler must answer it with a transition, typically to a failure or compensation state the domain defines:

| :? ExpectationExhausted, Some(ReservingSlug _) ->
    PublicationFailed |> StateChangedEvent
(ExpectationExhausted, PublicationState.ReservingSlug) =>
    StateChanged(new PublicationState.PublicationFailed()),

The rules that make this safe:

The hand-rolled equivalent — toSelfAfter with an attempt counter in the state — remains valid and shows exactly what the framework automates.

Exhaustion has two answers: escalate or renew

Escalating to a failure state, as above, is correct while the workflow can still change its mind. Some waits cannot fail. Once a saga has persisted a decision that other aggregates may already have acted on — the commit phase of a two-phase workflow, a payment capture after authorization — the only correct behaviour is to keep delivering that decision until every participant has confirmed it.

For such a wait, answer exhaustion by re-entering the same state:

| :? ExpectationExhausted, Some(Committing pending) ->
    Committing pending |> StateChangedEvent
(ExpectationExhausted, PublicationState.Committing committing) =>
    StateChanged(committing),

A self-transition persists a new state entry, which re-anchors the deadline and re-arms the schedule: the saga retries forever, but in journaled cycles. Each renewal is a durable event operators can alert on, so a participant that never recovers shows up as a growing trail of renewals instead of a silent hang. This is the deliberate blocking behaviour of a commit phase made observable, not a bug.

Choose per state:

Declare the start event

StartOn answers “which originator event creates one new instance of this saga?” Match only the event that begins the workflow.

let private startsOn (event: Event<Document.Event>) =
    match event.EventDetails with
    | Document.PublicationRequested _ -> true
    | _ -> false

let definition documentFactory slugFactory =
    { Name = "PublicationSaga"
      InitialData = ()
      Originator = documentFactory
      HandleEvent = handleEvent
      ApplySideEffects = applySideEffects documentFactory slugFactory
      StartOn = startsOn
      Snapshots = Default }

Originator supplies the aggregate factory used by the starting handshake and by toOriginator. InitialData supplies fixed data available to the saga functions. Current workflow progress belongs in the state-machine cases; use unit when no additional fixed data is needed.

Do not construct SagaStartingEvent yourself. FCQRS creates and stores that runtime envelope from the event accepted by StartOn.

Motivation: The start rule lets FCQRS subscribe the saga before the originator publishes the one event that begins the workflow. Without that handshake, the new saga could miss its first event.

Register the saga and starter rules

Register participant aggregates before constructing the saga, then wire every saga start rule once:

let documents =
    Fcqrs.aggregate api
        { Name = "Document"; Initial = Document.initial
          Decide = Document.decide; Fold = Document.fold; Snapshots = Default }

let slugs =
    Fcqrs.aggregate api
        { Name = "Slug"; Initial = Slug.initial
          Decide = Slug.decide; Fold = Slug.fold; Snapshots = Default }

let publication = Fcqrs.saga api (definition documents.Factory slugs.Factory)
Fcqrs.wireSagaStarters api [ publication ]

wireSagaStarters is not optional. It installs the predicates and the safe-start handshake that subscribes a new saga before the originator publishes its starting event.

C# equivalent

Derive from Saga<TOriginatorEvent,TData,TState>. HandleEvent returns persisted state actions; ApplySideEffects returns commands and a saga transition. The startOn predicate belongs in registration rather than on the class. HandleEvent takes object deliberately: a saga also receives other aggregates' reply events and ToSelf timeout payloads. The typed SagaApi.InitSimple shortcut only delivers the originator's events, so it cannot express timeouts or multi-aggregate coordination; those sagas belong on this base class.

public abstract record PublicationState
{
    public sealed record ReservingSlug(DocumentId DocumentId, string Slug) : PublicationState;
    public sealed record ReportingResult(PublicationResult Result) : PublicationState;
    public sealed record Done : PublicationState;
}

public sealed record PublicationData;

public sealed class PublicationSaga
    : Saga<DocumentEvent, PublicationData, PublicationState>
{
    private readonly Func<string, IEntityRef<object>> _documents;
    private readonly Func<string, IEntityRef<object>> _slugs;

    public PublicationSaga(
        Func<string, IEntityRef<object>> documents,
        Func<string, IEntityRef<object>> slugs)
    {
        _documents = documents;
        _slugs = slugs;
    }

    public override PublicationData InitialData => new();
    public override string SagaName => "PublicationSaga";
    public override Func<string, IEntityRef<object>> Originator => _documents;

    public override EventAction<PublicationState> HandleEvent(
        object message,
        SagaState<PublicationData, FSharpOption<PublicationState>> sagaState) =>
        (message, sagaState.State?.Value) switch
        {
            (Event<DocumentEvent>
                { EventDetails: DocumentEvent.PublicationRequested requested }, null) =>
                StateChanged(new PublicationState.ReservingSlug(
                    requested.DocumentId, requested.Slug)),

            (Event<SlugEvent>
                { EventDetails: SlugEvent.SlugReserved reserved },
                PublicationState.ReservingSlug expected)
                when reserved.DocumentId == expected.DocumentId =>
                StateChanged(new PublicationState.ReportingResult(
                    PublicationResult.Published)),

            (Event<SlugEvent>
                { EventDetails: SlugEvent.SlugUnavailable unavailable },
                PublicationState.ReservingSlug expected)
                when unavailable.DocumentId == expected.DocumentId =>
                StateChanged(new PublicationState.ReportingResult(
                    PublicationResult.Rejected)),

            (Event<DocumentEvent>
                { EventDetails: DocumentEvent.PublicationFinished finished },
                PublicationState.ReportingResult reporting)
                when finished.Result == reporting.Result =>
                StateChanged(new PublicationState.Done()),

            _ => Unhandled()
        };

    public override SagaSideEffectResult<PublicationState> ApplySideEffects(
        SagaState<PublicationData, PublicationState> sagaState,
        bool recovering) =>
        sagaState.State switch
        {
            PublicationState.ReservingSlug s => new()
            {
                Transition = Stay(),
                Commands = [SagaCommands.ToAggregate(
                    _slugs, s.Slug, new SlugCommand.Reserve(s.DocumentId))]
            },
            PublicationState.ReportingResult s => new()
            {
                Transition = Stay(),
                Commands = [SagaCommands.ToOriginator(
                    _documents, new DocumentCommand.FinishPublication(s.Result))]
            },
            PublicationState.Done _ => new()
            {
                Transition = StopSaga(),
                Commands = []
            },
            _ => new() { Transition = Stay(), Commands = [] }
        };
}

Register it with both factories and the safe start predicate:

services
    .AddFcqrs(connectionString, "Documents")
    .AddAggregate<PublicationDocumentAggregate>()
    .AddAggregate<SlugAggregate>()
    .AddSaga<PublicationSaga, DocumentEvent, PublicationData, PublicationState>(
        create: sp => new PublicationSaga(
            sp.AggregateFactory<PublicationDocumentAggregate>(),
            sp.AggregateFactory<SlugAggregate>()),
        startOn: e => e is Event<DocumentEvent>
            { EventDetails: DocumentEvent.PublicationRequested });

The host builder wires the saga-starter automatically from all registered sagas at startup — there is no C# counterpart of wireSagaStarters to call. Note also the signature asymmetry: HandleEvent receives the saga state as an FSharpOption (None before the first user state exists), while ApplySideEffects runs only once a user state exists and so receives it directly.

Make recovery commands safe

After reconstructing saga state, FCQRS invokes applySideEffects with recovering = true. Delivery of the previous command is uncertain, so each waiting state must do one of the following:

Motivation: Recovery repeats the next intended action because the journal can prove the stored state, but it cannot prove whether an outgoing message crossed the process boundary before failure.

In this example, reserving the same slug for the same document returns the existing reservation, and repeating FinishPublication returns the existing result without storing a duplicate. The normal commands are therefore safe to issue again.

Do not return no command merely because recovering is true. If the process stopped before delivery, that leaves the workflow waiting forever. Add a timeout for every event that may never arrive.

The complete runnable version is chapter 3 of the tutorial. Use Test your domain to test the event-to-state and state-to-command functions independently.

type State = | ReservingSlug of obj * string | ReportingResult of obj | Done
Multiple items
val string: value: 'T -> string

--------------------
type string = System.String
val message: obj
type obj = System.Object
Multiple items
module Event from Microsoft.FSharp.Control

--------------------
type Event<'T> = new: unit -> Event<'T> member Trigger: arg: 'T -> unit member Publish: IEvent<'T>

--------------------
type Event<'Delegate,'Args (requires delegate and 'Delegate :> Delegate and reference type)> = new: unit -> Event<'Delegate,'Args> member Trigger: sender: obj * args: 'Args -> unit member Publish: IEvent<'Delegate,'Args>

--------------------
new: unit -> Event<'T>

--------------------
new: unit -> Event<'Delegate,'Args>
val event: obj
union case Option.Some: Value: 'T -> Option<'T>
union case Option.None: Option<'T>
val private handleEvent: message: 'a -> sagaState: 'b -> 'c
val message: 'a
val sagaState: 'b
active recognizer DocumentEvent: obj -> 'a option
union case State.ReservingSlug: obj * string -> State
active recognizer SlugEvent: obj -> 'a option
union case State.ReportingResult: obj -> State
union case State.Done: State
val private applySideEffects: documentFactory: 'a -> slugFactory: 'b -> sagaState: 'c -> _recovering: 'd -> 'e * 'f list
val documentFactory: 'a
val slugFactory: 'b
val sagaState: 'c
val _recovering: 'd
val docId: obj
val slug: string
val result: obj

Type something to start searching.