Header menu logo FCQRS

3. Adding a saga

DocStore can now store and look up documents. This chapter adds one more rule: a document can be published under a URL slug, and each slug may belong to only one document.

Every rule so far involved a single document, so one aggregate could enforce it alone. This rule is different, and it runs into a restriction that has been true since chapter 1: an aggregate can read and change only its own state. A document aggregate cannot look inside a slug aggregate, cannot lock it, and cannot update it in the same transaction. Aggregates never call each other. FCQRS keeps them isolated on purpose, because that isolation is what lets each one process commands, recover, and move between nodes independently.

In a single-database application the new rule would be one transaction touching a documents table and a slugs table. Across aggregates there is no shared transaction, so something else must coordinate, and that coordinator is the saga. A saga is a durable workflow that does the job a transaction coordinator does in a database: it waits for an event from one aggregate, sends a command to the next, waits for the reply, and reports the outcome back to where the work started. It cannot lock both aggregates or roll them back together. Instead it stores its own progress after every step, so a crash in the middle resumes the conversation instead of losing it.

This example is intentionally small. The only new problem is durable coordination, so the saga mechanics remain visible.

Course position: chapter 2 completed work owned by one aggregate and projected its event. This chapter handles one rule with two independent owners. By the end you will be able to derive saga state from events, issue commands only after progress is stored, and explain safe startup and resumption.

The rule belongs to two owners

The document aggregate decides whether its document may be published. The slug aggregate decides whether its slug is still free. Neither one can see the other's state, so neither one can enforce the whole rule alone. The saga carries the conversation between them:

Document                          Publication saga              Slug[guides/fcqrs]
   |                                      |                                       |
   |-- PublicationRequested ------------->|                                       |
   |                                      |-- Reserve(documentId) --------------->|
   |                                      |<-- SlugReserved / Unavailable --------|
   |<-- FinishPublication(result) --------|                                       |
   |                                      |                                       |
   |-- PublicationFinished -------------->|  StopSaga                             |

The aggregates still make every business decision. The saga only remembers how far the conversation has gone and sends the next message.

Motivation: Keeping each rule with its owner prevents the saga from becoming a second, stale copy of document and slug state. The saga coordinates answers; it does not invent them.

The chapter's new pieces as a wireframe; each unimplemented body names the step that fills it in.

// Step 1 extends Document with Publish / FinishPublication commands and
// PublicationRequested / PublicationFinished events.

module Slug =
    // One aggregate instance per slug; the first Reserve wins.
    let decide (cmd: Command<Command>) (state: State) : EventAction<Event> =
        failwith "step 2"

    let fold (event: Event<Event>) (state: State) : State =
        failwith "step 2"

module PublicationSaga =
    // Store intended progress first, then send the next safe command.
    let handleEvent (message: obj) (sagaState: SagaState<unit, State option>)
        : EventAction<State> =
        failwith "step 3"

    let applySideEffects documentFactory slugFactory sagaState recovering
        : SagaTransition<State> * ExecuteCommand list =
        failwith "step 3"

    let startsOn (event: Event<Document.Event>) : bool =
        failwith "step 4"
// Step 1 extends DocumentCommand with Publish / FinishPublication and
// DocumentEvent with PublicationRequested / PublicationFinished.

// One aggregate instance per slug; the first Reserve wins.
public sealed class SlugAggregate : Aggregate<SlugState, SlugCommand, SlugEvent>
{
    public override EventAction<SlugEvent> HandleCommand(
        Command<SlugCommand> cmd, SlugState state) =>
        throw new NotImplementedException("step 2");

    public override SlugState ApplyEvent(Event<SlugEvent> evt, SlugState state) =>
        throw new NotImplementedException("step 2");
}

// Store intended progress first, then send the next safe command.
public sealed class PublicationSaga
    : Saga<DocumentEvent, PublicationData, PublicationState>
{
    public override EventAction<PublicationState> HandleEvent(
        object message,
        SagaState<PublicationData, FSharpOption<PublicationState>> sagaState) =>
        throw new NotImplementedException("step 3");

    public override SagaSideEffectResult<PublicationState> ApplySideEffects(
        SagaState<PublicationData, PublicationState> sagaState, bool recovering) =>
        throw new NotImplementedException("step 3");

    public static bool StartsOn(object message) =>
        throw new NotImplementedException("step 4");
}

Extend the document with publication

Chapters 1 and 2 gave the document its content model: Root, CreateOrUpdate, and Updated. Those stay exactly as they were. Publication is added to that same aggregate — the state gains a publication track beside the document. The document id is the aggregate's own identity, so the new Publish command carries only the slug. Validate the slug at the application boundary before sending it.

The validated values are unchanged from chapter 1:

module Values =
    type DocumentId =
        | DocumentId of Guid
        static member OfGuid value = DocumentId value
        member this.Value = let (DocumentId value) = this in value
        override this.ToString() = let (DocumentId value) = this in value.ToString()

    type Title =
        | Title of ShortString
        static member TryCreate s =
            match ValueLens.TryCreate s with
            | Ok ss -> Ok(Title ss)
            | Error _ -> Error "Invalid title"
        member this.Value = let (Title s) = this in ValueLens.Value s

    type Content =
        | Content of LongString
        static member TryCreate s =
            match ValueLens.TryCreate s with
            | Ok ss -> Ok(Content ss)
            | Error _ -> Error "Invalid content"
        member this.Value = let (Content s) = this in ValueLens.Value s
// Unchanged from chapter 1.
public readonly record struct DocumentId(Guid Value)
{
    public static DocumentId OfGuid(Guid value) => new(value);
    public override string ToString() => Value.ToString();
}

Step 1: let the document request publication

