Header menu logo FCQRS

1. The aggregate

Most applications store the current document. Saving a new body replaces the old one. An event-sourced document stores facts such as DocumentCreated and ContentEdited, then derives the current document by applying those facts in order.

This chapter models that write side. It uses no actor system or database yet. You will define the domain values, separate requests from recorded outcomes, and write the two pure functions FCQRS runs inside an aggregate.

Course position: the quickstart showed the whole request path. This chapter isolates its first part, the aggregate decision. By the end you will be able to explain and test command, event, state, decide, and fold without running Akka.NET.

This wireframe is the chapter's result: validated values, message types, and three functions with their bodies left unimplemented. The rest of the chapter fills them in.

// Values: Title and Content, validated string types that reject illegal values.
// Document.Root, State, Command, and Event: the document and its message types.

module Document =
    let decide (cmd: Command<Command>) (state: State) : EventAction<Event> =
        failwith "not written yet"

    let fold (event: Event<Event>) (state: State) : State =
        failwith "not written yet"

    let register (api: IActor) : AggregateHandle<Command, Event> =
        failwith "not written yet"
// Title and Content: validated string types that reject illegal values.
// Document, DocumentState, DocumentCommand, and DocumentEvent: the document
// and its message types.

public sealed class DocumentAggregate
    : Aggregate<DocumentState, DocumentCommand, DocumentEvent>
{
    public override DocumentState InitialState =>
        throw new NotImplementedException();
    public override string EntityName => "Document";

    public override EventAction<DocumentEvent> HandleCommand(
        Command<DocumentCommand> cmd, DocumentState state) =>
        throw new NotImplementedException();

    public override DocumentState ApplyEvent(
        Event<DocumentEvent> evt, DocumentState state) =>
        throw new NotImplementedException();
}

Commands and events are not the same thing

A command asks the system to do something, such as CreateOrUpdate. The aggregate may accept or reject it. An event records an outcome, such as Updated or Rejected. A stored event is already a fact and is not edited when a later command arrives.

Commands and events are different types because one request can have several outcomes. Keeping them separate also lets the publication workflow in chapter 3 add a new outcome without pretending that every command succeeds.

Motivation: A command records intent; an event records what the domain decided. Keeping those moments separate makes rejection explicit and prevents an unaccepted request from entering history as though it were a fact.

An aggregate owns the state and rules needed to make decisions about one entity. FCQRS runs each aggregate as an actor that handles one command at a time. Sequential handling eliminates races within that aggregate. Rules spanning several aggregates require coordination, which chapter 3 introduces.

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
using System;
using System.Diagnostics.CodeAnalysis;
using FCQRS;
using static FCQRS.Common;
using static FCQRS.CSharp;
using static FCQRS.Model.CSharp;
using static FCQRS.Model.Data;

Make the illegal values impossible to type

A title and document body have different meaning even though both arrive as strings. Separate domain types prevent them from being swapped and provide one place to reject invalid input. FCQRS provides validated ShortString and LongString values through ValueLens; the document wraps them as Title and Content.

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
// C#: validated value objects wrap FCQRS's ShortString / LongString.
public readonly record struct DocumentId(Guid Value)
{
    public static DocumentId OfGuid(Guid g) => new(g);
    public override string ToString() => Value.ToString();
}

public readonly record struct Title(ShortString Value)
{
    public static bool TryCreate(string s, [NotNullWhen(true)] out Title result)
    {
        if (StringTypes.TryCreateShortString(s, out var v)) { result = new Title(v); return true; }
        result = default; return false;
    }
}

public readonly record struct Content(LongString Value)
{
    public static bool TryCreate(string s, [NotNullWhen(true)] out Content result)
    {
        if (StringTypes.TryCreateLongString(s, out var v)) { result = new Content(v); return true; }
        result = default; return false;
    }
}

TryCreate returns a Result, so invalid input is handled where raw strings enter the application. After construction, Title and Content carry validated values and the aggregate does not repeat the same validation.

State, command, event

Root.TryCreate validates the title and content together and returns either a complete document or one error. Commands therefore carry a complete Root, not a mixture of raw and validated fields.

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
public sealed record Document(DocumentId Id, Title Title, Content Content)
{
    public static bool TryCreate(
        Guid id,
        string title,
        string content,
        [NotNullWhen(true)] out Document? result,
        [NotNullWhen(false)] out string? error)
    {
        if (!Title.TryCreate(title, out var validTitle))
        {
            result = null;
            error = "Invalid title";
            return false;
        }

        if (!Content.TryCreate(content, out var validContent))
        {
            result = null;
            error = "Invalid content";
            return false;
        }

        result = new Document(DocumentId.OfGuid(id), validTitle, validContent);
        error = null;
        return true;
    }
}

