Send at an expected version
A teller's screen shows Alice's account at version 7, and the teller decides to withdraw 60 based
on what it shows. The withdrawal is sent with that version. If a transfer has already advanced the
account to version 8, FCQRS rejects the stale command before the aggregate's decision function runs.
Use Fcqrs.sendIfVersion in F# or SendIfVersionAsync in C#, available in FCQRS 6.5.0 or later.
The examples use the account from the tutorial's withdraw money
step:
Shared setup
module Account =
open FCQRS.Common
// What a caller can ask an account to do.
type AccountCommand =
| Open of owner: string
| Deposit of amount: decimal
| Withdraw of amount: decimal
// What the account replies. Rejected is a reply only: it is never stored.
type AccountEvent =
| Opened of owner: string
| Deposited of amount: decimal
| Withdrawn of amount: decimal
| Rejected of reason: string
// What the account knows now, rebuilt from its events.
type AccountState = { Owner: string option; Balance: decimal }
// The state before the account's first event.
let initial = { Owner = None; Balance = 0m }
// Chooses what to do with a command, based on the current state.
let decide (command: Command<AccountCommand>) (state: AccountState) =
match command.CommandDetails, state.Owner with
| Open _, Some _ -> DeferEvent(Rejected "The account is already open")
| Open owner, None -> PersistEvent(Opened owner)
| _, None -> DeferEvent(Rejected "The account is not open")
| (Deposit amount | Withdraw amount), _ when amount <= 0m ->
DeferEvent(Rejected "The amount must be positive")
| Deposit amount, _ -> PersistEvent(Deposited amount)
| Withdraw amount, _ when amount > state.Balance ->
DeferEvent(Rejected $"Insufficient funds: {state.Balance} available")
| Withdraw amount, _ -> PersistEvent(Withdrawn amount)
// Applies one event to the state. A rejection changes nothing.
let fold (event: Event<AccountEvent>) (state: AccountState) =
match event.EventDetails with
| Opened owner -> { state with Owner = Some owner }
| Deposited amount -> { state with Balance = state.Balance + amount }
| Withdrawn amount -> { state with Balance = state.Balance - amount }
| Rejected _ -> state
open FCQRS.Common
open FCQRS.FSharp
open Account
Fcqrs_450-how-to_003-send-if-version.md_page.AccountFCQRSFCQRS.CommonContains common types like Events and Commands Functionality for Write Side.
Fcqrs_450-how-to_003-send-if-version.md_page.Account.AccountCommandOpenowner: stringstringAn abbreviation for the CLI type . Basic Types
Depositamount: decimaldecimalAn abbreviation for the CLI type . Basic Types
WithdrawFcqrs_450-how-to_003-send-if-version.md_page.Account.AccountEventOpenedDepositedWithdrawnRejectedreason: stringFcqrs_450-how-to_003-send-if-version.md_page.Account.AccountStateOwner: 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
Balance: decimalinitial: AccountStateNoneThe representation of "No value"
decide: Command<AccountCommand> -> AccountState -> EventAction<AccountEvent>command: Command<AccountCommand>FCQRS.Common.Command`1Represents a command to be processed by an aggregate actor. <typeparam name="'CommandDetails">The specific type of the command payload.</typeparam>
state: AccountStateCommandDetails: 'CommandDetailsThe specific details or payload of the command.
SomeThe representation of "Value of type 'T" The input value. An option representing the value.
DeferEventPublish and fold the event in the live actor without storing it or incrementing the persisted version. A deferred event does not start a saga; running sagas still receive it.
PersistEventPersist the event to the journal. The actor's state will be updated using the event handler *after* persistence succeeds.
(<=): 'T -> 'T -> boolStructural less-than-or-equal comparison The first parameter. The second parameter. The result of the comparison. 5 <= 1 // Evaluates to false 5 <= 5 // Evaluates to true [1; 5] <= [1; 6] // Evaluates to true
(>): 'T -> 'T -> boolStructural greater-than The first parameter. The second parameter. The result of the comparison. 5 > 1 // Evaluates to true 5 > 5 // Evaluates to false (1, "a") > (1, "z") // Evaluates to false
fold: Event<AccountEvent> -> AccountState -> AccountStateevent: Event<AccountEvent>FCQRS.Common.Event`1Represents an event generated by an aggregate actor as a result of processing a command. <typeparam name="'EventDetails">The specific type of the event payload.</typeparam>
EventDetails: 'EventDetailsThe specific details or payload of the event.
FCQRS.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 / Projections.startTracked / 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 -> ...)
open FCQRS.Common
open FCQRS.FSharp
open Account
let withdrawAtVersion api accounts expectedVersion accountId amount =
Fcqrs.sendIfVersion api accounts expectedVersion
(Fcqrs.newCid ()) (Fcqrs.aggregateId accountId) (Withdraw amount)
(function
| Withdrawn _ | Rejected _ -> true
| _ -> false)
FCQRSFCQRS.CommonContains common types like Events and Commands Functionality for Write Side.
FCQRS.FSharpIdiomatic-F# functional facade for FCQRS. Gives F# consumers the same one-call ergonomics the C# host-builder (HostExtensions.fs) gives C#, but with F# idioms: records-of-functions for the definitions, typed handles for the results, an explicit wiring pipeline, and plain helpers for saga side effects. It is a *pure addition* that wraps only the existing primitives (IActor.InitializeActor / SagaBuilder.initSimple / Projections.startTracked / 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_003-send-if-version.md_page.AccountwithdrawAtVersion: IActor -> AggregateHandle<AccountCommand,AccountEvent> -> int64 -> string -> decimal -> Async<Event<AccountEvent>>api: IActoraccounts: AggregateHandle<AccountCommand,AccountEvent>expectedVersion: int64accountId: stringamount: decimalFCQRS.FSharp.FcqrssendIfVersion: IActor -> AggregateHandle<'Command,'Event> -> int64 -> FCQRS.Model.Data.CID -> FCQRS.Model.Data.AggregateId -> 'Command -> ('Event -> bool) -> Async<Event<'Event>>Send only if the aggregate's persisted version equals expectedVersion (initially zero). A mismatch raises AggregateVersionConflictException before the domain handler or filter runs. The check and handler run in the same actor turn. Deferred replies do not advance the version; a persisted batch advances it once per event. Stashed commands and RunAsync result commands recheck the original expected version. This is not command deduplication or a projection wait. Cancellation or timeout after dispatch does not undo a write. A caller-built PublishEvent reply must retain the incoming command's Id and CorrelationId.
newCid: unit -> FCQRS.Model.Data.CIDA fresh correlation id (UUID v7).
aggregateId: string -> FCQRS.Model.Data.AggregateIdAn 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.
WithdrawWithdrawnRejectedusing FCQRS;
using static FCQRS.Common;
using static FCQRS.CSharp;
using static FCQRS.CSharp.ActorWiring;
public sealed class Teller(
FcqrsRuntime runtime,
AggregateRefs<AccountCommand, AccountEvent> accounts)
{
public Task<Event<AccountEvent>> WithdrawAtVersion(
long expectedVersion, string accountId, decimal amount,
CancellationToken cancellationToken) =>
runtime.Actor.SendIfVersionAsync(
accounts.Factory, expectedVersion,
Values.NewCID(), Values.CreateAggregateId(accountId),
(AccountCommand)new Withdraw(amount),
(AccountEvent reply) => reply is Withdrawn or Rejected,
cancellationToken);
}
In F#, api and accounts come from Fcqrs.actor and Fcqrs.aggregate. In C#,
AddAggregate<Account>() registers the typed AggregateRefs for injection, and AddFcqrs
registers FcqrsRuntime. Import FCQRS.CSharp.ActorWiring as shown to make the extension method
available. Call the service after the host has started. If several aggregates share
the same command and event types, resolve the refs keyed by the aggregate class, as described in
Use FCQRS from C#.
expectedVersion is a nonnegative int64 in F# or long in C#. Supply the version that accompanied
the data the decision was based on. A reply's Version is the aggregate's version after that
command. Read it as a number with ValueLens.Value from FCQRS.Model.Data in F#, or with
Values.VersionValue in C#, available from FCQRS 6.7.0. In a read model, store the aggregate event's Version alongside
the fields it shows and commit both in the same projection transaction, as the tutorial's
statement does. A delayed read model can return an older
version; the aggregate then detects that the withdrawal was based on stale data.
Handle a conflict
A version mismatch raises FCQRS.Common.AggregateVersionConflictException. Its AggregateId property
is a string; ExpectedVersion and ActualVersion are 64-bit integers. A mismatch does not depend on
the event filter accepting a domain reply.
Shared setup
let tryWithdraw (api: IActor) (accounts: AggregateHandle<AccountCommand, AccountEvent>) =
tryWithdraw: IActor -> AggregateHandle<AccountCommand,AccountEvent> -> Async<unit>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.
accounts: AggregateHandle<AccountCommand,AccountEvent>FCQRS.FSharp.AggregateHandle`2What you get back after registering an aggregate.
Fcqrs_450-how-to_003-send-if-version.md_page.Account.AccountCommandFcqrs_450-how-to_003-send-if-version.md_page.Account.AccountEventasync {
try
let! reply = withdrawAtVersion api accounts 7L "alice" 60m
printfn "Account reply: %A" reply.EventDetails
with :? AggregateVersionConflictException as conflict ->
printfn "Account %s changed: expected %d, actual %d"
conflict.AggregateId conflict.ExpectedVersion conflict.ActualVersion
}
async: 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
reply: Event<AccountEvent>withdrawAtVersion: IActor -> AggregateHandle<AccountCommand,AccountEvent> -> int64 -> string -> decimal -> Async<Event<AccountEvent>>api: IActoraccounts: AggregateHandle<AccountCommand,AccountEvent>printfn: Printf.TextWriterFormat<'T> -> 'TPrint to stdout using the given format, and add a newline. The formatter. The formatted result. See Printf.printfn (link: ) for examples.
EventDetails: 'EventDetailsThe specific details or payload of the event.
FCQRS.Common.AggregateVersionConflictExceptionThe aggregate rejected a conditional command before running its handler because its persisted version differed from the caller's expected version.
conflict: AggregateVersionConflictExceptionAggregateId: stringThe target aggregate's entity ID.
ExpectedVersion: int64The persisted version required by the caller.
ActualVersion: int64The persisted version observed when the aggregate checked the command.
try
{
var reply = await teller.WithdrawAtVersion(
7L, "alice", 60m, cancellationToken);
Console.WriteLine($"Account reply: {reply.EventDetails}");
}
catch (AggregateVersionConflictException conflict)
{
Console.WriteLine(
$"Account {conflict.AggregateId} changed: expected " +
$"{conflict.ExpectedVersion}, actual {conflict.ActualVersion}");
}
On conflict, load the current statement and let the teller decide again. Automatically substituting
ActualVersion and resending would permit the stale withdrawal that the check was intended to
prevent. The reported actual version was current at the check; another command can advance it before
the exception reaches the caller.
When the version matches, the domain still decides whether the withdrawal is valid. The method returns
the first matching aggregate reply, including a deferred Rejected reply for insufficient funds.
Inspect that reply before reporting that the withdrawal succeeded.
Conditional waits match the command ID, correlation ID, target aggregate, and event filter. FCQRS
preserves these IDs for persisted and deferred replies and guarded RunAsync continuations. If a
handler uses PublishEvent with an envelope it builds itself, copy the incoming command's Id and
CorrelationId into that envelope. Otherwise, the conditional wait can time out even though the event
was published; a different command's reply with the same correlation ID cannot complete this wait.
Understand the version boundary
FCQRS checks the version inside the aggregate actor immediately before calling its decision function. One aggregate instance processes commands sequentially, so another command cannot run between this check and that decision. An initial mismatch skips the decision function and fold, produces no domain event, and writes nothing to the journal.
The checked value is the aggregate's persisted domain version:
| Action or lifecycle stage | Version |
|---|---|
| No events have been persisted | 0 |
| Persist one event | Advances by 1 |
PersistAllEvents with several events |
Advances once per event in the batch |
| Defer a reply or perform no write | Unchanged |
| Recover from the journal or a snapshot | Restores the persisted version |
Two concurrent withdrawals expecting version 7 cannot both persist from that version. After one persists,
the other observes the advanced version and conflicts. Two commands that write nothing can both
match version 7. This check is not a command identifier or a durable record of a previous request.
The boundary covers one aggregate identity. It does not make changes to other aggregates atomic and
does not wait for a projection. For correlated read-your-writes, subscribe
before sending the conditional command, then await the projection notification when the reply was
journaled. Alternatively, call a transactional projection's CatchUpAsync after the command reply,
as shown in Catch up projections, before querying the updated read model.
Handle delayed work and unknown outcomes
A stashed conditional command retains its expected version and checks it again when it is unstashed.
For RunAsync, FCQRS checks before dispatching the effect and checks the same expected version again
when the result command returns. If the aggregate changed while the effect was running, that result
command conflicts. Work already performed outside the actor is not undone. See
Dispatch a best-effort async effect for its recovery limits.
The command wait uses akka.fcqrs.command-timeout, which defaults to 30 seconds. An already-canceled
token prevents the C# request from starting. Once a request starts, a timeout or cancellation stops
waiting; it does not prove that no event was saved or prevent processing already in flight, even if
the caller has not yet observed that the command was sent. Retry policy still belongs to the
application. A retry using the original version can conflict after the first attempt succeeded, but
that conflict alone cannot identify which command advanced the version. Use a domain operation
identifier when repeated requests need a durable, recognizable result.
Upgrade every node that can receive aggregate commands before using this API. Conditional commands use a distinct transport message; older receivers do not support it and can reject it or leave the caller waiting until timeout. They do not execute it as an ordinary unguarded command. Persisted command and event envelope shapes remain unchanged.
Test your domain covers decision and replay tests. Test competing conditional commands with a running FCQRS runtime too: the expected-version check belongs to the actor, so calling the domain decision function directly does not exercise it.