0. Quickstart: follow one request end to end
The application in this quickstart is DocStore, a small document store. It keeps titled text documents: you can create a document, edit its content, and look a document up by its id.
This page builds just enough of DocStore to save one new document and read it back. In FCQRS the save and the read take different paths. The save is recorded as an event, a small stored fact describing what happened. The read is served from a lookup view that is updated from those events shortly after they are stored. Because the view lags behind by a moment, the program waits for its confirmation before reading. Each section below introduces one part of that path as the code needs it.
Course position: this is the first practical stage. It assumes only basic F# or C# and introduces the runtime vocabulary used by chapters 1 through 5. Motivation: Build one complete write-and-read path first. An aggregate alone hides the asynchronous handoff to projections, while a full command-to-query loop exposes the architecture you will use in a real application.
The read model is kept in memory so the example needs only the FCQRS package. It is rebuilt by
replaying the journal from offset zero whenever the program starts. Later stages explain why this works
and replace the teaching shortcuts with production decisions.
Run a complete sample
The repository contains two small projects that perform the complete flow on stable .NET 10:
Language |
Project |
|---|---|
F# |
|
C# |
After cloning FCQRS, run either project from the repository root:
|
Both send one command, persist one event to SQLite, wait for an in-memory projection, and query the result. The C# sample begins with one concrete command and event type, so it does not require preview union syntax. The C# examples later on this page show how the domain expands to several cases.
Create the project
|
|
Replace Program.fs (Program.cs in C#) with the code from the following sections. The C# tabs are direct counterparts;
because the expanded command and event examples use C# discriminated unions, they require the compiler
setup described in C# interop and serialization. Use the linked stable
.NET 10 C# sample when you want a runnable project without preview union syntax.
The shape of the program
Program.fs will hold four pieces. The wireframe below shows every signature with an unimplemented
body; sections 1 to 4 replace each failwith with a working implementation.
module Document =
// 1. The aggregate: Root, State, Command, and Event, then two pure functions.
let decide (command: Command<Command>) (state: State) : EventAction<Event> =
failwith "section 1"
let fold (event: Event<Event>) (state: State) : State =
failwith "section 1"
// 2. The read model: apply each stored event to an in-memory view.
let handleProjection (offset: int64) (message: obj) : unit =
failwith "section 2"
// 3. The actor system: configuration, logging, and SQLite storage.
let buildApi () : IActor =
failwith "section 3"
// 4. One request end to end: send Create, wait for the projection, query.
let run () : Async<unit> =
failwith "section 4"
|
1. Define the aggregate
An aggregate receives commands and produces events. Its current state is rebuilt by folding its stored
events. This document aggregate accepts Create and Edit commands. A command that cannot change the
document produces a deferred reply instead of a stored event.
module Document =
type Root =
{ Id: string
Title: string
Content: string }
type State = { Document: Root option }
type Command =
| Create of Root
| Edit of id: string * content: string
type Event =
| Created of Root
| Edited of id: string * content: string
| AlreadyExists
| NoSuchDocument
let initial = { Document = None }
// Decide: a pure function from command and current state to one event action.
let decide (command: Command<Command>) state =
match command.CommandDetails, state with
| Create document, { Document = None } -> Created document |> PersistEvent // stored and published
| Create _, { Document = Some _ } -> AlreadyExists |> DeferEvent // reply only, nothing stored
| Edit(id, content), { Document = Some document } when document.Id = id ->
Edited(id, content) |> PersistEvent
| Edit _, _ -> NoSuchDocument |> DeferEvent
// Fold: rebuilds state one event at a time; it also runs during replay after a restart.
let fold (event: Event<Event>) state =
match event.EventDetails with
| Created document -> { Document = Some document }
| Edited(id, content) ->
match state.Document with
| Some document when document.Id = id ->
{ Document = Some { document with Content = content } }
| _ -> state
| AlreadyExists
| NoSuchDocument -> state // deferred replies never change persisted state
PersistEvent appends the event to the journal, folds it into state, and publishes it. DeferEvent
publishes and folds a reply without storing it or changing the persisted aggregate version. The
deferred AlreadyExists and NoSuchDocument cases above deliberately leave state unchanged in
fold; otherwise their state change would disappear on recovery. The decision and fold are pure
functions, so they can be tested without Akka.NET or a database.
|
See Aggregates and the write side for the model behind these functions.
2. Build a read model
The query side below is a concurrent dictionary indexed by document id. The projection receives each
stored event in order and updates the dictionary. Throwing on Edited without a preceding Created
makes a broken event history visible instead of silently producing an incorrect view.
let readModel = ConcurrentDictionary<string, Document.Root>()
let handleProjection (_offset: int64) (message: obj) =
match message with
| :? Event<Document.Event> as event ->
match event.EventDetails with
| Document.Created document -> readModel[document.Id] <- document
| Document.Edited(id, content) ->
match readModel.TryGetValue id with
| true, document -> readModel[id] <- { document with Content = content }
| false, _ -> failwith $"Projection received Edited before Created for {id}"
| Document.AlreadyExists
| Document.NoSuchDocument -> () // deferred replies carry nothing to project
| _ -> ()
|
This projection starts at offset zero, so it rebuilds the dictionary from all stored events on every run. A durable read model stores its last offset together with each update in one transaction and resumes from there; the shared transaction prevents a crash from skipping an event or applying one twice.
3. Create the actor system
Fcqrs.actor combines the embedded Akka.NET defaults with the supplied configuration and database
connection. SQLite stores the journal and snapshots in getstarted.db.
let buildApi () : IActor =
let config = ConfigurationBuilder().Build()
let loggerFactory = LoggerFactory.Create(fun _ -> ())
let connection =
Fcqrs.connect FCQRS.Actor.DBType.Sqlite "Data Source=getstarted.db;"
Fcqrs.actor config loggerFactory (Some connection) "getstarted"
|
4. Send, wait, and query
The aggregate and projection are registered before commands are sent. Subscribe to the correlation id
before sending, then wait for the projection after the aggregate returns its stored event. The new
aggregate id in this example guarantees that Create stores an event rather than returning a deferred
AlreadyExists 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 subscriptions = Fcqrs.projection api (Projection.single 0 handleProjection)
let documentId = Guid.NewGuid().ToString("N")
let aggregateId = Fcqrs.aggregateId documentId
let correlationId = Fcqrs.newCid ()
let document: Document.Root =
{ Id = documentId
Title = "FCQRS notes"
Content = "first event" }
// Subscribe before sending so the projection cannot publish first.
use projectedEvent = subscriptions.Subscribe(correlationId, 1)
let! stored =
documents.Send
correlationId
aggregateId
(Document.Create document)
// The filter picks which aggregate reply completes this send.
(function
| Document.Created _ -> true
| _ -> false)
// Read-your-writes: wait until the projection has handled this correlation id.
do! projectedEvent.Task |> Async.AwaitTask
let projected = readModel[documentId]
printfn "stored version %A; query returned '%s'" stored.Version projected.Content
}
|
Add the entry point and run the program:
[<EntryPoint>]
let main _ =
run () |> Async.RunSynchronously
0
|
|
The aggregate id is new on each run, so each command creates a different document. The projection still replays previous documents before handling the new event.
Continue the learning path
You have seen the entire route once: command, aggregate decision, stored event, projection, then query. The next chapter slows down at the first step and explains how to design that decision correctly.
Continue to 1. The aggregate.
After chapter 1 introduces the domain model, its optional deep dives point to the relevant Understand and Apply pages. You do not need those pages before continuing.
module Event from Microsoft.FSharp.Control
--------------------
type Event<'T> = new: unit -> Event<'T> member Trigger: arg: 'T -> unit member Publish: IEvent<'T>
--------------------
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<'T>
--------------------
new: unit -> Event<'Delegate,'Args>
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> 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>
val string: value: 'T -> string
--------------------
type string = String
type State = { Document: Root option }
--------------------
type State<'Command,'Event> = { CommandDetails: CommandDetails<'Command,'Event> Sender: IActorRef }
type Command = | Create of Root | Edit of id: string * content: string
--------------------
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 = | Created of Root | Edited of id: string * content: string | AlreadyExists | NoSuchDocument
--------------------
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> Publish and fold the event in the live actor without storing it or incrementing the persisted version. </summary>
<summary> The specific details or payload of the event. </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 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> 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>
<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>
[<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
<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>
<summary> A fresh correlation id (UUID v7). </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