State is the value FCQRS keeps in the actor and rebuilds during recovery. Before any event has been stored, the document is absent:

    type State = { Document: Root option }
    let initial = { Document = None }
public sealed record DocumentState(Document? Document = null)
{
    public static readonly DocumentState Initial = new();
}

The first model has one command and one event. Chapter 3 adds a separate Publish request with several possible outcomes. Defining commands and events separately now leaves room for that growth.

    type Command = CreateOrUpdate of Root

    type Event = Updated of Root
// C#: commands and events are separate C# union types.
public union DocumentCommand(DocumentCommand.CreateOrUpdate)
{
    public record CreateOrUpdate(Document Document);
}

public union DocumentEvent(DocumentEvent.Updated)
{
    public record Updated(Document Document);
}

Decide what the command means

decide receives a command envelope and the current state. The envelope carries the command payload, creation time, correlation id, and metadata. The function returns an EventAction describing what FCQRS should do. It performs no mutation or I/O itself.

    let decide (cmd: Command<Command>) state =
        match cmd.CommandDetails with
        | CreateOrUpdate doc -> Updated doc |> PersistEvent
// C#: decide is the aggregate's HandleCommand method (a switch expression).
public override EventAction<DocumentEvent> HandleCommand(
    Command<DocumentCommand> cmd, DocumentState state) =>
    cmd.CommandDetails switch
    {
        DocumentCommand.CreateOrUpdate c =>
            EventActions.Persist<DocumentEvent>(new DocumentEvent.Updated(c.Document)),
        _ => EventActions.Ignore<DocumentEvent>()
    };

The common actions are:

Persist only facts needed to reconstruct the aggregate. Operational auditing of rejected attempts belongs in logs or a separate audit model unless the rejection itself changes the domain.

fold: rebuild the present from the past

FCQRS calls fold after persisting a new event and again when replaying stored events during recovery. The same event sequence must produce the same state in both cases.

    let fold (event: Event<Event>) state =
        match event.EventDetails with
        | Updated doc -> { Document = Some doc }
// C#: fold is the aggregate's ApplyEvent method.
public override DocumentState ApplyEvent(Event<DocumentEvent> evt, DocumentState state) =>
    evt.EventDetails switch
    {
        DocumentEvent.Updated e => state with { Document = e.Document },
        _ => state
    };

fold must not read the clock, generate random values, or perform I/O. If a decision needs the current time, read the creation time from the command and include the relevant value in the event. Recovery then uses the value that was recorded when the decision was made.

Bind the functions to an actor

Fcqrs.aggregate registers the functions with the actor system and returns a typed handle used to send commands. The actor lifecycle, sharding, persistence, and recovery stay outside the domain functions.

    let register (api: IActor) =
        Fcqrs.aggregate api
            { Name = "Document"
              Initial = initial
              Decide = decide
              Fold = fold
              Snapshots = Default }        // snapshot cadence: Default | NoSnapshots | Every n
// C#: HandleCommand/ApplyEvent live on a class deriving Aggregate<>, registered
// through the DI host-builder (the C# counterpart of Fcqrs.aggregate).
public sealed class DocumentAggregate : Aggregate<DocumentState, DocumentCommand, DocumentEvent>
{
    public override DocumentState InitialState => DocumentState.Initial;
    public override string EntityName => "Document";
    // HandleCommand and ApplyEvent as shown above.
}

services
    .AddFcqrs("Data Source=tutorial.db;", "tutorial")
    .AddAggregate<DocumentAggregate, DocumentState, DocumentCommand, DocumentEvent>();

Name, Initial, Decide, Fold, and Snapshots form the aggregate definition. The domain functions do not depend on the actor implementation.

What you now understand

The write model now has four distinct parts: a command requests a change, decide chooses an action, a persisted event records the result, and fold derives state from stored events. You can test each decision with ordinary function calls:

let doc = Document.Root.TryCreate(System.Guid.NewGuid(), "Spec", "draft") |> Result.value
// decide returns an action, so the test asserts on that value.
let action = Document.decide (cmd (Document.CreateOrUpdate doc)) Document.initial
// => PersistEvent (Updated doc)
Document.TryCreate(Guid.NewGuid(), "Spec", "draft", out var doc, out _);
var aggregate = new DocumentAggregate();

var action = aggregate.HandleCommand(
    TestEnvelope.Command<DocumentCommand>(
        new DocumentCommand.CreateOrUpdate(doc!)),
    DocumentState.Initial);