The document stores PublicationRequested before any reservation begins. Its state records the slug being reserved, so the eventual result remains valid after recovery. A document must exist (chapter 1's CreateOrUpdate) before it can be published.

module Document =
    open Values

    type Root =
        { Id: DocumentId; Title: Title; Content: Content }

        static member TryCreate(guid, title, content) =
            match Title.TryCreate title, Content.TryCreate content with
            | Ok t, Ok c -> Ok { Id = DocumentId.OfGuid guid; Title = t; Content = c }
            | Error e, _ -> Error e
            | _, Error e -> Error e

    type PublicationResult =
        | Published
        | Rejected

    type PublicationStatus =
        | NotRequested
        | WaitingForSlug of slug: string
        | Finished of slug: string * PublicationResult

    type State =
        { Document: Root option
          Publication: PublicationStatus }

    let initial = { Document = None; Publication = NotRequested }

    type Command =
        | CreateOrUpdate of Root
        | Publish of slug: string
        | FinishPublication of PublicationResult

    type Event =
        | Updated of Root
        | PublicationRequested of DocumentId * slug: string
        | PublicationFinished of DocumentId * slug: string * PublicationResult

    let decide (cmd: Command<Command>) state =
        match cmd.CommandDetails, state with
        // Content changes work exactly as in chapter 1.
        | CreateOrUpdate doc, _ ->
            Updated doc |> PersistEvent
        // First Publish: store the request; state now remembers the slug being reserved.
        | Publish slug, { Document = Some doc; Publication = NotRequested } ->
            PublicationRequested(doc.Id, slug) |> PersistEvent
        // The same Publish again (a retry): same reply, nothing stored twice.
        | Publish slug, { Document = Some doc; Publication = WaitingForSlug currentSlug }
            when slug = currentSlug ->
            PublicationRequested(doc.Id, slug) |> DeferEvent
        // The saga reports the outcome: store it once.
        | FinishPublication result, { Document = Some doc; Publication = WaitingForSlug slug } ->
            PublicationFinished(doc.Id, slug, result) |> PersistEvent
        // The same outcome again (saga recovery): same reply, nothing stored twice.
        | FinishPublication result, { Document = Some doc; Publication = Finished(slug, current) }
            when result = current ->
            PublicationFinished(doc.Id, slug, result) |> DeferEvent
        // Everything else: no document yet, a different slug, a contradicting result.
        | _ -> UnhandledEvent

    let fold (event: Event<Event>) state =
        match event.EventDetails with
        | Updated doc -> { state with Document = Some doc }
        | PublicationRequested(_, slug) -> { state with Publication = WaitingForSlug slug }
        | PublicationFinished(_, slug, result) -> { state with Publication = Finished(slug, result) }

FinishPublication carries either Published or Rejected. Repeating the same result returns the same PublicationFinished outcome with DeferEvent, so recovery can reissue one saga command without storing another domain event. Keeping the result as data removes duplicate success and failure branches from the aggregate.

The publication cases form two pairs: the first request is stored and its retry is deferred; the first result is stored and its retry is deferred. The final wildcard rejects every command and state combination that does not belong to this workflow — including Publish before the document exists.

public enum PublicationResult { Published, Rejected }

public abstract record PublicationProgress
{
    public sealed record NotRequested : PublicationProgress;
    public sealed record WaitingForSlug(string Slug) : PublicationProgress;
    public sealed record Finished(string Slug, PublicationResult Result) : PublicationProgress;
}

// The chapter-1 state type gains a publication track beside the document.
public sealed record DocumentState(Document? Document, PublicationProgress Publication)
{
    public static readonly DocumentState Initial =
        new(null, new PublicationProgress.NotRequested());
}

public union DocumentCommand(
    DocumentCommand.CreateOrUpdate, DocumentCommand.Publish, DocumentCommand.FinishPublication)
{
    public record CreateOrUpdate(Document Document);
    public record Publish(string Slug);
    public record FinishPublication(PublicationResult Result);
}

public union DocumentEvent(
    DocumentEvent.Updated, DocumentEvent.PublicationRequested, DocumentEvent.PublicationFinished)
{
    public record Updated(Document Document);
    public record PublicationRequested(DocumentId DocumentId, string Slug);
    public record PublicationFinished(
        DocumentId DocumentId, string Slug, PublicationResult Result);
}

public sealed class PublicationDocumentAggregate
    : Aggregate<DocumentState, DocumentCommand, DocumentEvent>
{
    public override DocumentState InitialState => DocumentState.Initial;
    public override string EntityName => "Document";

    public override EventAction<DocumentEvent> HandleCommand(
        Command<DocumentCommand> command, DocumentState state) =>
        (command.CommandDetails, state) switch
        {
            // Content changes work exactly as in chapter 1.
            (DocumentCommand.CreateOrUpdate c, _) =>
                EventActions.Persist<DocumentEvent>(new DocumentEvent.Updated(c.Document)),
            // First Publish: store the request; state now remembers the slug being reserved.
            (DocumentCommand.Publish p,
                { Document: { } doc, Publication: PublicationProgress.NotRequested }) =>
                EventActions.Persist<DocumentEvent>(
                    new DocumentEvent.PublicationRequested(doc.Id, p.Slug)),
            // The same Publish again (a retry): same reply, nothing stored twice.
            (DocumentCommand.Publish p,
                { Document: { } doc, Publication: PublicationProgress.WaitingForSlug waiting })
                when p.Slug == waiting.Slug =>
                EventActions.Defer<DocumentEvent>(
                    new DocumentEvent.PublicationRequested(doc.Id, p.Slug)),
            // The saga reports the outcome: store it once.
            (DocumentCommand.FinishPublication finish,
                { Document: { } doc, Publication: PublicationProgress.WaitingForSlug waiting }) =>
                EventActions.Persist<DocumentEvent>(new DocumentEvent.PublicationFinished(
                    doc.Id, waiting.Slug, finish.Result)),
            // The same outcome again (saga recovery): same reply, nothing stored twice.
            (DocumentCommand.FinishPublication finish,
                { Document: { } doc, Publication: PublicationProgress.Finished current })
                when finish.Result == current.Result =>
                EventActions.Defer<DocumentEvent>(new DocumentEvent.PublicationFinished(
                    doc.Id, current.Slug, finish.Result)),
            // Everything else: no document yet, a different slug, a contradicting result.
            _ => EventActions.Ignore<DocumentEvent>()
        };

    public override DocumentState ApplyEvent(
        Event<DocumentEvent> stored, DocumentState state) =>
        stored.EventDetails switch
        {
            DocumentEvent.Updated updated =>
                state with { Document = updated.Document },
            DocumentEvent.PublicationRequested requested =>
                state with { Publication = new PublicationProgress.WaitingForSlug(requested.Slug) },
            DocumentEvent.PublicationFinished finished =>
                state with
                {
                    Publication = new PublicationProgress.Finished(finished.Slug, finished.Result)
                },
            _ => state
        };
}

Step 2: let each slug protect its own uniqueness

The slug aggregate is addressed by the slug text. Its entire state is the document that owns the reservation, if any.

module Slug =
    open Values

    type State = { ReservedFor: DocumentId option }
    let initial = { ReservedFor = None }

    type Command = Reserve of DocumentId

    type Event =
        | SlugReserved of DocumentId
        | SlugUnavailable of DocumentId

    let decide (cmd: Command<Command>) state =
        match cmd.CommandDetails, state.ReservedFor with
        // First reservation wins and becomes durable.
        | Reserve documentId, None ->
            SlugReserved documentId |> PersistEvent
        // The same document asking again gets the same answer, nothing stored.
        | Reserve documentId, Some current when current = documentId ->
            SlugReserved documentId |> DeferEvent
        // Any other document: taken; the owner does not change.
        | Reserve documentId, Some _ ->
            SlugUnavailable documentId |> DeferEvent

    let fold (event: Event<Event>) state =
        match event.EventDetails with
        | SlugReserved documentId -> { ReservedFor = Some documentId }
        | SlugUnavailable _ -> state

The first reservation becomes durable. Repeating it for the same document returns the existing result. A different document receives SlugUnavailable without changing the owner. Both repeated paths are safe when a saga resumes after uncertain delivery.

public sealed record SlugState(DocumentId? ReservedFor = null)
{
    public static readonly SlugState Initial = new();
}

public union SlugCommand(SlugCommand.Reserve)
{
    public record Reserve(DocumentId DocumentId);
}

public union SlugEvent(SlugEvent.SlugReserved, SlugEvent.SlugUnavailable)
{
    public record SlugReserved(DocumentId DocumentId);
    public record SlugUnavailable(DocumentId DocumentId);
}

public sealed class SlugAggregate : Aggregate<SlugState, SlugCommand, SlugEvent>
{
    public override SlugState InitialState => SlugState.Initial;
    public override string EntityName => "Slug";

    public override EventAction<SlugEvent> HandleCommand(
        Command<SlugCommand> command, SlugState state) =>
        (command.CommandDetails, state.ReservedFor) switch
        {
            // First reservation wins and becomes durable.
            (SlugCommand.Reserve reserve, null) =>
                EventActions.Persist<SlugEvent>(new SlugEvent.SlugReserved(reserve.DocumentId)),
            // The same document asking again gets the same answer, nothing stored.
            (SlugCommand.Reserve reserve, DocumentId owner) when owner == reserve.DocumentId =>
                EventActions.Defer<SlugEvent>(new SlugEvent.SlugReserved(reserve.DocumentId)),
            // Any other document: taken; the owner does not change.
            (SlugCommand.Reserve reserve, _) =>
                EventActions.Defer<SlugEvent>(new SlugEvent.SlugUnavailable(reserve.DocumentId)),
            _ => EventActions.Ignore<SlugEvent>()
        };

    public override SlugState ApplyEvent(Event<SlugEvent> stored, SlugState state) =>
        stored.EventDetails is SlugEvent.SlugReserved reserved
            ? new SlugState(reserved.DocumentId)
            : state;
}

Step 3: write the saga as a table

Before writing the saga functions, write every accepted event and resulting command:

Current state

Incoming event

Stored next state

Command after storage

not started

PublicationRequested

ReservingSlug

Reserve to slug

ReservingSlug

SlugReserved

ReportingResult Published

FinishPublication Published to document

ReservingSlug

SlugUnavailable

ReportingResult Rejected

FinishPublication Rejected to document

ReservingSlug

ExpectationExhausted

ReportingResult Rejected

FinishPublication Rejected to document

ReportingResult result

PublicationFinished result

Done

stop

The fourth row is the timeout. ExpectationExhausted is not a domain event: FCQRS delivers it when the wait declared in Function 2 below runs out of time without any answer from the slug.

This table separates two questions:

  1. Given an event and current saga state, what state should be stored?
  2. Once that state is durable, which command should be sent?

Motivation: The split ensures FCQRS can store what the saga intends to do before it sends a command that may be delivered just as the process fails.

module PublicationSaga =
    open Values

    type State =
        | ReservingSlug of DocumentId * string
        | ReportingResult of Document.PublicationResult
        | Done
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;

Function 1: event plus state becomes persisted progress

Saga events arrive as obj because several aggregate event types share the same workflow. Active patterns recover their typed envelopes. The first domain event reaches user code with sagaState.State = None; no user-defined state exists until that event is accepted.

    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 handleEvent (message: obj) sagaState =
        match message, sagaState.State with
        // Table row 1: the starting event opens the workflow.
        | DocumentEvent(Document.PublicationRequested(documentId, slug)), None ->
            ReservingSlug(documentId, slug) |> StateChangedEvent
        // Table rows 2 and 3: the slug's answer decides which result to report.
        | SlugEvent(Slug.SlugReserved documentId), Some(ReservingSlug(expected, _))
            when documentId = expected ->
            ReportingResult Document.Published |> StateChangedEvent
        | SlugEvent(Slug.SlugUnavailable documentId), Some(ReservingSlug(expected, _))
            when documentId = expected ->
            ReportingResult Document.Rejected |> StateChangedEvent
        // Timeout row: the declared wait ran out without an answer. Unknown is
        // treated as Rejected; the prose after Function 2 discusses the cost.
        | :? ExpectationExhausted, Some(ReservingSlug _) ->
            ReportingResult Document.Rejected |> StateChangedEvent
        // Last row: the document confirmed the result; the workflow is complete.
        | DocumentEvent(Document.PublicationFinished(_, _, result)), Some(ReportingResult expected)
            when result = expected ->
            Done |> StateChangedEvent
        // Anything else is out of order for the current state.
        | _ -> UnhandledEvent
public override EventAction<PublicationState> HandleEvent(
    object message,
    SagaState<PublicationData, FSharpOption<PublicationState>> sagaState) =>
    (message, sagaState.State?.Value) switch
    {
        // Table row 1: the starting event opens the workflow.
        (Event<DocumentEvent>
            { EventDetails: DocumentEvent.PublicationRequested requested }, null) =>
            StateChanged(new PublicationState.ReservingSlug(
                requested.DocumentId, requested.Slug)),
        // Table rows 2 and 3: the slug's answer decides which result to report.
        (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)),
        // Timeout row: the declared wait ran out without an answer.
        (ExpectationExhausted, PublicationState.ReservingSlug) =>
            StateChanged(new PublicationState.ReportingResult(
                PublicationResult.Rejected)),
        // Last row: the document confirmed the result; the workflow is complete.
        (Event<DocumentEvent>
            { EventDetails: DocumentEvent.PublicationFinished finished },
            PublicationState.ReportingResult reporting)
            when finished.Result == reporting.Result =>
            StateChanged(new PublicationState.Done()),
        // Anything else is out of order for the current state.
        _ => Unhandled()
    };

