1. The aggregate
Think about the last time you edited a document and wished you could see who changed that one paragraph, and when. An ordinary database can't tell you — it overwrote the old value the instant you hit save. The current state is all it keeps; the story of how you got there is gone. Event sourcing is the decision to keep the story instead, and rebuild the current state from it whenever you need it. This chapter is where that decision becomes code — and, pleasantly, it's the part with no actors, no database, and no async at all. Just types and two pure functions you can run in your head.
Commands and events are not the same thing
Let's start with the single idea everything else hangs off. In CRUD you have one verb — save — that both decides and records in one breath. Event sourcing pulls those apart into two different things, and once you see the seam you can't unsee it.
A command is a request, phrased in the imperative: "create or update this document." It's a wish. It can be turned down. An event is a fact, phrased in the past tense: "this document was updated." It already happened, and you never un-happen a fact — you can only record a new one that corrects it.
💡 Mental model. A command is someone asking; an event is what the record will forever say happened. The bank teller hears "withdraw $100" (command); the ledger gets "withdrew $100" — or "declined: insufficient funds" (event). The request and the recorded outcome are different sentences, and the ledger only ever keeps the second kind.
That's why they're deliberately different sets. One CreateOrUpdate command might produce an
Updated fact on a good day and a Rejected one on a bad day. If commands and events were the same
type you'd be quietly assuming every request succeeds — which is exactly the assumption that makes CRUD
code lie to you later.
The thing that receives a command, decides, and emits the event is an aggregate: the consistency boundary for one entity — one document, one account, one order. In FCQRS an aggregate is an actor that handles one command at a time, so there are no locks and no races inside it. But hold that lightly:
🎯 Key principle. The aggregate is a consistency boundary with a pure decision function at its centre. "It's an actor" is how FCQRS implements the boundary; it isn't the idea. We won't touch the actor machinery until chapter 2 — and the decision function we write here would be just as correct if the boundary were a lock or a database transaction instead.
⚠️ Is all this worth it for a notes app you'll delete next week? Honestly, no — plain CRUD is less code, and you should reach for it. The calculus flips the moment "what changed, when, and in what order" has real value: money, approvals, anything several people edit at once. Then keeping the events stops being ceremony and becomes the feature — audit, undo, and rebuildable views all fall out of it for free.
Open Program.fs in the project from the tutorial intro and follow along. The opens
first:
open System
open FCQRS.Common
open FCQRS.Model.Data
open FCQRS.FSharp
Make the illegal values impossible to type
Before the document, its raw materials: a title and a body. We could use bare strings — and for a
prototype that's a fine shortcut. But a bare string lets an empty title through, and lets a caller swap
title and body without the compiler noticing. The fix is an old DDD habit: validate once, at the
edge, and bake the result into the type. After that, holding a Title is the proof it's a valid
title; nothing downstream has to re-check.
FCQRS hands you two validated primitives for this — ShortString and LongString — built through
ValueLens, which returns a Result so bad input can't sneak past. We wrap each in its own domain type
so the compiler will never let a Content stand in for a Title:
module Values =
type DocumentId =
| DocumentId of Guid
static member OfGuid g = DocumentId g
member this.Value = let (DocumentId g) = this in g
override this.ToString() = let (DocumentId g) = this in g.ToString()
type Title =
| Title of ShortString
static member TryCreate s =
match ValueLens.TryCreate s with
| Ok ss -> Ok(Title ss)
| Error _ -> Error "Invalid title"
member this.Value = let (Title s) = this in ValueLens.Value s
type Content =
| Content of LongString
static member TryCreate s =
match ValueLens.TryCreate s with
| Ok ss -> Ok(Content ss)
| Error _ -> Error "Invalid content"
member this.Value = let (Content s) = this in ValueLens.Value s
|
ShortString and LongString are just FCQRS's ready-made validated strings (they also serialize
cleanly into the event log, which matters later). The wrapping in Title/Content is plain F# you'd
write the same way with any framework — the principle is "parse, don't validate," and the framework only
supplies the validated primitive underneath.
🤔 Did you know? This is why
TryCreatereturns aResultrather than throwing. A thrown exception on bad input would force every caller into a try/catch; aResultmakes "this might be rejected" part of the type, so the compiler reminds you to handle the rejection at the one place it can happen — the edge.
State, command, event
Now the document itself. Root is the content as it travels on the wire, with a smart constructor that
validates title and body together and hands back a single Result. An aggregate should never have to
reason about half-valid input, and this is where we guarantee it won't:
module Document =
open Values
type Root =
{ Id: DocumentId; Title: Title; Content: Content }
static member TryCreate(guid, title, content) =
match Title.TryCreate title, Content.TryCreate content with
| Ok t, Ok c -> Ok { Id = DocumentId.OfGuid guid; Title = t; Content = c }
| Error e, _ -> Error e
| _, Error e -> Error e
State is what the aggregate folds events into and keeps in memory. A document we've never heard of has
no content yet and sits at version 0:
type State = { Document: Root option; Version: int64 }
let initial = { Document = None; Version = 0L }
And the command/event pair. Right now there's exactly one of each — every write is a CreateOrUpdate
that yields an Updated.
You might wonder: if there's only one case each, why bother splitting command from event at all?
Because the split isn't about how many cases you have today — it's about keeping the request and the
record free to diverge tomorrow. In chapter 3 CreateOrUpdate will start
producing several different events depending on a quota check, and the code here won't have to be
reshaped to allow it. Starting with one case keeps the moving parts visible; the seam is already in the
right place.
type Command = CreateOrUpdate of Root
type Event = Updated of Root
|
decide: the one function that earns its keep
Here's the heart of the write side. decide takes the command — wrapped in a Command<_> envelope
that carries metadata like a timestamp and a correlation id — and the current state, and returns an
action. Read that word carefully: it returns a description of what should happen, not a mutation.
It doesn't write to anything. It decides.
let decide (cmd: Command<Command>) state =
match cmd.CommandDetails with
| CreateOrUpdate doc -> Updated doc |> PersistEvent
|
The two actions you'll reach for constantly are a study in contrast:
*PersistEvent e* is the happy path — append e to the log, bump the version, fold it into state,
and publish it. Return this when something genuinely happened.
*DeferEvent e* publishes e as an answer without storing it. The state and version don't move,
nothing lands in the log. This is the right call for rejections — AlreadyExists, Errored. The caller
still needs to hear "no," but a refusal isn't a fact that changed the entity, so letting it into the
permanent history would be a lie.
⚠️ Common mistake. Persisting your rejections "so there's a record of them." It feels tidy, but now
foldhas to pattern-match events that mean nothing changed, your version counter inflates on failed attempts, and a future replay re-applies non-events. Keep history to things that actually happened; deliver the "no" withDeferEvent. (Our single case is a plainPersistEventbecause here every write is a real change — we'll useDeferEventin chapter 3.)
There are two more actions, IgnoreEvent and UnhandledEvent, for "do nothing" and "I don't handle
this here" — you'll meet both in chapter 3.
fold: rebuild the present from the past
State is not the source of truth and is never stored. It's a cache, reconstructed by replaying every
event through fold. Which means this function lives a double life: it runs once when a brand-new event
is persisted, and it runs again, many times, when old events are replayed to rebuild state after a
restart. Those two lives must produce identical results.
let fold (event: Event<Event>) state =
match event.EventDetails with
| Updated doc -> { state with Document = Some doc; Version = state.Version + 1L }
|
That Version + 1L is small but it's the whole magic trick. Each replayed event re-runs this fold, so
when you restart the app next chapter and the document's one stored event replays, the version ticks
from 0 to 1 again — and a fresh write takes it to 2. The version literally counts how many facts
are in the log.
🎯 Key principle.
foldmust be pure — no clock, noRandom, no I/O. The instant it depends on something that changes between runs, replaying the same events produces a different state, and event sourcing's core promise ("the log fully determines the present") quietly breaks. If you need the current time, the command captures it and the event carries it;foldonly ever reads what the event already holds.
Bind the functions to an actor
Everything above is plain F# you could lift into any project. The framework shows up in exactly one
place: Fcqrs.aggregate takes a record describing the aggregate and registers it — spinning up the
cluster-sharding region behind the scenes — and hands back a typed handle we'll use to talk to it in
chapter 2.
let register (api: IActor) =
Fcqrs.aggregate api
{ Name = "Document"
Initial = initial
Decide = decide
Fold = fold }
|
Notice what you didn't write: no base class to inherit, no attributes to sprinkle, no actor lifecycle
to manage. Those four fields — Name, Initial, Decide, Fold — are the entire contract between
your domain and the framework.
What you now understand
You came in thinking of "save" as one action. You're leaving with it split into a command (a request
that might be refused) and an event (a fact you keep forever), joined by a pure decide that chooses
between them and a pure fold that rebuilds the present by replaying the past. That's not FCQRS
trivia — it's the shape of every event-sourced aggregate in any language.
And because there's no Akka in any of it, you can test the valuable part right now with ordinary function calls:
let doc = Document.Root.TryCreate(System.Guid.NewGuid(), "Spec", "draft") |> Result.value
// decide returns an action, not a mutation — so you assert on the action:
let action = Document.decide (cmd (Document.CreateOrUpdate doc)) Document.initial
// => PersistEvent (Updated doc)
Common mistakes
-
Putting a clock or random value in
fold. It passes every test that doesn't restart the process, then corrupts state on the first replay. If the value can change between runs, capture it in the command and carry it in the event. - Reaching for
PersistEventon rejections. Rejections are answers, not history —DeferEvent. - Using bare strings for domain values in anything but a throwaway. The empty title gets in, the swapped arguments compile, and you debug it in production instead of at the type boundary.
-
Treating
Stateas the source of truth. It's a derived cache. The event log is the truth;Stateis just what's convenient to hold in memory.
Further study
- Aggregates and the write side — the long-form reasoning behind command/event and why the boundary is an actor.
- CQRS and event sourcing — why storing events instead of current state changes everything downstream.
-
Test your domain — the tiny
cmd/evthelpers used above, with a full set of assertions.
Next, we give these functions a running home and watch the version climb across restarts: wiring and running it.
<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* — it 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 users = Fcqrs.aggregate api { Name="User"; ... } let quota = Fcqrs.saga api (quotaDef documents.Factory users.Factory) Fcqrs.wireSagaStarters api [ quota ] let subs = Fcqrs.projection api { LastOffset = 0; Handle = handle } // send a command and await the resulting event (read-your-writes): 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 Version: int64 }
--------------------
type State<'Command,'Event> = { CommandDetails: CommandDetails<'Command,'Event> Sender: IActorRef }
<summary> Aggregate Version </summary>
val int64: value: 'T -> int64 (requires member op_Explicit)
--------------------
type int64 = Int64
--------------------
type int64<'Measure> = int64
type Command = | CreateOrUpdate of Root
--------------------
type Command<'CommandDetails> = { CommandDetails: 'CommandDetails CreationDate: DateTime Id: MessageId Sender: AggregateId option CorrelationId: CID Metadata: Map<string,string> } 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 IMessage interface ISerializable member Equals: Event<'EventDetails> * IEqualityComparer -> bool override ToString: unit -> string
<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>
<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>
<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>
module Result from Microsoft.FSharp.Core
--------------------
[<Struct>] type Result<'T,'TError> = | Ok of ResultValue: 'T | Error of ErrorValue: 'TError
FCQRS