Header menu logo FCQRS

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: RunAsync keeps 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:

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
public 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:

let notes =
    Fcqrs.aggregateWithEffects api
        { Name = "Note"; Initial = Note.initial; Decide = decide; Fold = fold; Snapshots = Default }
        (fun (SummarizeText text) -> async {
            try
                let! summary = ai.Summarize text
                return RecordSummary summary
            with _ ->
                return GiveUp })
var 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.

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:

Expect.equal
    (decide (command Summarize) state)
    (dispatch (SummarizeText state.Body))
    "Summarize dispatches a summarization effect"
var action = Decide(
    TestEnvelope.Command<NoteCommand>(new NoteCommand.Summarize()),
    state);

Assert.Equal(
    EventActions.Dispatch<NoteEvent>(new NoteEffect.SummarizeText(state.Body)),
    action);

Failure contract

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.

type NoteEffect = | SummarizeText of string
Multiple items
val string: value: 'T -> string

--------------------
type string = System.String
val decide: cmd: 'a -> state: 'b -> 'c
val cmd: 'a
val state: 'b
val Summarize: obj
union case NoteEffect.SummarizeText: string -> NoteEffect
val s: 'a
val GiveUp: obj
val async: AsyncBuilder

Type something to start searching.