StateChangedEvent next is the saga counterpart to a persisted aggregate outcome: FCQRS stores the new workflow state. UnhandledEvent means the incoming event is not valid for the current state.

Function 2: persisted state becomes commands

FCQRS calls applySideEffects only after a state change is stored. It calls the same function after recovery, with recovering = true.

    let applySideEffects documentFactory slugFactory sagaState _recovering =
        match sagaState.State with
        // Ask the slug to reserve, and declare the wait: re-send Reserve every
        // five seconds (it is retry-safe, the same property recovery relies on)
        // and deliver ExpectationExhausted after thirty. The deadline measures
        // from the journaled entry into this state, so restarts cannot reset it.
        | ReservingSlug(documentId, slug) ->
            expecting (TimeSpan.FromSeconds 30.0) (FixedInterval(TimeSpan.FromSeconds 5.0))
                [ toAggregate slugFactory slug (Slug.Reserve documentId) ],
            []
        // Report the outcome back to the document that started the workflow.
        | ReportingResult result ->
            Stay, [ toOriginator documentFactory (Document.FinishPublication result) ]
        // Nothing left to send; the saga completes and passivates.
        | Done ->
            StopSaga, []
public override SagaSideEffectResult<PublicationState> ApplySideEffects(
    SagaState<PublicationData, PublicationState> sagaState,
    bool _recovering) =>
    sagaState.State switch
    {
        // Ask the slug to reserve, and declare the wait: re-send Reserve every
        // five seconds, deliver ExpectationExhausted after thirty.
        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)))
        },
        // Report the outcome back to the document that started the workflow.
        PublicationState.ReportingResult state => new()
        {
            Transition = Stay(),
            Commands = [SagaCommands.ToOriginator(
                _documents, new DocumentCommand.FinishPublication(state.Result))]
        },
        // Nothing left to send; the saga completes and passivates.
        PublicationState.Done => new()
        {
            Transition = StopSaga(),
            Commands = []
        },
        _ => new() { Transition = Stay(), Commands = [] }
    };

The C# constructor in Step 4 supplies _documents and _slugs. As in F#, the method receives the recovery flag even though this retry-safe workflow sends the same command during live processing and recovery.

StayExpecting (C#: Stay plus the Expect property) keeps the stored state like Stay while declaring what the wait is for: FCQRS sends Reserve on entry into ReservingSlug, re-sends exactly that command on the schedule while no transition is stored, and past the deadline delivers the ExpectationExhausted handled in Function 1. ReportingResult uses plain Stay; a fuller design would bound that wait the same way. StopSaga completes and passivates the saga. NextState next is the fourth available transition; it persists another state immediately when a step should advance without waiting for an event. This workflow does not need it.

The timeout row buys a bounded wait at a price the domain must acknowledge: exhaustion reports Rejected while the true outcome is unknown. The slug may have reserved successfully with only its answer lost, and a SlugReserved arriving after the escalation is out of order for ReportingResult, so handleEvent ignores it: the reservation stays held by a document that was told its publication failed. A production workflow gives the failure path a compensating command (release the slug) or lets the late answer un-fail the workflow. Write a saga states the general rules.

The state is always stored before its commands are issued:

incoming event
    -> handleEvent
    -> persist StateChangedEvent
    -> applySideEffects
    -> send commands

That ordering is what makes the next action recoverable.

Step 4: declare how the saga starts

StartOn selects the originator event that creates one workflow instance. Originator identifies the aggregate family that produced it and lets toOriginator route back to the exact document.

Motivation: A saga cannot subscribe before it exists. Declaring the exact start event gives FCQRS a safe point to create and subscribe the saga before that event is released.

    let 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 }
public sealed class PublicationSaga
    : Saga<DocumentEvent, PublicationData, PublicationState>
{
    readonly Func<string, IEntityRef<object>> _documents;
    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 static bool StartsOn(object message) =>
        message is Event<DocumentEvent>
            { EventDetails: DocumentEvent.PublicationRequested };

    // HandleEvent and ApplySideEffects are the methods shown in Step 3.
}

The domain publishes PublicationRequested; application code does not create SagaStartingEvent. FCQRS wraps the matched event internally so the new saga remembers its originator, starting version, correlation context, and the event that created it.

The safe-start sequence is:

  1. the document chooses PublicationRequested;
  2. StartOn says the event requires PublicationSaga;
  3. FCQRS creates and subscribes the saga;
  4. the saga stores its starting envelope;
  5. the document event is persisted and published;
  6. handleEvent stores ReservingSlug;
  7. only then does applySideEffects send Reserve.

Without this handshake, the first event could be published before the new saga was listening.

The C# API uses the same two-function split shown beside the F# code. HandleEvent stores progress; ApplySideEffects returns commands and Stay, NextState, or StopSaga. The document and slug aggregates use the HandleCommand and ApplyEvent pattern taught in chapter 1.

Wire the participants and starter

Register both aggregates first, then construct the saga from their factories. The final call installs the start predicate and handshake.

let wire (api: IActor) =
    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 (PublicationSaga.definition documents.Factory slugs.Factory)

    Fcqrs.wireSagaStarters api [ publication ]
    documents
