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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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:
toOriginator factory command: the exact aggregate instance whose event started the saga;toAggregate factory id command: another aggregate instance selected by id;toActor actorRef command: an arbitrary actor reference;toSelf command: a message back to the saga itself (it lands inhandleEvent);-
toOriginatorAfter factory delayMs taskName command/toAggregateAfter/toSelfAfter: delayed variants for timeout or retry behaviour. AtoSelfAfterreminder is the idiomatic saga timeout: enter a state, schedule a wake-up, and lethandleEventdecide whether it still matters.
The returned saga transition means:
Stay: keep waiting in the current state after sending commands;-
StayExpecting expectation: keep waiting, but declare a timeout and retry policy for the wait (next section); NextState next: persist another state immediately and run its side effects;-
StopSaga: send any returned commands, then complete and passivate. Delayed commands returned withStopSagaare still delivered — excepttoSelfAfterones, which are cancelled with a warning so a completed saga cannot resurrect itself.
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) ]
|
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
|
The rules that make this safe:
- The deadline is measured from the persisted state-entry time, not from a timer. A restart or crash loop re-arms the schedule from the journal and cannot postpone the deadline.
- Re-sent commands must be retry-safe. This is the same contract recovery re-drives already impose; the expectation adds no new obligation on the target aggregate.
-
A timeout means the outcome is unknown, not failed. The reply may still arrive after the saga
escalated, so the failure state's
handleEventshould decide what a late success means instead of ignoring it. -
An unhandled
ExpectationExhausted(no matching case, or a handler exception) is logged as an error and re-delivered one deadline period later. The framework never invents a terminal state. -
DeadlineandRetryEveryare explicit; there are no defaults. Size the deadline above worst-case shard handoff plus journal latency, and preferBackoff(which applies jitter) when many sagas can wait on the same aggregate. - One expectation per state. A state waiting on several aggregates with different deadlines should be split into one state per wait.
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
|
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:
- Escalate when a timeout can still resolve the workflow (report a rejection, compensate, release a hold). Anything before the decision point belongs here.
- Renew when the state represents a decision already made. Never abort after the decision; alert on repeated renewals and fix the participant instead.
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.
|
Register it with both factories and the safe start predicate:
|
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:
- resend an idempotent command;
- query an external operation by a stable idempotency key;
- issue a recovery-specific reconciliation command;
- move to an explicit failed or manual-resolution path.
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.
val string: value: 'T -> string
--------------------
type string = System.String
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>
FCQRS