Send at an expected version
A document editor loads version 7 and sends its changes with that version. If another edit has
already advanced the document 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 document types from
samples/getting-started-fsharp/Document.fs and samples/getting-started-csharp/Document.cs:
Shared setup
module Program =
open FCQRS.Common
type Document = { Id: string; Title: string; Content: string }
type PublicationResult = Published | Rejected
type PublicationProgress = WaitingForSlug of string | Finished of string * PublicationResult
// An absent publication field in an older snapshot reads as None.
type DocumentState = { Document: Document option; Publication: PublicationProgress option }
type DocumentCommand =
| CreateDocument of Document
| EditDocument of id: string * content: string
| PublishDocument of slug: string
| FinishPublication of slug: string * PublicationResult
type DocumentEvent =
| DocumentCreated of Document
| DocumentEdited of id: string * content: string
| DocumentRejected of reason: string
| PublicationRequested of id: string * slug: string
| PublicationFinished of id: string * slug: string * PublicationResult
let initial = { Document = None; Publication = None }
let decide (command: Command<DocumentCommand>) state =
match command.CommandDetails, state.Document, state.Publication with
// docs:create
| CreateDocument document, None, _ -> DocumentCreated document |> PersistEvent
| CreateDocument _, Some existing, _ -> DocumentCreated existing |> DeferEvent
// docs:end
// docs:edit
| EditDocument(id, content), Some document, None when document.Id = id ->
if System.String.IsNullOrWhiteSpace content then
DocumentRejected "Content must not be blank" |> DeferEvent
elif document.Content = content then
DocumentEdited(id, content) |> DeferEvent
else
DocumentEdited(id, content) |> PersistEvent
| EditDocument _, None, _ -> DocumentRejected "Document does not exist" |> DeferEvent
| EditDocument _, _, _ -> DocumentRejected "Editing closes when publication starts" |> DeferEvent
// docs:end
// docs:publish
| PublishDocument slug, Some document, None ->
if System.String.IsNullOrWhiteSpace slug then
DocumentRejected "Slug must not be blank" |> DeferEvent
else PublicationRequested(document.Id, slug) |> PersistEvent
| PublishDocument slug, Some document, Some(WaitingForSlug current) when slug = current ->
PublicationRequested(document.Id, slug) |> DeferEvent
| PublishDocument slug, Some document, Some(Finished(current, result)) when slug = current ->
PublicationFinished(document.Id, slug, result) |> DeferEvent
| FinishPublication(slug, result), Some document, Some(WaitingForSlug current) when slug = current ->
PublicationFinished(document.Id, slug, result) |> PersistEvent
| FinishPublication(slug, result), Some document, Some(Finished(current, previous))
when slug = current && result = previous ->
PublicationFinished(document.Id, slug, result) |> DeferEvent
| _ -> DocumentRejected "Publication does not match the document's state" |> DeferEvent
// docs:end
let fold (event: Event<DocumentEvent>) state =
match event.EventDetails with
| DocumentCreated document -> { state with Document = Some document }
| DocumentEdited(id, content) ->
match state.Document with
| Some document when document.Id = id ->
{ state with Document = Some { document with Content = content } }
| _ -> failwith "DocumentEdited requires an earlier DocumentCreated"
| DocumentRejected _ -> state
| PublicationRequested(_, slug) -> { state with Publication = Some(WaitingForSlug slug) }
| PublicationFinished(_, slug, result) -> { state with Publication = Some(Finished(slug, result)) }
open System
open FCQRS.Model.Data
open FCQRS.Common
open FCQRS.FSharp
open Program
Fcqrs_450-how-to_003-send-if-version.md_page.ProgramFCQRSFCQRS.CommonContains common types like Events and Commands Functionality for Write Side.
Fcqrs_450-how-to_003-send-if-version.md_page.Program.DocumentId: stringstringAn abbreviation for the CLI type . Basic Types
Title: stringContent: stringFcqrs_450-how-to_003-send-if-version.md_page.Program.PublicationResultPublishedRejectedFcqrs_450-how-to_003-send-if-version.md_page.Program.PublicationProgressWaitingForSlugFinishedFcqrs_450-how-to_003-send-if-version.md_page.Program.DocumentStateDocument: Document 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
Publication: PublicationProgress optionFcqrs_450-how-to_003-send-if-version.md_page.Program.DocumentCommandCreateDocumentEditDocumentid: stringcontent: stringPublishDocumentslug: stringFinishPublicationFcqrs_450-how-to_003-send-if-version.md_page.Program.DocumentEventDocumentCreatedDocumentEditedDocumentRejectedreason: stringPublicationRequestedPublicationFinishedinitial: DocumentStateNoneThe representation of "No value"
decide: Command<DocumentCommand> -> DocumentState -> EventAction<DocumentEvent>command: Command<DocumentCommand>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: DocumentStateCommandDetails: 'CommandDetailsThe specific details or payload of the command.
document: Document(|>): '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.
SomeThe representation of "Value of type 'T" The input value. An option representing the value.
existing: DocumentDeferEventPublish and fold the event in the live actor without storing it or incrementing the persisted version.
(=): 'T -> 'T -> boolStructural equality The first parameter. The second parameter. The result of the comparison. 5 = 5 // Evaluates to true 5 = 6 // Evaluates to false [1; 2] = [1; 2] // Evaluates to true (1, 5) = (1, 6) // Evaluates to false
SystemIsNullOrWhiteSpace: string -> boolIndicates whether a specified string is , empty, or consists only of white-space characters. The string to test. if the parameter is or , or if consists exclusively of white-space characters.
System.StringRepresents text as a sequence of UTF-16 code units.
current: stringresult: PublicationResultprevious: PublicationResult(&&): bool -> bool -> boolBinary 'and'. When used as a binary operator the right hand value is evaluated only on demand The first value. The second value. The result of the operation.
fold: Event<DocumentEvent> -> DocumentState -> DocumentStateevent: Event<DocumentEvent>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.
failwith: string -> 'TThrow a exception. The exception message. Never returns. let failingFunction() = failwith "Oh no" // Throws an exception true // Never reaches this failingFunction() // Throws a System.Exception
ModelFCQRS.Model.DataFCQRS.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 -> ...)
open FCQRS.Common
open FCQRS.FSharp
open Program
let editDocument api documents expectedVersion documentId content =
Fcqrs.sendIfVersion api documents expectedVersion
(Fcqrs.newCid ()) (Fcqrs.aggregateId documentId)
(EditDocument(documentId, content))
(function
| DocumentEdited _ | DocumentRejected _ -> 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 / 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_003-send-if-version.md_page.ProgrameditDocument: IActor -> AggregateHandle<DocumentCommand,DocumentEvent> -> int64 -> string -> string -> Async<Event<DocumentEvent>>api: IActordocuments: AggregateHandle<DocumentCommand,DocumentEvent>expectedVersion: int64documentId: stringcontent: stringFCQRS.FSharp.FcqrssendIfVersion: IActor -> AggregateHandle<'Command,'Event> -> int64 -> CID -> 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 -> CIDA fresh correlation id (UUID v7).
aggregateId: string -> 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.
EditDocumentDocumentEditedDocumentRejectedusing FCQRS;
using static FCQRS.Common;
using static FCQRS.CSharp;
using static FCQRS.CSharp.ActorWiring;
public sealed class DocumentEditor(
FcqrsRuntime runtime,
AggregateRefs<DocumentCommand, DocumentEvent> documents)
{
public Task<Event<DocumentEvent>> EditDocument(
long expectedVersion, string documentId, string content,
CancellationToken cancellationToken) =>
runtime.Actor.SendIfVersionAsync(
documents.Factory, expectedVersion,
Values.NewCID(), Values.CreateAggregateId(documentId),
(DocumentCommand)new EditDocument(documentId, content),
(DocumentEvent reply) => reply is DocumentEdited or DocumentRejected,
cancellationToken);
}
In F#, api and documents come from Fcqrs.actor and Fcqrs.aggregate. In C#,
AddAggregate<DocumentAggregate>() 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 being edited. In a read model, store the aggregate event's Version alongside the document
fields and commit both in the same projection transaction. A delayed read model can return an older
version; the aggregate then detects that the edit 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 tryEdit (api: IActor) (documents: AggregateHandle<DocumentCommand, DocumentEvent>) =
tryEdit: IActor -> AggregateHandle<DocumentCommand,DocumentEvent> -> 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.
documents: AggregateHandle<DocumentCommand,DocumentEvent>FCQRS.FSharp.AggregateHandle`2What you get back after registering an aggregate.
Fcqrs_450-how-to_003-send-if-version.md_page.Program.DocumentCommandFcqrs_450-how-to_003-send-if-version.md_page.Program.DocumentEvent async {
try
let! reply = editDocument api documents 7L "doc-1" "Revised content"
printfn "Document reply: %A" reply.EventDetails
with :? AggregateVersionConflictException as conflict ->
printfn "Document %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<DocumentEvent>editDocument: IActor -> AggregateHandle<DocumentCommand,DocumentEvent> -> int64 -> string -> string -> Async<Event<DocumentEvent>>api: IActordocuments: AggregateHandle<DocumentCommand,DocumentEvent>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 editor.EditDocument(
7L, "doc-1", "Revised content", cancellationToken);
Console.WriteLine($"Document reply: {reply.EventDetails}");
}
catch (AggregateVersionConflictException conflict)
{
Console.WriteLine(
$"Document {conflict.AggregateId} changed: " +
$"expected {conflict.ExpectedVersion}, actual {conflict.ActualVersion}");
}
On conflict, load the current document and let the caller review or merge its changes. Automatically
substituting ActualVersion and resending would permit the stale edit 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 edit is valid. The method returns the
first matching aggregate reply, including a deferred DocumentRejected reply. Inspect that reply
before reporting that the edit 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 edits 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.