services
    .AddFcqrs(connectionString, "Documents")
    .AddAggregate<PublicationDocumentAggregate>()
    .AddAggregate<SlugAggregate>()
    .AddSaga<PublicationSaga, DocumentEvent, PublicationData, PublicationState>(
        create: sp => new PublicationSaga(
            sp.AggregateFactory<PublicationDocumentAggregate>(),
            sp.AggregateFactory<SlugAggregate>()),
        startOn: PublicationSaga.StartsOn);

How resumption works

Assume the saga has stored ReportingResult Published and sent FinishPublication Published, then the process stops. On restart FCQRS:

  1. loads the saga snapshot when one exists;
  2. replays later state changes;
  3. restores the starting-event context;
  4. subscribes the saga again;
  5. calls applySideEffects for ReportingResult Published with recovering = true.

The saga cannot know whether the earlier result reached the document. It sends the command again. The document's idempotent decision returns the same PublicationFinished event without storing a duplicate if the first command already succeeded.

This is resumability: recover durable progress and re-drive the next safe action. It is not rewinding the other aggregate, and it is not exactly-once delivery.

Motivation: Restarting the workflow from its first command could repeat already completed work. Re-driving only the action implied by the last stored state limits repetition to one retry-safe step.

Run both outcomes

Now run the workflow. The helper below publishes one document and prints the result the saga drives back. Two details from chapter 2 return here: the document is created with CreateOrUpdate before Publish, and the subscription is made before sending so the result cannot slip past. The saga's commands keep your correlation id, so one subscription sees the whole workflow — the final PublicationFinished included.

let buildApi () : IActor =
    let config = ConfigurationBuilder().Build()
    let loggerFactory = LoggerFactory.Create(fun _ -> ())

    let connection =
        Fcqrs.connect FCQRS.Actor.DBType.Sqlite "Data Source=tutorial.db;"

    Fcqrs.actor config loggerFactory (Some connection) "tutorial"

let readModel = ConcurrentDictionary<string, Document.Root>()

let handleProjection (_offset: int64) (message: obj) =
    match message with
    | :? Event<Document.Event> as event ->
        match event.EventDetails with
        | Document.Updated document -> readModel[document.Id.ToString()] <- document
        | _ -> ()
    | _ -> ()

let private isPublicationFinished (message: IMessageWithCID) =
    match message with
    | :? Event<Document.Event> as event ->
        match event.EventDetails with
        | Document.PublicationFinished _ -> true
        | _ -> false
    | _ -> false

let publishAndPrintOutcome
    (documents: AggregateHandle<Document.Command, Document.Event>)
    (subscriptions: FCQRS.Query.ISubscribe)
    (doc: Document.Root)
    slug
    =
    async {
        let id = Fcqrs.aggregateId (doc.Id.ToString())

        // The document must exist before it can be published (chapter 1's command).
        let! _created =
            documents.Send (Fcqrs.newCid ()) id (Document.CreateOrUpdate doc)
                (fun e ->
                    match e with
                    | Document.Updated _ -> true
                    | _ -> false)

        let cid = Fcqrs.newCid ()
        let mutable outcome = None

        // Subscribe BEFORE publishing: the saga's commands keep this CID, so the
        // final result arrives on this same subscription.
        use finished =
            subscriptions.Subscribe(cid, isPublicationFinished, 1, fun message ->
                outcome <- Some message)

        let! _requested =
            documents.Send cid id (Document.Publish slug)
                (fun e ->
                    match e with
                    | Document.PublicationRequested _ -> true
                    | _ -> false)

        do! finished.Task |> Async.AwaitTask

        match outcome with
        | Some(:? Event<Document.Event> as event) ->
            (match event.EventDetails with
             | Document.PublicationFinished(_, slug, result) ->
                 printfn "%s -> %A (%s)" slug result doc.Title.Value
             | _ -> ())
            |> ignore
        | _ -> ()
    }
// The wiring step configured the builder; C# needs no separate buildApi.
var readModel = new ConcurrentDictionary<string, Document>();

void HandleProjection(long _offset, object message)
{
    if (message is Event<DocumentEvent> { EventDetails: DocumentEvent.Updated updated })
        readModel[updated.Document.Id.ToString()] = updated.Document;
}

async Task PublishAndPrintOutcome(
    Handler<DocumentCommand, DocumentEvent> documents,
    ISubscribe subscriptions,
    Document doc,
    string slug)
{
    var id = Values.CreateAggregateId(doc.Id.ToString());

    // The document must exist before it can be published (chapter 1's command).
    await documents(
        e => e is DocumentEvent.Updated,
        Values.NewCID(), id, new DocumentCommand.CreateOrUpdate(doc));

    var cid = Values.NewCID();
    Event<DocumentEvent>? outcome = null;

    // Subscribe BEFORE publishing: the saga's commands keep this CID, so the
    // final result arrives on this same subscription. The filter also records
    // the matching event, because the awaiter's Task carries no payload.
    using var awaiter = subscriptions.SubscribeForFirst(cid, message =>
    {
        if (message is Event<DocumentEvent> { EventDetails: DocumentEvent.PublicationFinished } ev)
        {
            outcome = ev;
            return true;
        }
        return false;
    });

    await documents(
        e => e is DocumentEvent.PublicationRequested,
        cid, id, new DocumentCommand.Publish(slug));

    await awaiter.Task;

    if (outcome is { EventDetails: DocumentEvent.PublicationFinished finished })
        Console.WriteLine($"{finished.Slug} -> {finished.Result} ({doc.Title})");
}
let run () =
    async {
        let api = buildApi ()
        let documents = wire api
        let subscriptions = Fcqrs.projection api (Projection.single 0 handleProjection)

        let docA =
            Document.Root.TryCreate(Guid.NewGuid(), "FCQRS guide", "draft A") |> Result.value

        let docB =
            Document.Root.TryCreate(Guid.NewGuid(), "Competing guide", "draft B") |> Result.value

        do! publishAndPrintOutcome documents subscriptions docA "guides/fcqrs"
        do! publishAndPrintOutcome documents subscriptions docB "guides/fcqrs"
    }

[<EntryPoint>]
let main _ =
    run () |> Async.RunSynchronously
    0
// Top-level statements are the entry point: register the projection, start the
// host from the wiring step, and run both publications.
builder.Services.AddProjection(HandleProjection, lastOffset: 0);

using var host = builder.Build();
await host.StartAsync();

var documents = host.Services.GetRequiredService<Handler<DocumentCommand, DocumentEvent>>();
var subscriptions = host.Services.GetRequiredService<ISubscribe>();

// Result.value in F#; validation cannot fail for these literals.
Document.TryCreate(Guid.NewGuid(), "FCQRS guide", "draft A", out var docA, out _);
Document.TryCreate(Guid.NewGuid(), "Competing guide", "draft B", out var docB, out _);

await PublishAndPrintOutcome(documents, subscriptions, docA, "guides/fcqrs");
await PublishAndPrintOutcome(documents, subscriptions, docB, "guides/fcqrs");

await host.StopAsync();

Publish two documents under the same slug:

dotnet run
# guides/fcqrs -> Published (FCQRS guide)
# guides/fcqrs -> Rejected (Competing guide)

The slug aggregate serializes both reservations and accepts only the first owner. Each publication saga stores its own progress and safely completes the matching document. Run again without deleting tutorial.db and both documents are rejected — the reservation from the first run is durable.

Common mistakes

Continue the learning path

Next, test the state machine and evolve its events. Chapter 4 turns the decisions, folds, retries, and stored contracts from the first three chapters into executable checks.

After chapter 4, use Sagas for deeper treatment of uncertain delivery and compensation, or Write a saga as the compact implementation recipe.

