Dispatch a best-effort async effect
Use RunAsync when a command needs a short asynchronous result and losing that in-flight work during a
restart is acceptable. Examples include a cache lookup, optional enrichment, or a suggestion that the
caller can request again.
Use a saga when the work must resume after a restart, needs durable retries, or crosses aggregate boundaries as a business process.
| Requirement | RunAsync |
Saga |
|---|---|---|
decide returns an effect description |
yes | no |
| In-flight intent is persisted | no | yes |
| Result returns as a command | yes | yes |
| Survives process stop or shard movement | no | yes |
| Suitable for required external business action | no | yes, with an idempotent handler |
Motivation:
RunAsynckeeps the decision function pure without pretending the in-flight work is durable. Choose it only when repeating the original request is an acceptable recovery strategy.
decide returns data describing the effect, not a closure that performs it. A separately registered
runner executes the description and returns a command.
The shape
A note aggregate accepts Summarize, then records either a summary or an unavailable result. The effect
description contains the input required by the runner:
Shared setup
open System
open FCQRS.Model.Data
open FCQRS.Common
open FCQRS.FSharp
type NoteCommand = Summarize | RecordSummary of string | GiveUp
type NoteEvent = SummaryRecorded of string | SummaryUnavailable
type NoteState = { Body: string; Summary: string option }
module Note =
let initial = { Body = "A note to summarize"; Summary = None }
let fold (event: Event<NoteEvent>) state =
match event.EventDetails with
| SummaryRecorded summary -> { state with Summary = Some summary }
| SummaryUnavailable -> state
type ISummarizer =
abstract Summarize: string -> Async<string>
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 -> ...)
Fcqrs_450-how-to_011-dispatch-async-effects.md_page.NoteCommandSummarizeRecordSummarystringAn abbreviation for the CLI type . Basic Types
GiveUpFcqrs_450-how-to_011-dispatch-async-effects.md_page.NoteEventSummaryRecordedSummaryUnavailableFcqrs_450-how-to_011-dispatch-async-effects.md_page.NoteStateBody: stringSummary: string 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
Fcqrs_450-how-to_011-dispatch-async-effects.md_page.Noteinitial: NoteStateNoneThe representation of "No value"
fold: Event<NoteEvent> -> NoteState -> NoteStateevent: Event<NoteEvent>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>
state: NoteStateEventDetails: 'EventDetailsThe specific details or payload of the event.
summary: stringFcqrs_450-how-to_011-dispatch-async-effects.md_page.ISummarizerSummarize: ISummarizer -> string -> Async<string>Microsoft.FSharp.Control.FSharpAsync`1An asynchronous computation, which, when run, will eventually produce a value of type T, or else raises an exception. This type has no members. Asynchronous computations are normally specified either by using an async expression or the static methods in the type. See also F# Language Guide - Async Workflows. Library functionality for asynchronous programming, events and agents. See also Asynchronous Programming, Events and Lazy Expressions in the F# Language Guide. Async Programming
type NoteEffect = SummarizeText of string // the effect, described as data
let decide (cmd: Command<NoteCommand>) state =
match cmd.CommandDetails with
| Summarize -> dispatch (SummarizeText state.Body) // pure: just a description
| RecordSummary s -> SummaryRecorded s |> PersistEvent
| GiveUp -> SummaryUnavailable |> PersistEvent
Fcqrs_450-how-to_011-dispatch-async-effects.md_page.NoteEffectSummarizeTextstringAn abbreviation for the CLI type . Basic Types
decide: Command<NoteCommand> -> NoteState -> EventAction<NoteEvent>cmd: Command<NoteCommand>FCQRS.Common.Command`1Represents a command to be processed by an aggregate actor. <typeparam name="'CommandDetails">The specific type of the command payload.</typeparam>
Fcqrs_450-how-to_011-dispatch-async-effects.md_page.NoteCommandstate: NoteStateCommandDetails: 'CommandDetailsThe specific details or payload of the command.
Summarizedispatch: 'description -> EventAction<'event>Dispatch an async side effect (a "mini saga" without persistence) by its DATA description. `decide` stays pure and inspectable; `decide cmd state = dispatch (ClusterThemes texts)` holds by structural equality, and the oracle lives in the runner registered at `Fcqrs.aggregateWithEffects`. EPHEMERAL: the in-flight work is not journaled; use a saga when the result must survive a crash. See `EventAction.RunAsync`.
Body: stringRecordSummarys: stringSummaryRecorded(|>): '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.
GiveUpSummaryUnavailablepublic abstract record NoteEffect
{
public sealed record SummarizeText(string Text) : NoteEffect;
}
EventAction<NoteEvent> Decide(Command<NoteCommand> cmd, NoteState state) =>
cmd.CommandDetails switch
{
NoteCommand.Summarize =>
EventActions.Dispatch<NoteEvent>(new NoteEffect.SummarizeText(state.Body)),
NoteCommand.RecordSummary result =>
EventActions.Persist<NoteEvent>(new NoteEvent.SummaryRecorded(result.Summary)),
NoteCommand.GiveUp =>
EventActions.Persist<NoteEvent>(new NoteEvent.SummaryUnavailable()),
_ => EventActions.Ignore<NoteEvent>()
};
No service client appears in decide, so the returned action can be compared directly in a unit test.
Register the runner
The runner maps every outcome to a command. Catch service failures and timeouts at this boundary:
Shared setup
let register (api: IActor) (ai: ISummarizer) =
register: IActor -> ISummarizer -> AggregateHandle<NoteCommand,NoteEvent>api: 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.
ai: ISummarizerFcqrs_450-how-to_011-dispatch-async-effects.md_page.ISummarizer let notes =
Fcqrs.aggregateWithEffects api
{ Name = "Note"; Initial = Note.initial; Decide = decide; Fold = fold
Snapshots = Default; Passivation = PassivationPolicy.Default }
(fun (SummarizeText text) -> async {
try
let! summary = ai.Summarize text
return RecordSummary summary
with _ ->
return GiveUp })
notes: AggregateHandle<NoteCommand,NoteEvent>FCQRS.FSharp.FcqrsaggregateWithEffects: IActor -> Aggregate<'State,'Command,'Event> -> ('description -> Async<'Command>) -> AggregateHandle<'Command,'Event>Register an aggregate whose `decide` uses `dispatch` (the RunAsync effect), supplying the runner that turns an effect DESCRIPTION into a command sent back to the aggregate. `decide` stays pure; the oracle or side effect lives only here. The runner MUST be total (wrap it with `total` so an oracle error becomes a command, never an escaping exception). See `EventAction.RunAsync` for the ephemeral contract.
api: IActorName: stringInitial: 'StateFcqrs_450-how-to_011-dispatch-async-effects.md_page.Noteinitial: NoteStateDecide: Command<'Command> -> 'State -> EventAction<'Event>handleCommand (decide): command + current state -> what to do.
decide: Command<NoteCommand> -> NoteState -> EventAction<NoteEvent>Fold: Event<'Event> -> 'State -> 'StateapplyEvent (fold): event + current state -> next state (pure).
fold: Event<NoteEvent> -> NoteState -> NoteStateSnapshots: 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.
_arg1: NoteEffectasync: AsyncBuilderBuilds an asynchronous workflow using computation expression syntax. let sleepExample() = async { printfn "sleeping" do! Async.Sleep 10 printfn "waking up" return 6 } sleepExample() |> Async.RunSynchronously
summary: stringai: ISummarizerSummarize: string -> Async<string>text: stringRecordSummaryGiveUpvar notes = ActorWiring.InitAggregateWithEffects(
actor,
NoteState.Initial,
"Note",
Decide,
Fold,
runner: async description =>
{
var effect = (NoteEffect.SummarizeText)description;
try
{
var summary = await ai.Summarize(effect.Text);
return (object)new NoteCommand.RecordSummary(summary);
}
catch
{
return new NoteCommand.GiveUp();
}
},
SnapshotPolicy.Default);
The runner executes away from the aggregate mailbox, so the aggregate can process other commands while
the call is in flight. FCQRS sends the result command back to the same aggregate with the original
correlation id. The result re-enters decide against the state that exists when it arrives, not the
state that existed when the request began.
Several effects can complete out of order. Include a request id or expected state in the description and result command when an older result must not overwrite newer work.
With Send at an expected version, FCQRS checks the expected version before starting the effect and again when its result command returns. A result that finds a different version conflicts before its decision function runs. The check does not undo external work that the runner has already performed.
Fcqrs.total (fun _exception -> GiveUp) (async { ... }) provides the same exception-to-command mapping.
Test it without Akka.NET
Because the effect is data, decide is testable like any other decision — here with the command
envelope helper from Test your domain:
Shared setup
notes
open Expecto
let command payload = FCQRS.CSharp.TestEnvelope.Command(payload)
let state = Note.initial
notes: AggregateHandle<NoteCommand,NoteEvent>Expectocommand: 'a -> Command<'a>payload: 'aFCQRSCommand: 'T -> Command<'T>Wrap a command payload in a Command envelope using the system clock.
FCQRS.CSharpC# interoperability helpers for FCQRS Provides simpler APIs for consuming FCQRS from C#
FCQRS.CSharp.TestEnvelopeC#-friendly builders for the Command/Event envelopes that the pure handleCommand/applyEvent functions expect. Intended for unit tests: the envelope's plumbing fields (a fresh MessageId/CID, a UTC timestamp, no sender, empty metadata) are filled in for you, so a test supplies only the payload and, for events, the aggregate version. The framework builds these envelopes itself at runtime; tests are the one place you build them by hand.
state: NoteStateFcqrs_450-how-to_011-dispatch-async-effects.md_page.Noteinitial: NoteStateExpect.equal
(decide (command Summarize) state)
(dispatch (SummarizeText state.Body))
"Summarize dispatches a summarization effect"
Expecto.ExpectA module for specifying what you expect from the values generated by your tests.
equal: 'a -> 'a -> string -> unitExpects the two values to equal each other.
decide: Command<NoteCommand> -> NoteState -> EventAction<NoteEvent>command: 'a -> Command<'a>Summarizestate: NoteStatedispatch: 'description -> EventAction<'event>Dispatch an async side effect (a "mini saga" without persistence) by its DATA description. `decide` stays pure and inspectable; `decide cmd state = dispatch (ClusterThemes texts)` holds by structural equality, and the oracle lives in the runner registered at `Fcqrs.aggregateWithEffects`. EPHEMERAL: the in-flight work is not journaled; use a saga when the result must survive a crash. See `EventAction.RunAsync`.
SummarizeTextBody: stringvar action = Decide(
TestEnvelope.Command<NoteCommand>(new NoteCommand.Summarize()),
state);
Assert.Equal(
EventActions.Dispatch<NoteEvent>(new NoteEffect.SummarizeText(state.Body)),
action);
Failure contract
- The work is ephemeral. A process stop, actor restart, or shard move loses the in-flight operation. FCQRS does not reissue it.
- The runner must be total. An escaping exception terminates the process because FCQRS cannot turn an unknown runner failure into a valid domain command. Model timeout, rejection, and retry exhaustion as explicit result commands.
- The result may be stale. Validate the result command against current aggregate state before persisting it.
Observability
FCQRS creates a Dispatch:<CaseName> span for the runner and parents it to the originating command's
trace. The result appears as a later command span such as Command:RecordSummary or Command:GiveUp.
See Observe your system.
The runner returns the command boxed as object. Every path returns a command that the
aggregate understands; an exception must not escape the runner.