Assert.Equal(
    EventActions.Persist<DocumentEvent>(new DocumentEvent.Updated(doc!)),
    action);

Common mistakes

Continue the learning path

Next, run the aggregate and project its events. Chapter 2 uses the domain model you just built and introduces the runtime, journal, projection, and query path.

After completing chapter 2, use Aggregates and the write side for a deeper boundary-design discussion or Define an aggregate as the short implementation recipe. Neither is required before continuing.

namespace System
namespace FCQRS
module Common from FCQRS
<summary> Contains common types like Events and Commands </summary>
<namespacedoc><summary>Functionality for Write Side.</summary></namespacedoc>
namespace FCQRS.Model
module Data from FCQRS.Model
module FSharp from FCQRS
<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 -&gt; ...) </summary>
module Document from theaggregate
val decide: cmd: 'a -> state: 'b -> 'c
val cmd: 'a
val state: 'b
Multiple items
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 failwith: message: string -> 'T
val fold: event: 'a -> state: 'b -> 'c
val event: 'a
val register: api: 'a -> 'b
val api: 'a
type DocumentId = | DocumentId of Guid override ToString: unit -> string static member OfGuid: g: Guid -> DocumentId member Value: Guid
Multiple items
[<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
val g: Guid
Multiple items
union case DocumentId.DocumentId: Guid -> DocumentId

--------------------
type DocumentId = | DocumentId of Guid override ToString: unit -> string static member OfGuid: g: Guid -> DocumentId member Value: Guid
val this: DocumentId
Guid.ToString() : string
Guid.ToString(format: string) : string
Guid.ToString(format: string, provider: IFormatProvider) : string
type Title = | Title of ShortString static member TryCreate: s: string -> Result<Title,string> member Value: string
type ShortString = private | ShortString of string member Equals: ShortString * IEqualityComparer -> bool override ToString: unit -> string member IsValid: bool static member Value_: (ShortString -> string) * (string -> ShortString -> Result<ShortString,ModelError list>)
<summary> Validated non-blank string up to 255 chars inclusive. </summary>
val s: string
type ValueLens = static member Create: innerValue: 'Inner -> 'Wrapped (requires member Value_) static member CreateAsResult: v: 'a -> Result<'b,'d> (requires member Value_ and member Value_) static member IsValidValue: this: 'Wrapped -> bool (requires member Value_) static member ToString: this: 'Wrapped -> string (requires member Value_) static member TryCreate: innerValue: 'Inner -> Result<'Wrapped,'Error> (requires member Value_) static member Value: this: 'Wrapped -> 'Inner (requires member Value_) + 1 overload
static member ValueLens.TryCreate: innerValue: 'Inner -> Result<'Wrapped,'Error> (requires member Value_)
union case Result.Ok: ResultValue: 'T -> Result<'T,'TError>
val ss: ShortString
Multiple items
union case Title.Title: ShortString -> Title

--------------------
type Title = | Title of ShortString static member TryCreate: s: string -> Result<Title,string> member Value: string
union case Result.Error: ErrorValue: 'TError -> Result<'T,'TError>
val this: Title
val s: ShortString
static member ValueLens.Value: this: 'Wrapped -> 'Inner (requires member Value_)
static member ValueLens.Value: this: 'Wrapped -> 'Inner (requires member Value_)
type Content = | Content of LongString static member TryCreate: s: string -> Result<Content,string> member Value: string
type LongString = private | LongString of string member Equals: LongString * IEqualityComparer -> bool override ToString: unit -> string member IsValid: bool static member Value_: (LongString -> string) * (string -> LongString -> Result<LongString,ModelError list>)
<summary> Represents any string at least 1 chars </summary>
val ss: LongString
Multiple items
union case Content.Content: LongString -> Content

--------------------
type Content = | Content of LongString static member TryCreate: s: string -> Result<Content,string> member Value: string
val this: Content
val s: LongString
module Document from 1-the-aggregate
module Values from 1-the-aggregate
val guid: Guid
val title: string
val content: string
static member Title.TryCreate: s: string -> Result<Title,string>
static member Content.TryCreate: s: string -> Result<Content,string>
val t: Title
val c: Content
static member DocumentId.OfGuid: g: Guid -> DocumentId
val e: string
Multiple items
type State = { Document: Root option }

--------------------
type State<'Command,'Event> = { CommandDetails: CommandDetails<'Command,'Event> Sender: IActorRef }
type Root = { Id: DocumentId Title: Title Content: Content } static member TryCreate: guid: Guid * title: string * content: string -> Result<Root,string>
type 'T option = Option<'T>
val initial: State
union case Option.None: Option<'T>
Multiple items
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. &lt;typeparam name="'CommandDetails"&gt;The specific type of the command payload.&lt;/typeparam&gt; </summary>

--------------------
type Command<'Command,'Event> = | Execute of CommandDetails<'Command,'Event>
<summary> Represents the message sent to the internal subscription mechanism. &lt;typeparam name="'Command"&gt;The type of the command payload.&lt;/typeparam&gt; &lt;typeparam name="'Event"&gt;The type of the expected event payload.&lt;/typeparam&gt; </summary>
Multiple items
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. &lt;typeparam name="'EventDetails"&gt;The specific type of the event payload.&lt;/typeparam&gt; </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>
val decide: cmd: Command<Command> -> state: 'a -> EventAction<Event>
val cmd: Command<Command>
val state: 'a
Command.CommandDetails: Command
<summary> The specific details or payload of the command. </summary>
union case Command.CreateOrUpdate: Root -> Command
val doc: Root
union case Event.Updated: Root -> Event
union case EventAction.PersistEvent: 'T -> EventAction<'T>
<summary> Persist the event to the journal. The actor's state will be updated using the event handler *after* persistence succeeds. </summary>
val fold: event: Event<Event> -> state: 'a -> State
val event: Event<Event>
Event.EventDetails: Event
<summary> The specific details or payload of the event. </summary>
union case Option.Some: Value: 'T -> Option<'T>
val register: api: IActor -> AggregateHandle<Command,Event>
val api: IActor
type IActor = abstract CreateCommandSubscription: (string -> IEntityRef<obj>) -> CID -> AggregateId -> 'b -> ('c -> bool) -> Map<string,string> option -> Async<Event<'c>> abstract InitializeActor: 'a -> string -> (Command<'c> -> 'a -> EventAction<'b>) -> (Event<'b> -> 'a -> 'a) -> SnapshotPolicy -> EntityFac<obj> abstract InitializeActorWithRunner: 'a -> string -> (Command<'c> -> 'a -> EventAction<'b>) -> (Event<'b> -> 'a -> 'a) -> SnapshotPolicy -> (obj -> Async<obj>) option -> EntityFac<obj> abstract InitializeSaga: SagaState<'SagaState,'State> -> (obj -> SagaState<'SagaState,'State> -> EventAction<'State>) -> (SagaState<'SagaState,'State> -> SagaStartingEvent<Event<'c>> option -> bool -> SagaTransition<'State> * ExecuteCommand list) -> (SagaState<'SagaState,'State> -> SagaState<'SagaState,'State>) -> string -> SnapshotPolicy -> EntityFac<obj> abstract InitializeSagaStarter: (obj -> ((string -> IEntityRef<obj>) * PrefixConversion * obj) list) -> unit + 1 overload abstract Stop: unit -> Task abstract SubscribeForCommand: Command<'a,'b> -> Async<Event<'b>> abstract Configuration: IConfiguration abstract LoggerFactory: ILoggerFactory abstract Materializer: ActorMaterializer ...
<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>
module Fcqrs from FCQRS.FSharp
val aggregate: api: IActor -> def: Aggregate<'State,'Command,'Event> -> AggregateHandle<'Command,'Event>
<summary> Register an aggregate and return its typed handle. Calling this IS the registration (it initializes the sharding region). </summary>
union case TargetName.Name: string -> TargetName
<summary> Identify the target by its string name (entity ID). </summary>
union case SnapshotPolicy.Default: SnapshotPolicy
<summary> Use the global config (config:akka:persistence:snapshot-version-count), or 30. </summary>
Multiple items
[<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>

--------------------
System.Guid ()
System.Guid(b: byte array) : System.Guid
System.Guid(b: System.ReadOnlySpan<byte>) : System.Guid
System.Guid(g: string) : System.Guid
System.Guid(b: System.ReadOnlySpan<byte>, bigEndian: bool) : System.Guid
System.Guid(a: int, b: int16, c: int16, d: byte array) : System.Guid
System.Guid(a: int, b: int16, c: int16, d: byte, e: byte, f: byte, g: byte, h: byte, i: byte, j: byte, k: byte) : System.Guid
System.Guid(a: uint32, b: uint16, c: uint16, d: byte, e: byte, f: byte, g: byte, h: byte, i: byte, j: byte, k: byte) : System.Guid
System.Guid.NewGuid() : System.Guid
Multiple items
module Result from Microsoft.FSharp.Core

--------------------
[<Struct>] type Result<'T,'TError> = | Ok of ResultValue: 'T | Error of ErrorValue: 'TError

Type something to start searching.