2. Wiring and running it
Chapter 1 produced a document aggregate as pure functions. This chapter registers those functions with
FCQRS, stores their events in SQLite, projects the events into query data, and sends a command through
the complete path. Continue in the same Program.fs.
Course position: chapter 1 defined a pure aggregate. This chapter runs it and follows one stored event into a query model. By the end you will understand the journal, projection offset, correlation id, and subscribe-before-send ordering from one working request.
This chapter adds three pieces around chapter 1's aggregate. Each failwith message names the
section that implements it.
let buildApi () : IActor =
failwith "create the actor system"
let handle (offset: int64) (message: obj) : unit =
failwith "build the query side"
let run () : Async<unit> =
failwith "send a command and read your own write"
|
Two paths, on purpose: writing and reading
The application has two paths:
decide / fold
command ----------------------> JOURNAL (append-only events; the truth)
|
v
projection
|
v
read model (a derived view; disposable)
Commands flow through the aggregate and append events to the journal. Projections consume those events and update read models. Application queries use the read model, not the aggregate state or journal.
Motivation: Two paths let query shapes evolve without widening the aggregate, and let business rules evolve without redesigning every query. The stored events are the stable handoff between them.
Create the actor system
Fcqrs.actor builds the Akka.NET system. The connection configures the journal, query journal, and
snapshot store. The embedded defaults configure serialization, sharding, and a one-node cluster.
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"
|
The empty IConfiguration accepts every embedded default. User configuration is merged over those
defaults when an application needs different Akka.NET settings. tutorial.db outlives the process, so
the next run can recover the aggregate and replay the projection. See
Configure the database for other providers.
Build the query side
This tutorial uses an in-memory dictionary for query data. The projection starts at offset zero and
replays all stored Updated events on every run. A production projection instead persists its read
model and, in the same database transaction, the offset of the last event it applied. The shared
transaction is what makes a crash safe: with separate writes, an offset saved first skips an event
forever, and data saved first applies an event twice on restart. The in-memory version here keeps the
event-to-query transformation visible.
let readModel = ConcurrentDictionary<string, Document.Root>()
let handle (_offset: int64) (message: obj) =
match message with
| :? Event<Document.Event> as event ->
match event.EventDetails with
| Document.Updated document -> readModel[document.Id.ToString()] <- document
| _ -> ()
|
Projection.single publishes an aggregate event to subscribers after handle returns. A caller can
therefore wait until this projection has applied a specific command's event. See
Add a projection for transactional offset storage.
Send a command and read your own write
The aggregate acknowledges its stored event before the projection necessarily updates the read model. A query sent immediately after the command can therefore return the previous view. FCQRS carries a correlation id (CID) through the command, event, and projection notification:
mint a CID
|
v
SUBSCRIBE to that CID <-- before sending, so the answer can't slip past
|
v
send command --> aggregate --> journal --> projection re-publishes --> your wait wakes
Subscribe before sending. Subscribing afterward creates a race in which the projection can publish the
notification before the subscription exists. .Send accepts the CID, aggregate id, command, and a
predicate selecting the aggregate reply.
let run () =
async {
let api = buildApi ()
let documents =
Fcqrs.aggregate api
{ Name = "Document"
Initial = Document.initial
Decide = Document.decide
Fold = Document.fold
Snapshots = Default }
Fcqrs.wireSagaStarters api []
let subs = Fcqrs.projection api (Projection.single 0 handle)
let cid = Fcqrs.newCid ()
let id = Fcqrs.aggregateId "11111111-1111-1111-1111-111111111111"
// Subscribe to this CID *before* sending so the confirmation can't be missed.
use awaiter = subs.Subscribe(cid, 1)
match Document.Root.TryCreate(Guid "11111111-1111-1111-1111-111111111111", "Welcome", "draft") with
| Error e -> printfn "rejected: %s" e
| Ok doc ->
let! event =
documents.Send cid id (Document.CreateOrUpdate doc)
(fun e ->
match e with
| Document.Updated _ -> true)
do! awaiter.Task |> Async.AwaitTask
let projected = readModel[doc.Id.ToString()]
printfn "saved version %A; query returned '%s'" event.Version projected.Title.Value
}
|
Wire it to your entry point:
[<EntryPoint>]
let main _ =
run () |> Async.RunSynchronously
0
|
Run it, then run it again
Run the program twice without deleting tutorial.db:
|
The second process starts with empty memory. FCQRS replays the first Updated event through fold, then
handles the new command and stores version 2. The projection also starts with an empty dictionary and
replays from offset zero before applying the new event.
Delete tutorial.db* and run again to start a new event history at version 1. In production, deleting a
read model and resetting its offset is a rebuild operation; deleting the journal discards the source of
truth.
What you now understand
A command produced a stored event. The aggregate recovered its state by replaying stored events, and a separate projection rebuilt query data from the same history. Subscribing to the CID before sending coordinated the query with that projection.
Common mistakes
- Querying without waiting for the required projection. The read model may still contain the old view.
- Subscribing after sending. The notification can pass before the subscription exists.
- Starting a durable projection at offset zero on every run. Persist the offset with the read-model update instead.
- Forgetting
Fcqrs.wireSagaStarters. Wire an empty list when the application has no sagas.
Continue the learning path
Next, add a saga for a publication rule that spans a document and its URL slug. The next chapter assumes you understand the two paths and the projection timing shown here.
For optional depth after chapter 3, The read side explains transactional offsets and rebuilds, while Add a projection is the short production recipe.
<summary> Contains common types like Events and Commands </summary>
<namespacedoc><summary>Functionality for Write Side.</summary></namespacedoc>
<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 -> ...) </summary>
[<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
union case DocumentId.DocumentId: Guid -> DocumentId
--------------------
type DocumentId = | DocumentId of Guid override ToString: unit -> string static member OfGuid: g: Guid -> DocumentId member Value: Guid
Guid.ToString(format: string) : string
Guid.ToString(format: string, provider: IFormatProvider) : string
<summary> Validated non-blank string up to 255 chars inclusive. </summary>
union case Title.Title: ShortString -> Title
--------------------
type Title = | Title of ShortString static member TryCreate: s: string -> Result<Title,string> member Value: string
static member ValueLens.Value: this: 'Wrapped -> 'Inner (requires member Value_)
<summary> Represents any string at least 1 chars </summary>
union case Content.Content: LongString -> Content
--------------------
type Content = | Content of LongString static member TryCreate: s: string -> Result<Content,string> member Value: string
type State = { Document: Root option }
--------------------
type State<'Command,'Event> = { CommandDetails: CommandDetails<'Command,'Event> Sender: IActorRef }
type Command = | CreateOrUpdate of Root
--------------------
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. <typeparam name="'CommandDetails">The specific type of the command payload.</typeparam> </summary>
--------------------
type Command<'Command,'Event> = | Execute of CommandDetails<'Command,'Event>
<summary> Represents the message sent to the internal subscription mechanism. <typeparam name="'Command">The type of the command payload.</typeparam> <typeparam name="'Event">The type of the expected event payload.</typeparam> </summary>
module Event from Microsoft.FSharp.Control
--------------------
type Event = | Updated of Root
--------------------
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. <typeparam name="'EventDetails">The specific type of the event payload.</typeparam> </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>
<summary> The specific details or payload of the command. </summary>
<summary> Persist the event to the journal. The actor's state will be updated using the event handler *after* persistence succeeds. </summary>
<summary> The specific details or payload of the event. </summary>
val int64: value: 'T -> int64 (requires member op_Explicit)
--------------------
type int64 = System.Int64
--------------------
type int64<'Measure> = int64
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>
<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>
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
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
<summary> Build a SQLite/etc. Connection from a raw connection string (ShortString hidden). </summary>
<summary> Represents the type of database connection </summary>
<summary> SQLite using Microsoft.Data.Sqlite provider </summary>
<summary> Create the actor system from plain values (cluster name as a string). </summary>
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 string: value: 'T -> string
--------------------
type string = String
val int64: value: 'T -> int64 (requires member op_Explicit)
--------------------
type int64 = Int64
--------------------
type int64<'Measure> = int64
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. <typeparam name="'EventDetails">The specific type of the event payload.</typeparam> </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>
<summary> The specific details or payload of the event. </summary>
<summary> Register an aggregate and return its typed handle. Calling this IS the registration (it initializes the sharding region). </summary>
<summary> Identify the target by its string name (entity ID). </summary>
<summary> Use the global config (config:akka:persistence:snapshot-version-count), or 30. </summary>
<summary> Wire every registered saga into one saga-starter (or the empty starter if none). Call after the aggregates + sagas are registered. </summary>
<summary> Register the read-model projection and return the subscription stream. </summary>
module Projection from FCQRS.FSharp
<summary> Constructors for the two projection-handler shapes. </summary>
--------------------
type Projection = { LastOffset: int64 Handle: (int64 -> obj -> IMessageWithCID list) }
<summary> A read-model projection definition. </summary>
<summary> Single-event handler: just update the read model (returns unit); each aggregate event is then published to subscribers as-is. The common case when every event is worth notifying. </summary>
<summary> A fresh correlation id (UUID v7). </summary>
<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>
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
<summary> Send a command and await the first matching aggregate event. </summary>
static member Async.AwaitTask: task: Threading.Tasks.Task<'T> -> Async<'T>
<summary> The version number of the aggregate after this event was applied. </summary>
type EntryPointAttribute = inherit Attribute new: unit -> EntryPointAttribute
--------------------
new: unit -> EntryPointAttribute
FCQRS