namespace System
namespace System.Collections
namespace System.Collections.Concurrent
namespace Microsoft
namespace Microsoft.Extensions
namespace Microsoft.Extensions.Configuration
namespace Microsoft.Extensions.Logging
namespace FCQRS
module Common from FCQRS
<summary> Contains common types like Events and Commands </summary>
<namespacedoc><summary>Functionality for Write Side.</summary></namespacedoc>
namespace FCQRS.Model
module Data from FCQRS.Model
module FSharp from FCQRS
<summary> Idiomatic-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 -&gt; ...) </summary>
module Slug from addingasaga
val decide: cmd: 'a -> state: 'b -> 'c
val cmd: 'a
val state: 'b
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 failwith: message: string -> 'T
val fold: event: 'a -> state: 'b -> 'c
val event: 'a
val handleEvent: message: obj -> sagaState: 'a -> 'b
val message: obj
type obj = System.Object
val sagaState: 'a
type unit = Unit
type 'T option = Option<'T>
val applySideEffects: documentFactory: 'a -> slugFactory: 'b -> sagaState: 'c -> recovering: 'd -> 'e * 'f
val documentFactory: 'a
val slugFactory: 'b
val sagaState: 'c
val recovering: 'd
type 'T list = List<'T>
val startsOn: event: 'a -> bool
type bool = System.Boolean
type DocumentId = | DocumentId of Guid override ToString: unit -> string static member OfGuid: value: Guid -> DocumentId member Value: Guid
Multiple items
[<Struct>] type Guid = new: b: byte array -> unit + 6 overloads member CompareTo: value: Guid -> int + 1 overload member Equals: g: Guid -> bool + 1 overload member GetHashCode: unit -> int member ToByteArray: unit -> byte array + 1 overload member ToString: unit -> string + 2 overloads member TryFormat: utf8Destination: Span<byte> * bytesWritten: byref<int> * ?format: ReadOnlySpan<char> -> bool + 1 overload member TryWriteBytes: destination: Span<byte> -> bool + 1 overload static member (<) : left: Guid * right: Guid -> bool static member (<=) : left: Guid * right: Guid -> bool ...
<summary>Represents a globally unique identifier (GUID).</summary>

--------------------
Guid ()
Guid(b: byte array) : Guid
Guid(b: ReadOnlySpan<byte>) : Guid
Guid(g: string) : Guid
Guid(b: ReadOnlySpan<byte>, bigEndian: bool) : Guid
Guid(a: int, b: int16, c: int16, d: byte array) : Guid
Guid(a: int, b: int16, c: int16, d: byte, e: byte, f: byte, g: byte, h: byte, i: byte, j: byte, k: byte) : Guid
Guid(a: uint32, b: uint16, c: uint16, d: byte, e: byte, f: byte, g: byte, h: byte, i: byte, j: byte, k: byte) : Guid
val value: Guid
Multiple items
union case DocumentId.DocumentId: Guid -> DocumentId

--------------------
type DocumentId = | DocumentId of Guid override ToString: unit -> string static member OfGuid: value: Guid -> DocumentId member Value: Guid
val this: DocumentId
Guid.ToString() : string
Guid.ToString(format: string) : string
Guid.ToString(format: string, provider: IFormatProvider) : string
type Title = | Title of ShortString static member TryCreate: s: string -> Result<Title,string> member Value: string
type ShortString = private | ShortString of string member Equals: ShortString * IEqualityComparer -> bool override ToString: unit -> string member IsValid: bool static member Value_: (ShortString -> string) * (string -> ShortString -> Result<ShortString,ModelError list>)
<summary> Validated non-blank string up to 255 chars inclusive. </summary>
val s: string
type ValueLens = static member Create: innerValue: 'Inner -> 'Wrapped (requires member Value_) static member CreateAsResult: v: 'a -> Result<'b,'d> (requires member Value_ and member Value_) static member IsValidValue: this: 'Wrapped -> bool (requires member Value_) static member ToString: this: 'Wrapped -> string (requires member Value_) static member TryCreate: innerValue: 'Inner -> Result<'Wrapped,'Error> (requires member Value_) static member Value: this: 'Wrapped -> 'Inner (requires member Value_) + 1 overload
static member ValueLens.TryCreate: innerValue: 'Inner -> Result<'Wrapped,'Error> (requires member Value_)
union case Result.Ok: ResultValue: 'T -> Result<'T,'TError>
val ss: ShortString
Multiple items
union case Title.Title: ShortString -> Title

--------------------
type Title = | Title of ShortString static member TryCreate: s: string -> Result<Title,string> member Value: string
union case Result.Error: ErrorValue: 'TError -> Result<'T,'TError>
val this: Title
val s: ShortString
static member ValueLens.Value: this: 'Wrapped -> 'Inner (requires member Value_)
static member ValueLens.Value: this: 'Wrapped -> 'Inner (requires member Value_)
type Content = | Content of LongString static member TryCreate: s: string -> Result<Content,string> member Value: string
type LongString = private | LongString of string member Equals: LongString * IEqualityComparer -> bool override ToString: unit -> string member IsValid: bool static member Value_: (LongString -> string) * (string -> LongString -> Result<LongString,ModelError list>)
<summary> Represents any string at least 1 chars </summary>
val ss: LongString
Multiple items
union case Content.Content: LongString -> Content

--------------------
type Content = | Content of LongString static member TryCreate: s: string -> Result<Content,string> member Value: string
val this: Content
val s: LongString
module Values from 3-adding-a-saga
val guid: Guid
val title: string
val content: string
static member Title.TryCreate: s: string -> Result<Title,string>
static member Content.TryCreate: s: string -> Result<Content,string>
val t: Title
val c: Content
static member DocumentId.OfGuid: value: Guid -> DocumentId
val e: string
type PublicationResult = | Published | Rejected
type PublicationStatus = | NotRequested | WaitingForSlug of slug: string | Finished of slug: string * PublicationResult
Multiple items
val string: value: 'T -> string

--------------------
type string = String
Multiple items
type State = { Document: Root option Publication: PublicationStatus }

--------------------
type State<'Command,'Event> = { CommandDetails: CommandDetails<'Command,'Event> Sender: IActorRef }
type Root = { Id: DocumentId Title: Title Content: Content } static member TryCreate: guid: Guid * title: string * content: string -> Result<Root,string>
val initial: State
union case Option.None: Option<'T>
union case PublicationStatus.NotRequested: PublicationStatus
Multiple items
type Command = | CreateOrUpdate of Root | Publish of slug: string | FinishPublication of PublicationResult

--------------------
type Command<'CommandDetails> = { CommandDetails: 'CommandDetails CreationDate: DateTime Id: MessageId Sender: AggregateId option CorrelationId: CID Metadata: Map<string,string> } interface IEnvelope interface IMessage interface ISerializable member Equals: Command<'CommandDetails> * IEqualityComparer -> bool override ToString: unit -> string
<summary> Represents a command to be processed by an aggregate actor. &lt;typeparam name="'CommandDetails"&gt;The specific type of the command payload.&lt;/typeparam&gt; </summary>

--------------------
type Command<'Command,'Event> = | Execute of CommandDetails<'Command,'Event>
<summary> Represents the message sent to the internal subscription mechanism. &lt;typeparam name="'Command"&gt;The type of the command payload.&lt;/typeparam&gt; &lt;typeparam name="'Event"&gt;The type of the expected event payload.&lt;/typeparam&gt; </summary>
union case Notify.Publish: Notify
Multiple items
module Event from Microsoft.FSharp.Control

--------------------
type Event = | Updated of Root | PublicationRequested of DocumentId * slug: string | PublicationFinished of DocumentId * slug: string * PublicationResult

--------------------
type Event<'EventDetails> = { EventDetails: 'EventDetails CreationDate: DateTime Id: MessageId Sender: AggregateId option CorrelationId: CID Version: Version Metadata: Map<string,string> } interface IEnvelope interface IMessage interface ISerializable member Equals: Event<'EventDetails> * IEqualityComparer -> bool override ToString: unit -> string member Journaled: bool option
<summary> Represents an event generated by an aggregate actor as a result of processing a command. &lt;typeparam name="'EventDetails"&gt;The specific type of the event payload.&lt;/typeparam&gt; </summary>

