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.
Shared setup
open System
open FCQRS.Model.Data
open FCQRS.Common
open FCQRS.FSharp
// Payloads for the publication workflow shown on this page.
type DocumentId = string
module Document =
type PublicationResult = Published | Rejected
type Command = Publish of DocumentId * string | FinishPublication of PublicationResult
type Event =
| PublicationRequested of DocumentId * string
| PublicationFinished of DocumentId * string * PublicationResult
type State = { Id: DocumentId; Slug: string; Result: PublicationResult option }
let initial = { Id = ""; Slug = ""; Result = None }
let decide (command: Command<Command>) state =
match command.CommandDetails with
| Publish(id, slug) -> PublicationRequested(id, slug) |> PersistEvent
| FinishPublication result ->
persistIf state.Result.IsNone (PublicationFinished(state.Id, state.Slug, defaultArg state.Result result))
let fold (event: Event<Event>) state =
match event.EventDetails with
| PublicationRequested(id, slug) -> { state with Id = id; Slug = slug }
| PublicationFinished(_, _, result) -> { state with Result = Some result }
module Slug =
type Command = Reserve of DocumentId
type Event = SlugReserved of DocumentId | SlugUnavailable of DocumentId
let initial: DocumentId option = None
let decide (command: Command<Command>) state =
let (Reserve id) = command.CommandDetails
match state with
| None -> SlugReserved id |> PersistEvent
| Some owner when owner = id -> SlugReserved id |> DeferEvent
| Some _ -> SlugUnavailable id |> DeferEvent
let fold (event: Event<Event>) state =
match event.EventDetails with
| SlugReserved id -> Some id
| SlugUnavailable _ -> state
SystemFCQRSModelFCQRS.Model.DataFCQRS.CommonContains common types like Events and Commands Functionality for Write Side.
FCQRS.FSharpIdiomatic-F# functional facade for FCQRS. Gives F# consumers the same one-call ergonomics the C# host-builder (HostExtensions.fs) gives C#, but with F# idioms: records-of-functions for the definitions, typed handles for the results, an explicit wiring pipeline, and plain helpers for saga side effects. It is a *pure addition* that wraps only the existing primitives (IActor.InitializeActor / SagaBuilder.initSimple / Query.init / InitializeSagaStarter / CreateCommandSubscription / Actor.api) and changes nothing in the C# interop layer or the core. open FCQRS.FSharp let api = Fcqrs.actor config loggerFactory (Some (Fcqrs.connect DBType.Sqlite conn)) "Cluster" let documents = Fcqrs.aggregate api { Name="Document"; Initial=...; Decide=...; Fold=... } let slugs = Fcqrs.aggregate api { Name="Slug"; Initial=...; Decide=...; Fold=... } let publication = Fcqrs.saga api (publicationDef documents.Factory slugs.Factory) Fcqrs.wireSagaStarters api [ publication ] let subs = Fcqrs.projection api (Projection.single 0 updateReadModel) // (Projection.multi when you must control which notifications publish) // send a command and await the matching aggregate reply: let! ev = documents.Send (Fcqrs.newCid()) (Fcqrs.aggregateId id) cmd (fun e -> ...)
DocumentIdstringAn abbreviation for the CLI type . Basic Types
Fcqrs_450-how-to_007-write-a-saga.md_page.DocumentFcqrs_450-how-to_007-write-a-saga.md_page.Document.PublicationResultPublishedRejectedFcqrs_450-how-to_007-write-a-saga.md_page.Document.CommandPublishFinishPublicationFcqrs_450-how-to_007-write-a-saga.md_page.Document.EventPublicationRequestedPublicationFinishedFcqrs_450-how-to_007-write-a-saga.md_page.Document.StateId: DocumentIdSlug: stringResult: PublicationResult optionoptionThe type of optional values. When used from other CLI languages the empty option is the null value. Use the constructors Some and None to create values of this type. Use the values in the Option module to manipulate values of this type, or pattern match against the values directly. 'None' values will appear as the value null to other CLI languages. Instance methods on this type will appear as static methods to other CLI languages due to the use of null as a value representation. Options
initial: StateNoneThe representation of "No value"
decide: Command<Command> -> State -> EventAction<Event>command: Command<Command>FCQRS.Common.Command`1Represents a command to be processed by an aggregate actor. <typeparam name="'CommandDetails">The specific type of the command payload.</typeparam>
state: StateCommandDetails: 'CommandDetailsThe specific details or payload of the command.
id: DocumentIdslug: string(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
PersistEventPersist the event to the journal. The actor's state will be updated using the event handler *after* persistence succeeds.
result: PublicationResultpersistIf: bool -> 'e -> EventAction<'e>Persist the event when `shouldPersist`, else defer it (published and folded but not journalled). The deferred fold should preserve state, because it cannot be replayed. This is the idempotent "emit this verdict, write it only once" shape.
IsNone: boolReturn 'true' if the option is a 'None' value.
defaultArg: 'T option -> 'T -> 'TUsed to specify a default value for an optional argument in the implementation of a function An option representing the argument. The default value of the argument. The argument value. If it is None, the defaultValue is returned. type Vector(x: double, y: double, ?z: double) = let z = defaultArg z 0.0 member this.X = x member this.Y = y member this.Z = z let v1 = Vector(1.0, 2.0) v1.Z // Evaluates to 0. let v2 = Vector(1.0, 2.0, 3.0) v2.Z // Evaluates to 3.0
fold: Event<Event> -> State -> Stateevent: Event<Event>FCQRS.Common.Event`1Represents an event generated by an aggregate actor as a result of processing a command. <typeparam name="'EventDetails">The specific type of the event payload.</typeparam>
EventDetails: 'EventDetailsThe specific details or payload of the event.
Fcqrs_450-how-to_007-write-a-saga.md_page.SlugFcqrs_450-how-to_007-write-a-saga.md_page.Slug.CommandReserveFcqrs_450-how-to_007-write-a-saga.md_page.Slug.EventSlugReservedSlugUnavailableinitial: DocumentId optiondecide: Command<Command> -> DocumentId option -> EventAction<Event>state: DocumentId optionSomeThe representation of "Value of type 'T" The input value. An option representing the value.
owner: DocumentId(=): 'T -> 'T -> boolStructural equality The first parameter. The second parameter. The result of the comparison. 5 = 5 // Evaluates to true 5 = 6 // Evaluates to false [1; 2] = [1; 2] // Evaluates to true (1, 5) = (1, 6) // Evaluates to false
DeferEventPublish and fold the event in the live actor without storing it or incrementing the persisted version.
fold: Event<Event> -> DocumentId option -> DocumentId optionopen 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
FCQRSFCQRS.CommonContains common types like Events and Commands Functionality for Write Side.
FCQRS.FSharpIdiomatic-F# functional facade for FCQRS. Gives F# consumers the same one-call ergonomics the C# host-builder (HostExtensions.fs) gives C#, but with F# idioms: records-of-functions for the definitions, typed handles for the results, an explicit wiring pipeline, and plain helpers for saga side effects. It is a *pure addition* that wraps only the existing primitives (IActor.InitializeActor / SagaBuilder.initSimple / Query.init / InitializeSagaStarter / CreateCommandSubscription / Actor.api) and changes nothing in the C# interop layer or the core. open FCQRS.FSharp let api = Fcqrs.actor config loggerFactory (Some (Fcqrs.connect DBType.Sqlite conn)) "Cluster" let documents = Fcqrs.aggregate api { Name="Document"; Initial=...; Decide=...; Fold=... } let slugs = Fcqrs.aggregate api { Name="Slug"; Initial=...; Decide=...; Fold=... } let publication = Fcqrs.saga api (publicationDef documents.Factory slugs.Factory) Fcqrs.wireSagaStarters api [ publication ] let subs = Fcqrs.projection api (Projection.single 0 updateReadModel) // (Projection.multi when you must control which notifications publish) // send a command and await the matching aggregate reply: let! ev = documents.Send (Fcqrs.newCid()) (Fcqrs.aggregateId id) cmd (fun e -> ...)
Fcqrs_450-how-to_007-write-a-saga.md_page.StateReservingSlugDocumentIdstringAn abbreviation for the CLI type . Basic Types
ReportingResultFcqrs_450-how-to_007-write-a-saga.md_page.DocumentFcqrs_450-how-to_007-write-a-saga.md_page.Document.PublicationResultDone(|DocumentEvent|_|): obj -> Document.Event optionDocumentEventmessage: objobjAn abbreviation for the CLI type . Basic Types
FCQRS.Common.Event`1Represents an event generated by an aggregate actor as a result of processing a command. <typeparam name="'EventDetails">The specific type of the event payload.</typeparam>
Fcqrs_450-how-to_007-write-a-saga.md_page.Document.Eventevent: Event<Document.Event>SomeThe representation of "Value of type 'T" The input value. An option representing the value.
EventDetails: 'EventDetailsThe specific details or payload of the event.
NoneThe representation of "No value"
(|SlugEvent|_|): obj -> Slug.Event optionSlugEventFcqrs_450-how-to_007-write-a-saga.md_page.SlugFcqrs_450-how-to_007-write-a-saga.md_page.Slug.Eventevent: Event<Slug.Event>handleEvent: 'a -> SagaState<'b,State option> -> EventAction<State>message: 'asagaState: SagaState<'b,State option>State: 'StateThe current state machine state of the saga.
PublicationRequesteddocId: DocumentIdslug: string(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
StateChangedEventIndicate that the state of a saga has changed (used internally by sagas for persistence).
SlugReservedPublishedSlugUnavailableRejectedPublicationFinishedresult: Document.PublicationResultexpected: Document.PublicationResult(=): 'T -> 'T -> boolStructural equality The first parameter. The second parameter. The result of the comparison. 5 = 5 // Evaluates to true 5 = 6 // Evaluates to false [1; 2] = [1; 2] // Evaluates to true (1, 5) = (1, 6) // Evaluates to false
UnhandledEventIndicate that the command or event could not be handled in the current state.
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, []
applySideEffects: AggregateFactory -> AggregateFactory -> SagaState<'a,State> -> 'b -> SagaTransition<'c> * ExecuteCommand listdocumentFactory: AggregateFactoryslugFactory: AggregateFactorysagaState: SagaState<'a,State>_recovering: 'bState: 'StateThe current state machine state of the saga.
ReservingSlugdocId: DocumentIdslug: stringStayThe saga should stay in current state without changes
toAggregate: AggregateFactory -> string -> obj -> ExecuteCommandSend a command to a specific aggregate instance by id (cross-aggregate).
Fcqrs_450-how-to_007-write-a-saga.md_page.SlugReserveReportingResultresult: Document.PublicationResulttoOriginator: AggregateFactory -> obj -> ExecuteCommandSend a command back to the saga's originator aggregate.
Fcqrs_450-how-to_007-write-a-saga.md_page.DocumentFinishPublicationDoneStopSagaThe saga should stop and terminate
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:
Shared setup
module DeadlineExample =
type State = ReservingSlug of DocumentId * string | PublicationFailed
let applySideEffects documentFactory slugFactory (sagaState: SagaState<unit, State>) =
match sagaState.State with
Fcqrs_450-how-to_007-write-a-saga.md_page.DeadlineExampleFcqrs_450-how-to_007-write-a-saga.md_page.DeadlineExample.StateReservingSlugDocumentIdstringAn abbreviation for the CLI type . Basic Types
PublicationFailedapplySideEffects: AggregateFactory -> AggregateFactory -> SagaState<unit,State> -> SagaTransition<'a> * ExecuteCommand listdocumentFactory: AggregateFactoryslugFactory: AggregateFactorysagaState: SagaState<unit,State>FCQRS.Common.SagaState`2Represents the state of a saga instance. <typeparam name="'SagaData">The type of the custom data held by the saga.</typeparam> <typeparam name="'State">The type representing the saga's current state machine state (e.g., an enum or DU).</typeparam>
unitThe type 'unit', which has only one value "()". This value is special and always uses the representation 'null'. Basic Types
State: 'StateThe current state machine state of the saga.
| 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) ]
ReservingSlugdocId: DocumentIdslug: stringexpecting: TimeSpan -> RetrySchedule -> ExecuteCommand list -> SagaTransition<'State>Declare a saga expectation: stay in this state, send `resend` now, re-send exactly those commands on `retryEvery`, and once `deadline` (measured from the persisted state-entry time, so restarts cannot postpone it) has passed without a state transition, deliver an ExpectationExhausted message to HandleEvent. The handler must answer it with a transition, typically to a failure or compensation state. Resend commands must be retry-safe and must not carry their own DelayInMs.
System.TimeSpanRepresents a time interval.
FromSeconds: float -> TimeSpanReturns a that represents a specified number of seconds, where the specification is accurate to the nearest millisecond. A number of seconds, accurate to the nearest millisecond. is less than TimeSpan.MinValue or greater than TimeSpan.MaxValue. -or- is . -or- is . is equal to . An object that represents .
FixedIntervalRe-send at a fixed interval.
toAggregate: AggregateFactory -> string -> obj -> ExecuteCommandSend a command to a specific aggregate instance by id (cross-aggregate).
slugFactory: AggregateFactoryFcqrs_450-how-to_007-write-a-saga.md_page.SlugReservePublicationFailedStayThe saga should stay in current state without changes
toOriginator: AggregateFactory -> obj -> ExecuteCommandSend a command back to the saga's originator aggregate.
documentFactory: AggregateFactoryFcqrs_450-how-to_007-write-a-saga.md_page.DocumentFinishPublicationRejectedPublicationState.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:
Shared setup
let handleEvent (message: obj) (sagaState: SagaState<unit, State option>) =
match message, sagaState.State with
handleEvent: obj -> SagaState<unit,State option> -> EventAction<State>message: objobjAn abbreviation for the CLI type . Basic Types
sagaState: SagaState<unit,State option>FCQRS.Common.SagaState`2Represents the state of a saga instance. <typeparam name="'SagaData">The type of the custom data held by the saga.</typeparam> <typeparam name="'State">The type representing the saga's current state machine state (e.g., an enum or DU).</typeparam>
unitThe type 'unit', which has only one value "()". This value is special and always uses the representation 'null'. Basic Types
Fcqrs_450-how-to_007-write-a-saga.md_page.DeadlineExample.StateoptionThe type of optional values. When used from other CLI languages the empty option is the null value. Use the constructors Some and None to create values of this type. Use the values in the Option module to manipulate values of this type, or pattern match against the values directly. 'None' values will appear as the value null to other CLI languages. Instance methods on this type will appear as static methods to other CLI languages due to the use of null as a value representation. Options
State: 'StateThe current state machine state of the saga.
| :? ExpectationExhausted, Some(ReservingSlug _) ->
PublicationFailed |> StateChangedEvent
FCQRS.Common.ExpectationExhaustedDelivered to the saga's event handler when an expectation's deadline has passed without a state transition. The handler must match this type and answer with a state change (typically to a domain failure or compensation state). An unhandled exhaustion is logged as an error and re-delivered one deadline period later; the framework never invents a terminal state. The original reply may still arrive after exhaustion — the escalated state's handler should decide what a late success means.
SomeThe representation of "Value of type 'T" The input value. An option representing the value.
ReservingSlugPublicationFailed(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
StateChangedEventIndicate that the state of a saga has changed (used internally by sagas for persistence).
(ExpectationExhausted, PublicationState.ReservingSlug) =>
StateChanged(new PublicationState.PublicationFailed()),
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:
Shared setup
| _ -> UnhandledEvent
module CommitExample =
type State = Committing of Set<DocumentId>
let handleEvent (message: obj) (sagaState: SagaState<unit, State option>) =
match message, sagaState.State with
UnhandledEventIndicate that the command or event could not be handled in the current state.
Fcqrs_450-how-to_007-write-a-saga.md_page.CommitExampleFcqrs_450-how-to_007-write-a-saga.md_page.CommitExample.StateCommittingMicrosoft.FSharp.Collections.FSharpSet`1Immutable sets based on binary trees, where elements are ordered by F# generic comparison. By default comparison is the F# structural comparison function or uses implementations of the IComparable interface on element values. See the module for further operations on sets. All members of this class are thread-safe and may be used concurrently from multiple threads.
DocumentIdhandleEvent: obj -> SagaState<unit,State option> -> EventAction<State>message: objobjAn abbreviation for the CLI type . Basic Types
sagaState: SagaState<unit,State option>FCQRS.Common.SagaState`2Represents the state of a saga instance. <typeparam name="'SagaData">The type of the custom data held by the saga.</typeparam> <typeparam name="'State">The type representing the saga's current state machine state (e.g., an enum or DU).</typeparam>
unitThe type 'unit', which has only one value "()". This value is special and always uses the representation 'null'. Basic Types
optionThe type of optional values. When used from other CLI languages the empty option is the null value. Use the constructors Some and None to create values of this type. Use the values in the Option module to manipulate values of this type, or pattern match against the values directly. 'None' values will appear as the value null to other CLI languages. Instance methods on this type will appear as static methods to other CLI languages due to the use of null as a value representation. Options
State: 'StateThe current state machine state of the saga.
| :? ExpectationExhausted, Some(Committing pending) ->
Committing pending |> StateChangedEvent
FCQRS.Common.ExpectationExhaustedDelivered to the saga's event handler when an expectation's deadline has passed without a state transition. The handler must match this type and answer with a state change (typically to a domain failure or compensation state). An unhandled exhaustion is logged as an error and re-delivered one deadline period later; the framework never invents a terminal state. The original reply may still arrive after exhaustion — the escalated state's handler should decide what a late success means.
SomeThe representation of "Value of type 'T" The input value. An option representing the value.
Committingpending: Set<DocumentId>(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
StateChangedEventIndicate that the state of a saga has changed (used internally by sagas for persistence).
(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:
- 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.
Shared setup
| _ -> UnhandledEvent
UnhandledEventIndicate that the command or event could not be handled in the current state.
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 }
startsOn: Event<Document.Event> -> boolevent: Event<Document.Event>FCQRS.Common.Event`1Represents an event generated by an aggregate actor as a result of processing a command. <typeparam name="'EventDetails">The specific type of the event payload.</typeparam>
Fcqrs_450-how-to_007-write-a-saga.md_page.DocumentFcqrs_450-how-to_007-write-a-saga.md_page.Document.EventEventDetails: 'EventDetailsThe specific details or payload of the event.
PublicationRequesteddefinition: AggregateFactory -> AggregateFactory -> Saga<unit,State,Document.Event>documentFactory: AggregateFactoryslugFactory: AggregateFactoryName: stringInitialData: 'DataOriginator: AggregateFactoryThe aggregate the saga starts from (its commands' Originator target).
HandleEvent: obj -> SagaState<'Data,'State option> -> EventAction<'State>handleEvent: 'a -> SagaState<'b,State option> -> EventAction<State>ApplySideEffects: SagaState<'Data,'State> -> bool -> SagaTransition<'State> * ExecuteCommand listapplySideEffects: AggregateFactory -> AggregateFactory -> SagaState<'a,State> -> 'b -> SagaTransition<'c> * ExecuteCommand listStartOn: Event<'OriginatorEvent> -> boolWhich originator events spawn an instance of this saga. Typed to the originator's event so 'OriginatorEvent is inferred from the definition; there is no type argument to remember (or to get silently wrong).
Snapshots: SnapshotPolicySnapshot cadence: Default (config / 30), NoSnapshots, or Every n.
DefaultUse the global config (config:akka:persistence:snapshot-version-count), or 30.
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:
Shared setup
let register (api: IActor) =
register: IActor -> unitapi: IActorFCQRS.Common.IActorDefines the core functionalities and context provided by the FCQRS environment to actors. This interface provides access to essential Akka.NET services and FCQRS initialization methods.
let documents =
Fcqrs.aggregate api
{ Name = "Document"; Initial = Document.initial
Decide = Document.decide; Fold = Document.fold
Snapshots = Default; Passivation = PassivationPolicy.Default }
let slugs =
Fcqrs.aggregate api
{ Name = "Slug"; Initial = Slug.initial
Decide = Slug.decide; Fold = Slug.fold
Snapshots = Default; Passivation = PassivationPolicy.Default }
let publication = Fcqrs.saga api (definition documents.Factory slugs.Factory)
Fcqrs.wireSagaStarters api [ publication ]
documents: AggregateHandle<Document.Command,Document.Event>FCQRS.FSharp.Fcqrsaggregate: IActor -> Aggregate<'State,'Command,'Event> -> AggregateHandle<'Command,'Event>Register an aggregate and return its typed handle. Calling this IS the registration (it initializes the sharding region).
api: IActorName: stringInitial: 'StateFcqrs_450-how-to_007-write-a-saga.md_page.Documentinitial: Document.StateDecide: Command<'Command> -> 'State -> EventAction<'Event>handleCommand (decide): command + current state -> what to do.
decide: Command<Document.Command> -> Document.State -> EventAction<Document.Event>Fold: Event<'Event> -> 'State -> 'StateapplyEvent (fold): event + current state -> next state (pure).
fold: Event<Document.Event> -> Document.State -> Document.StateSnapshots: SnapshotPolicySnapshot cadence: Default (config / 30), NoSnapshots, or Every n.
DefaultUse the global config (config:akka:persistence:snapshot-version-count), or 30.
Passivation: PassivationPolicyIdle passivation: PassivationPolicy.Default (configuration, then Akka's 120s), After an idle period, or Never.
FCQRS.Common.PassivationPolicyIdle passivation for an aggregate type, set per entity at registration. Passivation stops an idle actor and releases its in-memory state; the next command recovers it from the journal. Only messages routed through cluster sharding count as activity. Sagas ignore this: their shard regions remember entities, which disables idle passivation in Akka.NET. A saga stops at StopSaga or abort instead.
DefaultUse configuration: `akka.cluster.sharding.<EntityName>.passivate-idle-entity-after`, then `akka.cluster.sharding.passivate-idle-entity-after`, then Akka.NET's 120s.
slugs: AggregateHandle<Slug.Command,Slug.Event>Fcqrs_450-how-to_007-write-a-saga.md_page.Sluginitial: DocumentId optiondecide: Command<Slug.Command> -> DocumentId option -> EventAction<Slug.Event>fold: Event<Slug.Event> -> DocumentId option -> DocumentId optionpublication: SagaHandlesaga: IActor -> Saga<'Data,'State,'OriginatorEvent> -> SagaHandleRegister a saga and return its handle. The originator-event type is inferred from `def.StartOn`, so there are no type arguments to supply: `Fcqrs.saga api def`.
definition: AggregateFactory -> AggregateFactory -> Saga<unit,State,Document.Event>Factory: AggregateFactoryEntity-ref factory (DEFAULT_SHARD applied). Hand this to a saga to target it.
wireSagaStarters: IActor -> SagaHandle list -> unitWire every registered saga into one saga-starter (or the empty starter if none). Call after the aggregates + sagas are registered.
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:
- 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.
Use Test your domain to test the event-to-state and state-to-command functions independently.