--------------------
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<'Delegate,'Args>
val decide: cmd: Command<Command> -> state: State -> EventAction<Event>
val cmd: Command<Command>
val state: State
Command.CommandDetails: Command
<summary> The specific details or payload of the command. </summary>
union case Command.CreateOrUpdate: Root -> Command
val doc: Root
union case Event.Updated: Root -> Event
union case EventAction.PersistEvent: 'T -> EventAction<'T>
<summary> Persist the event to the journal. The actor's state will be updated using the event handler *after* persistence succeeds. </summary>
union case Command.Publish: slug: string -> Command
val slug: string
union case Option.Some: Value: 'T -> Option<'T>
union case Event.PublicationRequested: DocumentId * slug: string -> Event
Root.Id: DocumentId
union case PublicationStatus.WaitingForSlug: slug: string -> PublicationStatus
val currentSlug: string
union case EventAction.DeferEvent: 'T -> EventAction<'T>
<summary> Publish and fold the event in the live actor without storing it or incrementing the persisted version. </summary>
union case Command.FinishPublication: PublicationResult -> Command
val result: PublicationResult
union case Event.PublicationFinished: DocumentId * slug: string * PublicationResult -> Event
union case PublicationStatus.Finished: slug: string * PublicationResult -> PublicationStatus
val current: PublicationResult
union case EventAction.UnhandledEvent: EventAction<'T>
<summary> Indicate that the command or event could not be handled in the current state. </summary>
val fold: event: Event<Event> -> state: State -> State
val event: Event<Event>
Event.EventDetails: Event
<summary> The specific details or payload of the event. </summary>
Multiple items
type State = { ReservedFor: DocumentId option }

--------------------
type State<'Command,'Event> = { CommandDetails: CommandDetails<'Command,'Event> Sender: IActorRef }
Multiple items
type Command = | Reserve of DocumentId

--------------------
type Command<'CommandDetails> = { CommandDetails: 'CommandDetails CreationDate: DateTime Id: MessageId Sender: AggregateId option CorrelationId: CID Metadata: Map<string,string> } interface IEnvelope interface IMessage interface ISerializable member Equals: Command<'CommandDetails> * IEqualityComparer -> bool override ToString: unit -> string
<summary> Represents a command to be processed by an aggregate actor. &lt;typeparam name="'CommandDetails"&gt;The specific type of the command payload.&lt;/typeparam&gt; </summary>

--------------------
type Command<'Command,'Event> = | Execute of CommandDetails<'Command,'Event>
<summary> Represents the message sent to the internal subscription mechanism. &lt;typeparam name="'Command"&gt;The type of the command payload.&lt;/typeparam&gt; &lt;typeparam name="'Event"&gt;The type of the expected event payload.&lt;/typeparam&gt; </summary>
Multiple items
module Event from Microsoft.FSharp.Control

--------------------
type Event = | SlugReserved of DocumentId | SlugUnavailable of DocumentId

--------------------
type Event<'EventDetails> = { EventDetails: 'EventDetails CreationDate: DateTime Id: MessageId Sender: AggregateId option CorrelationId: CID Version: Version Metadata: Map<string,string> } interface IEnvelope interface IMessage interface ISerializable member Equals: Event<'EventDetails> * IEqualityComparer -> bool override ToString: unit -> string member Journaled: bool option
<summary> Represents an event generated by an aggregate actor as a result of processing a command. &lt;typeparam name="'EventDetails"&gt;The specific type of the event payload.&lt;/typeparam&gt; </summary>

--------------------
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<'Delegate,'Args>
State.ReservedFor: DocumentId option
union case Command.Reserve: DocumentId -> Command
val documentId: DocumentId
union case Event.SlugReserved: DocumentId -> Event
val current: DocumentId
union case Event.SlugUnavailable: DocumentId -> Event
Multiple items
type State = | ReservingSlug of DocumentId * string | ReportingResult of PublicationResult | Done

--------------------
type State<'Command,'Event> = { CommandDetails: CommandDetails<'Command,'Event> Sender: IActorRef }
module Document from 3-adding-a-saga
type obj = Object
Multiple items
module Event from Microsoft.FSharp.Control

--------------------
type Event<'EventDetails> = { EventDetails: 'EventDetails CreationDate: DateTime Id: MessageId Sender: AggregateId option CorrelationId: CID Version: Version Metadata: Map<string,string> } interface IEnvelope interface IMessage interface ISerializable member Equals: Event<'EventDetails> * IEqualityComparer -> bool override ToString: unit -> string member Journaled: bool option
<summary> Represents an event generated by an aggregate actor as a result of processing a command. &lt;typeparam name="'EventDetails"&gt;The specific type of the event payload.&lt;/typeparam&gt; </summary>

--------------------
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<'Delegate,'Args>
type Event = | Updated of Root | PublicationRequested of DocumentId * slug: string | PublicationFinished of DocumentId * slug: string * PublicationResult
val event: Event<Document.Event>
Event.EventDetails: Document.Event
<summary> The specific details or payload of the event. </summary>
module Slug from 3-adding-a-saga
type Event = | SlugReserved of DocumentId | SlugUnavailable of DocumentId
val event: Event<Slug.Event>
Event.EventDetails: Slug.Event
<summary> The specific details or payload of the event. </summary>
val handleEvent: message: obj -> sagaState: SagaState<'a,State option> -> EventAction<State>
val sagaState: SagaState<'a,State option>
SagaState.State: State option
<summary> The current state machine state of the saga. </summary>
active recognizer DocumentEvent: obj -> Document.Event option
union case Document.Event.PublicationRequested: DocumentId * slug: string -> Document.Event
union case State.ReservingSlug: DocumentId * string -> State
union case EventAction.StateChangedEvent: 'T -> EventAction<'T>
<summary> Indicate that the state of a saga has changed (used internally by sagas for persistence). </summary>
active recognizer SlugEvent: obj -> Slug.Event option
union case Slug.Event.SlugReserved: DocumentId -> Slug.Event
val expected: DocumentId
union case State.ReportingResult: Document.PublicationResult -> State
union case Document.PublicationResult.Published: Document.PublicationResult
union case Slug.Event.SlugUnavailable: DocumentId -> Slug.Event
union case Document.PublicationResult.Rejected: Document.PublicationResult
type ExpectationExhausted = { StateName: string EnteredAt: DateTime Attempts: int } member Equals: ExpectationExhausted * IEqualityComparer -> bool
<summary> Delivered 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. </summary>
union case Document.Event.PublicationFinished: DocumentId * slug: string * Document.PublicationResult -> Document.Event
val result: Document.PublicationResult
val expected: Document.PublicationResult
union case State.Done: State
val applySideEffects: documentFactory: AggregateFactory -> slugFactory: AggregateFactory -> sagaState: SagaState<'a,State> -> _recovering: 'b -> SagaTransition<'c> * ExecuteCommand list
val documentFactory: AggregateFactory
val slugFactory: AggregateFactory
val sagaState: SagaState<'a,State>
val _recovering: 'b
SagaState.State: State
<summary> The current state machine state of the saga. </summary>
val expecting: deadline: TimeSpan -> retryEvery: RetrySchedule -> resend: ExecuteCommand list -> SagaTransition<'State>
<summary> 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. </summary>
Multiple items
[<Struct>] type TimeSpan = new: hours: int * minutes: int * seconds: int -> unit + 4 overloads member Add: ts: TimeSpan -> TimeSpan member CompareTo: value: obj -> int + 1 overload member Divide: divisor: float -> TimeSpan + 1 overload member Duration: unit -> TimeSpan member Equals: value: obj -> bool + 2 overloads member GetHashCode: unit -> int member Multiply: factor: float -> TimeSpan member Negate: unit -> TimeSpan member Subtract: ts: TimeSpan -> TimeSpan ...
<summary>Represents a time interval.</summary>

--------------------
TimeSpan ()
TimeSpan(ticks: int64) : TimeSpan
TimeSpan(hours: int, minutes: int, seconds: int) : TimeSpan
TimeSpan(days: int, hours: int, minutes: int, seconds: int) : TimeSpan
TimeSpan(days: int, hours: int, minutes: int, seconds: int, milliseconds: int) : TimeSpan
TimeSpan(days: int, hours: int, minutes: int, seconds: int, milliseconds: int, microseconds: int) : TimeSpan
TimeSpan.FromSeconds(seconds: int64) : TimeSpan
TimeSpan.FromSeconds(value: float) : TimeSpan
TimeSpan.FromSeconds(seconds: int64, ?milliseconds: int64, ?microseconds: int64) : TimeSpan
union case RetrySchedule.FixedInterval: TimeSpan -> RetrySchedule
<summary> Re-send at a fixed interval. </summary>
val toAggregate: factory: AggregateFactory -> id: string -> command: obj -> ExecuteCommand
<summary> Send a command to a specific aggregate instance by id (cross-aggregate). </summary>
union case Slug.Command.Reserve: DocumentId -> Slug.Command
union case SagaTransition.Stay: SagaTransition<'State>
<summary> The saga should stay in current state without changes </summary>
val toOriginator: factory: AggregateFactory -> command: obj -> ExecuteCommand
<summary> Send a command back to the saga's originator aggregate. </summary>
union case Document.Command.FinishPublication: Document.PublicationResult -> Document.Command
union case SagaTransition.StopSaga: SagaTransition<'State>
<summary> The saga should stop and terminate </summary>
val startsOn: event: Event<Document.Event> -> bool
val definition: documentFactory: AggregateFactory -> slugFactory: AggregateFactory -> Saga<unit,State,Document.Event>
union case TargetName.Name: string -> TargetName
<summary> Identify the target by its string name (entity ID). </summary>
union case TargetName.Originator: TargetName
<summary> Identify the target as the originator actor of the current saga process. </summary>
union case SnapshotPolicy.Default: SnapshotPolicy
<summary> Use the global config (config:akka:persistence:snapshot-version-count), or 30. </summary>
val wire: api: IActor -> AggregateHandle<Document.Command,Document.Event>
val api: IActor
type IActor = abstract CreateCommandSubscription: (string -> IEntityRef<obj>) -> CID -> AggregateId -> 'b -> ('c -> bool) -> Map<string,string> option -> Async<Event<'c>> abstract InitializeActor: 'a -> string -> (Command<'c> -> 'a -> EventAction<'b>) -> (Event<'b> -> 'a -> 'a) -> SnapshotPolicy -> EntityFac<obj> abstract InitializeActorWithRunner: 'a -> string -> (Command<'c> -> 'a -> EventAction<'b>) -> (Event<'b> -> 'a -> 'a) -> SnapshotPolicy -> (obj -> Async<obj>) option -> EntityFac<obj> abstract InitializeSaga: SagaState<'SagaState,'State> -> (obj -> SagaState<'SagaState,'State> -> EventAction<'State>) -> (SagaState<'SagaState,'State> -> SagaStartingEvent<Event<'c>> option -> bool -> SagaTransition<'State> * ExecuteCommand list) -> (SagaState<'SagaState,'State> -> SagaState<'SagaState,'State>) -> string -> SnapshotPolicy -> EntityFac<obj> abstract InitializeSagaStarter: (obj -> ((string -> IEntityRef<obj>) * PrefixConversion * obj) list) -> unit + 1 overload abstract Stop: unit -> Task abstract SubscribeForCommand: Command<'a,'b> -> Async<Event<'b>> abstract Configuration: IConfiguration abstract LoggerFactory: ILoggerFactory abstract Materializer: ActorMaterializer ...
<summary> Defines 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. </summary>
val documents: AggregateHandle<Document.Command,Document.Event>
module Fcqrs from FCQRS.FSharp
val aggregate: api: IActor -> def: Aggregate<'State,'Command,'Event> -> AggregateHandle<'Command,'Event>
<summary> Register an aggregate and return its typed handle. Calling this IS the registration (it initializes the sharding region). </summary>
val initial: Document.State
val decide: cmd: Command<Document.Command> -> state: Document.State -> EventAction<Document.Event>
val fold: event: Event<Document.Event> -> state: Document.State -> Document.State
val slugs: AggregateHandle<Slug.Command,Slug.Event>
val initial: Slug.State
val decide: cmd: Command<Slug.Command> -> state: Slug.State -> EventAction<Slug.Event>
val fold: event: Event<Slug.Event> -> state: Slug.State -> Slug.State
val publication: SagaHandle
val saga: api: IActor -> def: Saga<'Data,'State,'OriginatorEvent> -> SagaHandle
<summary> Register 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`. </summary>
module PublicationSaga from 3-adding-a-saga
val definition: documentFactory: AggregateFactory -> slugFactory: AggregateFactory -> Saga<unit,PublicationSaga.State,Document.Event>
AggregateHandle.Factory: AggregateFactory
<summary> Entity-ref factory (DEFAULT_SHARD applied). Hand this to a saga to target it. </summary>
val wireSagaStarters: api: IActor -> sagas: SagaHandle list -> unit
<summary> Wire every registered saga into one saga-starter (or the empty starter if none). Call after the aggregates + sagas are registered. </summary>
val buildApi: unit -> IActor
val config: IConfigurationRoot
Multiple items
type ConfigurationBuilder = interface IConfigurationBuilder new: unit -> unit member Add: source: IConfigurationSource -> IConfigurationBuilder member Build: unit -> IConfigurationRoot member Properties: IDictionary<string,obj> member Sources: IList<IConfigurationSource>
<summary> Builds key/value-based configuration settings for use in an application. </summary>

--------------------
ConfigurationBuilder() : ConfigurationBuilder
val loggerFactory: ILoggerFactory
Multiple items
type LoggerFactory = interface ILoggerFactory interface IDisposable new: unit -> unit + 4 overloads member AddProvider: provider: ILoggerProvider -> unit member CreateLogger: categoryName: string -> ILogger member Dispose: unit -> unit static member Create: configure: Action<ILoggingBuilder> -> ILoggerFactory
<summary>Produces instances of <see cref="T:Microsoft.Extensions.Logging.ILogger" /> classes based on the given providers.</summary>

--------------------
LoggerFactory() : LoggerFactory
LoggerFactory(providers: Collections.Generic.IEnumerable<ILoggerProvider>) : LoggerFactory
LoggerFactory(providers: Collections.Generic.IEnumerable<ILoggerProvider>, filterOptions: LoggerFilterOptions) : LoggerFactory
LoggerFactory(providers: Collections.Generic.IEnumerable<ILoggerProvider>, filterOption: Extensions.Options.IOptionsMonitor<LoggerFilterOptions>) : LoggerFactory
LoggerFactory(providers: Collections.Generic.IEnumerable<ILoggerProvider>, filterOption: Extensions.Options.IOptionsMonitor<LoggerFilterOptions>, ?options: Extensions.Options.IOptions<LoggerFactoryOptions>) : LoggerFactory
LoggerFactory.Create(configure: Action<ILoggingBuilder>) : ILoggerFactory
val connection: FCQRS.Actor.Connection
val connect: dbType: FCQRS.Actor.DBType -> connectionString: string -> FCQRS.Actor.Connection
<summary> Build a SQLite/etc. Connection from a raw connection string (ShortString hidden). </summary>
module Actor from FCQRS
type DBType = | Sqlite | SqlServer2012 | SqlServer2014 | SqlServer2016 | SqlServer2017 | SqlServer2019 | SqlServer2022 | PostgreSQL | PostgreSQL15 | MySql ... member Equals: DBType * IEqualityComparer -> bool member IsDB2: bool member IsFirebird: bool member IsMySql: bool member IsOracle: bool member IsPostgreSQL: bool member IsPostgreSQL15: bool member IsSqlServer2012: bool member IsSqlServer2014: bool member IsSqlServer2016: bool ...
<summary> Represents the type of database connection </summary>
union case FCQRS.Actor.DBType.Sqlite: FCQRS.Actor.DBType
<summary> SQLite using Microsoft.Data.Sqlite provider </summary>
val actor: config: IConfiguration -> loggerFactory: ILoggerFactory -> connection: FCQRS.Actor.Connection option -> clusterName: string -> IActor
<summary> Create the actor system from plain values (cluster name as a string). </summary>
val readModel: ConcurrentDictionary<string,Document.Root>
Multiple items
type ConcurrentDictionary<'TKey,'TValue> = interface ICollection<KeyValuePair<'TKey,'TValue>> interface IEnumerable<KeyValuePair<'TKey,'TValue>> interface IEnumerable interface IDictionary<'TKey,'TValue> interface IReadOnlyCollection<KeyValuePair<'TKey,'TValue>> interface IReadOnlyDictionary<'TKey,'TValue> interface ICollection interface IDictionary new: unit -> unit + 6 overloads member AddOrUpdate: key: 'TKey * addValueFactory: Func<'TKey,'TValue> * updateValueFactory: Func<'TKey,'TValue,'TValue> -> 'TValue + 2 overloads ...
<summary>Represents a thread-safe collection of key/value pairs that can be accessed by multiple threads concurrently.</summary>
<typeparam name="TKey">The type of the keys in the dictionary.</typeparam>
<typeparam name="TValue">The type of the values in the dictionary.</typeparam>


--------------------
ConcurrentDictionary() : ConcurrentDictionary<'TKey,'TValue>
ConcurrentDictionary(collection: Collections.Generic.IEnumerable<Collections.Generic.KeyValuePair<'TKey,'TValue>>) : ConcurrentDictionary<'TKey,'TValue>
ConcurrentDictionary(comparer: Collections.Generic.IEqualityComparer<'TKey>) : ConcurrentDictionary<'TKey,'TValue>
ConcurrentDictionary(collection: Collections.Generic.IEnumerable<Collections.Generic.KeyValuePair<'TKey,'TValue>>, comparer: Collections.Generic.IEqualityComparer<'TKey>) : ConcurrentDictionary<'TKey,'TValue>
ConcurrentDictionary(concurrencyLevel: int, capacity: int) : ConcurrentDictionary<'TKey,'TValue>
ConcurrentDictionary(concurrencyLevel: int, collection: Collections.Generic.IEnumerable<Collections.Generic.KeyValuePair<'TKey,'TValue>>, comparer: Collections.Generic.IEqualityComparer<'TKey>) : ConcurrentDictionary<'TKey,'TValue>
ConcurrentDictionary(concurrencyLevel: int, capacity: int, comparer: Collections.Generic.IEqualityComparer<'TKey>) : ConcurrentDictionary<'TKey,'TValue>
val handleProjection: _offset: int64 -> message: obj -> unit
val _offset: int64
Multiple items
val int64: value: 'T -> int64 (requires member op_Explicit)

--------------------
type int64 = Int64

--------------------
type int64<'Measure> = int64
union case Document.Event.Updated: Document.Root -> Document.Event
val document: Document.Root
Document.Root.Id: Values.DocumentId
override Values.DocumentId.ToString: unit -> string
val private isPublicationFinished: message: IMessageWithCID -> bool
val message: IMessageWithCID
type IMessageWithCID = abstract CID: CID
<summary> Interface for messages that carry a Correlation ID (CID). </summary>
union case Document.Event.PublicationFinished: Values.DocumentId * slug: string * Document.PublicationResult -> Document.Event
val publishAndPrintOutcome: documents: AggregateHandle<Document.Command,Document.Event> -> subscriptions: FCQRS.Query.ISubscribe -> doc: Document.Root -> slug: string -> Async<unit>
type AggregateHandle<'Command,'Event> = { Factory: AggregateFactory Send: (CID -> AggregateId -> 'Command -> ('Event -> bool) -> Async<Event<'Event>>) }
<summary> What you get back after registering an aggregate. </summary>
type Command = | CreateOrUpdate of Root | Publish of slug: string | FinishPublication of PublicationResult
val subscriptions: FCQRS.Query.ISubscribe
module Query from FCQRS
Multiple items
type ISubscribe<'TDataEvent (requires 'TDataEvent :> IMessageWithCID)> = abstract Subscribe: callback: ('TDataEvent -> unit) * ?cancellationToken: CancellationToken -> IDisposable + 3 overloads

--------------------
type ISubscribe = inherit ISubscribe<IMessageWithCID>
<summary> The canonical subscription stream: a non-generic shorthand for ISubscribe&amp;lt;IMessageWithCID&amp;gt; — the type every FCQRS projection / read-your-writes subscription actually uses (cf. IEnumerable vs IEnumerable&amp;lt;T&amp;gt;). Lets consumers write ISubscribe instead of the closed generic, and inject it by that name. </summary>
val doc: Document.Root
val async: AsyncBuilder
val id: AggregateId
val aggregateId: s: string -> AggregateId
<summary> An aggregate id from a string (e.g. a document/user key). Any non-blank id works: the shard names entity actors Uri.EscapeDataString(entityId), so characters Akka actor names would reject directly (spaces, %) are escaped before they reach an actor path. </summary>
val _created: Event<Document.Event>
AggregateHandle.Send: CID -> AggregateId -> Document.Command -> (Document.Event -> bool) -> Async<Event<Document.Event>>
<summary> Send a command and await the first matching aggregate event. </summary>
val newCid: unit -> CID
<summary> A fresh correlation id (UUID v7). </summary>
union case Document.Command.CreateOrUpdate: Document.Root -> Document.Command
val e: Document.Event
val cid: CID
val mutable outcome: IMessageWithCID option
val finished: FCQRS.Query.IAwaitableDisposable
abstract FCQRS.Query.ISubscribe.Subscribe: callback: ('TDataEvent -> unit) * ?cancellationToken: Threading.CancellationToken -> IDisposable
abstract FCQRS.Query.ISubscribe.Subscribe: cid: CID * take: int * ?callback: ('TDataEvent -> unit) * ?cancellationToken: Threading.CancellationToken -> FCQRS.Query.IAwaitableDisposable
abstract FCQRS.Query.ISubscribe.Subscribe: filter: ('TDataEvent -> bool) * take: int * ?callback: ('TDataEvent -> unit) * ?cancellationToken: Threading.CancellationToken -> FCQRS.Query.IAwaitableDisposable
abstract FCQRS.Query.ISubscribe.Subscribe: cid: CID * filter: ('TDataEvent -> bool) * take: int * ?callback: ('TDataEvent -> unit) * ?cancellationToken: Threading.CancellationToken -> FCQRS.Query.IAwaitableDisposable
val _requested: Event<Document.Event>
union case Document.Command.Publish: slug: string -> Document.Command
union case Document.Event.PublicationRequested: Values.DocumentId * slug: string -> Document.Event
property FCQRS.Query.IAwaitable.Task: Threading.Tasks.Task with get
Multiple items
type Async = static member AsBeginEnd: computation: ('Arg -> Async<'T>) -> ('Arg * AsyncCallback * obj -> IAsyncResult) * (IAsyncResult -> 'T) * (IAsyncResult -> unit) static member AwaitEvent: event: IEvent<'Del,'T> * ?cancelAction: (unit -> unit) -> Async<'T> (requires delegate and 'Del :> Delegate) static member AwaitIAsyncResult: iar: IAsyncResult * ?millisecondsTimeout: int -> Async<bool> static member AwaitTask: task: Task<'T> -> Async<'T> + 1 overload static member AwaitWaitHandle: waitHandle: WaitHandle * ?millisecondsTimeout: int -> Async<bool> static member CancelDefaultToken: unit -> unit static member Catch: computation: Async<'T> -> Async<Choice<'T,exn>> static member Choice: computations: Async<'T option> seq -> Async<'T option> static member FromBeginEnd: beginAction: (AsyncCallback * obj -> IAsyncResult) * endAction: (IAsyncResult -> 'T) * ?cancelAction: (unit -> unit) -> Async<'T> + 3 overloads static member FromContinuations: callback: (('T -> unit) * (exn -> unit) * (OperationCanceledException -> unit) -> unit) -> Async<'T> ...

--------------------
type Async<'T>
static member Async.AwaitTask: task: Threading.Tasks.Task -> Async<unit>
static member Async.AwaitTask: task: Threading.Tasks.Task<'T> -> Async<'T>
val printfn: format: Printf.TextWriterFormat<'T> -> 'T
Document.Root.Title: Values.Title
property Values.Title.Value: string with get
val ignore: value: 'T -> unit
Multiple items
module Result from Microsoft.FSharp.Core

--------------------
[<Struct>] type Result<'T,'TError> = | Ok of ResultValue: 'T | Error of ErrorValue: 'TError
Multiple items
type EntryPointAttribute = inherit Attribute new: unit -> EntryPointAttribute

--------------------
new: unit -> EntryPointAttribute
static member Async.RunSynchronously: computation: Async<'T> * ?timeout: int * ?cancellationToken: System.Threading.CancellationToken -> 'T

Type something to start searching.