Header menu logo FCQRS

Test your domain

Test the domain at four levels: individual decisions, individual folds, replayed histories, and repeated commands. These tests call pure functions directly and need no actor system or database.

Use fixed envelope values so failures are reproducible:

let fixedTime = System.DateTime(2026, 1, 1, 12, 0, 0, System.DateTimeKind.Utc)

let command details : Command<_> =
    { CommandDetails = details
      CreationDate = fixedTime
      Id = Guid.CreateVersion7().ToString() |> ValueLens.CreateAsResult |> Result.value
      Sender = None
      CorrelationId = Fcqrs.newCid ()
      Metadata = Map.empty }

let event version details : Event<_> =
    { EventDetails = details
      CreationDate = fixedTime
      Id = Guid.CreateVersion7().ToString() |> ValueLens.CreateAsResult |> Result.value
      Sender = None
      CorrelationId = Fcqrs.newCid ()
      Version = version |> ValueLens.TryCreate |> Result.value
      Metadata = Map.empty }
using Microsoft.Extensions.Time.Testing;
using static FCQRS.CSharp;

var fixedTime = new FakeTimeProvider(
    new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero));

Command<T> MakeCommand<T>(T details) =>
    TestEnvelope.Command(details, fixedTime);

Event<T> MakeEvent<T>(long version, T details) where T : notnull =>
    TestEnvelope.Event(details, version, fixedTime);

FakeTimeProvider comes from the Microsoft.Extensions.TimeProvider.Testing package. Use TimeProvider.System when the rule does not inspect the envelope time and a fixed clock adds no value.

The examples below use Expect.equal from Expecto. Use the equivalent equality assertion in another test framework.

Test the decision table

These use the Document from the tutorial. CreateOrUpdate produces Updated.

let doc =
    Document.Root.TryCreate(System.Guid.NewGuid(), "Spec", "draft") |> Result.value

// a write persists Updated
let action = Document.decide (command (Document.CreateOrUpdate doc)) Document.initial

Expect.equal
    action
    (PersistEvent (Document.Updated doc))
    "creating a document stores Updated"
Document.TryCreate(Guid.NewGuid(), "Spec", "draft", out var doc, out _);
var aggregate = new DocumentAggregate();

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

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

For an idempotent verdict, such as publication confirmation in chapter 3, assert the deferred event the same way:

let publishedState: Document.State =
    { Document = Some doc
      Publication = Document.Finished("guides/fcqrs", Document.Published) }

// reporting the same result again does not add another journal entry
let action2 =
    Document.decide
        (command (Document.FinishPublication Document.Published))
        publishedState

Expect.equal
    action2
    (DeferEvent(
        Document.PublicationFinished(doc.Id, "guides/fcqrs", Document.Published)))
    "repeating the result defers the existing publication outcome"
var publication = new PublicationDocumentAggregate();
var publishedState = new DocumentState(
    doc, new PublicationProgress.Finished("guides/fcqrs", PublicationResult.Published));

var action2 = publication.HandleCommand(
    MakeCommand<DocumentCommand>(
        new DocumentCommand.FinishPublication(PublicationResult.Published)),
    publishedState);

Assert.Equal(
    EventActions.Defer<DocumentEvent>(new DocumentEvent.PublicationFinished(
        doc.Id, "guides/fcqrs", PublicationResult.Published)),
    action2);

Write one case for every meaningful command and state combination, including commands that should be ignored or unhandled.

Test one fold

fold takes an event envelope and produces the next state:

let state = Document.fold (event 1L (Document.Updated doc)) Document.initial
Expect.equal state.Document (Some doc) "Updated becomes the current document"
var state = aggregate.ApplyEvent(
    MakeEvent<DocumentEvent>(1, new DocumentEvent.Updated(doc!)),
    DocumentState.Initial);

Assert.Equal(doc, state.Document);

The envelope version is maintained by FCQRS. Do not duplicate it in domain state unless the domain has a separate version concept with different meaning.

Test replay

Fold a complete history to verify recovery:

let edited = { doc with Content = "revised" }

let recovered =
    [ event 1L (Document.Updated doc)
      event 2L (Document.Updated edited) ]
    |> List.fold (fun state stored -> Document.fold stored state) Document.initial

Expect.equal recovered.Document (Some edited) "replay recovers the latest document"
var edited = doc! with { Content = "revised" };
var history = new DocumentEvent[]
{
    new DocumentEvent.Updated(doc),
    new DocumentEvent.Updated(edited)
};

var recovered = history
    .Select((details, index) => MakeEvent<DocumentEvent>(index + 1, details))
    .Aggregate(DocumentState.Initial,
        (state, stored) => aggregate.ApplyEvent(stored, state));

Assert.Equal(edited, recovered.Document);

Use fixed events captured from an older release as compatibility fixtures. A replay test should fail if a changed fold can no longer reproduce the historical state.

Test retry behaviour

Call the same command against the state produced by its first event. A repeated command should not repeat a business effect. For the chapter 3 document, confirming an already published document returns a deferred Published reply instead of persisting another publication.

Also test boundary times and generated ids. Put the chosen value in the command or event; never let a fold read the live clock or random generator.

In C#, decide and fold are the aggregate's HandleCommand and ApplyEvent methods. The paired examples use TestEnvelope and a FakeTimeProvider; no actor system or dependency-injection container is started.

Test sagas in two layers

Test handleEvent by asserting the next saga state for each event and current state. Test applySideEffects separately by asserting its transition, target aggregate id, command payload, and delay. Include recovery cases for branches that treat recovering = true differently.

Use integration tests for actor routing, persistence plugins, projection transactions, and recovery across an actual process restart. Pure domain tests do not prove those infrastructure paths.

See Aggregates and Testing and evolution.

namespace System
namespace Expecto
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>
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
Multiple items
val string: value: 'T -> string

--------------------
type string = String
val id: Guid
val title: string
val content: string
Multiple items
type String = interface IEnumerable<char> interface IEnumerable interface ICloneable interface IComparable interface IComparable<string> interface IConvertible interface IEquatable<string> interface IParsable<string> interface ISpanParsable<string> new: value: nativeptr<char> -> unit + 8 overloads ...
<summary>Represents text as a sequence of UTF-16 code units.</summary>

--------------------
String(value: nativeptr<char>) : String
String(value: char array) : String
String(value: ReadOnlySpan<char>) : String
String(value: nativeptr<sbyte>) : String
String(c: char, count: int) : String
String(value: nativeptr<char>, startIndex: int, length: int) : String
String(value: char array, startIndex: int, length: int) : String
String(value: nativeptr<sbyte>, startIndex: int, length: int) : String
String(value: nativeptr<sbyte>, startIndex: int, length: int, enc: Text.Encoding) : String
String.IsNullOrWhiteSpace(value: string) : bool
union case Result.Error: ErrorValue: 'TError -> Result<'T,'TError>
union case Result.Ok: ResultValue: 'T -> Result<'T,'TError>
type PublicationResult = | Published | Rejected
type PublicationStatus = | NotRequested | WaitingForSlug of slug: string | Finished of slug: string * PublicationResult
Multiple items
type State = { Document: Root option Publication: PublicationStatus }

--------------------
type State<'Command,'Event> = { CommandDetails: CommandDetails<'Command,'Event> Sender: IActorRef }
type Root = { Id: Guid Title: string Content: string } static member TryCreate: id: Guid * title: string * content: string -> Result<Root,string>
type 'T option = Option<'T>
val initial: State
union case Option.None: Option<'T>
union case PublicationStatus.NotRequested: PublicationStatus
Multiple items
type Command = | CreateOrUpdate of Root | Publish of slug: string | FinishPublication of PublicationResult

--------------------
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>
union case Notify.Publish: Notify
Multiple items
module Event from Microsoft.FSharp.Control

--------------------
type Event = | Updated of Root | PublicationRequested of Guid * slug: string | PublicationFinished of Guid * slug: string * PublicationResult

--------------------
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: command: Command<Command> -> state: State -> EventAction<Event>
val command: Command<Command>
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>
val state: State
Command.CommandDetails: Command
<summary> The specific details or payload of the command. </summary>
union case Command.CreateOrUpdate: Root -> Command
val document: 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>
union case Command.Publish: slug: string -> Command
val slug: string
union case Option.Some: Value: 'T -> Option<'T>
val doc: Root
union case Event.PublicationRequested: Guid * slug: string -> Event
Root.Id: Guid
union case PublicationStatus.WaitingForSlug: slug: string -> PublicationStatus
val currentSlug: string
union case EventAction.DeferEvent: 'T -> EventAction<'T>
<summary> Publish and fold the event in the live actor without storing it or incrementing the persisted version. </summary>
union case Command.FinishPublication: PublicationResult -> Command
val result: PublicationResult
union case Event.PublicationFinished: Guid * slug: string * PublicationResult -> Event
union case PublicationStatus.Finished: slug: string * PublicationResult -> PublicationStatus
val current: PublicationResult
union case EventAction.UnhandledEvent: EventAction<'T>
<summary> Indicate that the command or event could not be handled in the current state. </summary>
val fold: event: Event<Event> -> state: State -> State
val event: Event<Event>
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>
Event.EventDetails: Event
<summary> The specific details or payload of the event. </summary>
val fixedTime: DateTime
Multiple items
[<Struct>] type DateTime = new: date: DateOnly * time: TimeOnly -> unit + 16 overloads member Add: value: TimeSpan -> DateTime member AddDays: value: float -> DateTime member AddHours: value: float -> DateTime member AddMicroseconds: value: float -> DateTime member AddMilliseconds: value: float -> DateTime member AddMinutes: value: float -> DateTime member AddMonths: months: int -> DateTime member AddSeconds: value: float -> DateTime member AddTicks: value: int64 -> DateTime ...
<summary>Represents an instant in time, typically expressed as a date and time of day.</summary>

--------------------
DateTime ()
   (+0 other overloads)
DateTime(ticks: int64) : DateTime
   (+0 other overloads)
DateTime(date: DateOnly, time: TimeOnly) : DateTime
   (+0 other overloads)
DateTime(ticks: int64, kind: DateTimeKind) : DateTime
   (+0 other overloads)
DateTime(date: DateOnly, time: TimeOnly, kind: DateTimeKind) : DateTime
   (+0 other overloads)
DateTime(year: int, month: int, day: int) : DateTime
   (+0 other overloads)
DateTime(year: int, month: int, day: int, calendar: Globalization.Calendar) : DateTime
   (+0 other overloads)
DateTime(year: int, month: int, day: int, hour: int, minute: int, second: int) : DateTime
   (+0 other overloads)
DateTime(year: int, month: int, day: int, hour: int, minute: int, second: int, kind: DateTimeKind) : DateTime
   (+0 other overloads)
DateTime(year: int, month: int, day: int, hour: int, minute: int, second: int, calendar: Globalization.Calendar) : DateTime
   (+0 other overloads)
[<Struct>] type DateTimeKind = | Unspecified = 0 | Utc = 1 | Local = 2
<summary>Specifies whether a <see cref="T:System.DateTime" /> object represents a local time, a Coordinated Universal Time (UTC), or is not specified as either local time or UTC.</summary>
field DateTimeKind.Utc: DateTimeKind = 1
val command: details: 'a -> Command<'a>
val details: 'a
Multiple items
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>
type CommandDetails<'Command,'Event> = { EntityRef: IEntityRef<obj> Cmd: Command<'Command> Filter: ('Event -> bool) }
Guid.CreateVersion7() : Guid
Guid.CreateVersion7(timestamp: DateTimeOffset) : Guid
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.CreateAsResult: v: 'a -> Result<'b,'d> (requires member Value_ and member Value_)
Multiple items
module Result from FCQRS.Model.Data

--------------------
module Result from Microsoft.FSharp.Core

--------------------
[<Struct>] type Result<'T,'TError> = | Ok of ResultValue: 'T | Error of ErrorValue: 'TError
val value: e: Result<'a,'b> -> 'a
union case TargetActor.Sender: TargetActor
<summary> Specifies the target as the original sender of the message that triggered the current saga step. NOTE: side effects run inside persist re-injections, where the ambient sender is the journal actor, or in the subscription-ack re-drive, where it is the pub-sub mediator — never the original trigger. Commands to Sender therefore dead-letter; the saga logs a warning at resolution. Use FactoryAndName with Originator to reach the originator instead. </summary>
module Fcqrs from FCQRS.FSharp
val newCid: unit -> CID
<summary> A fresh correlation id (UUID v7). </summary>
Multiple items
module Map from Microsoft.FSharp.Collections

--------------------
type Map<'Key,'Value (requires comparison)> = interface IReadOnlyDictionary<'Key,'Value> interface IReadOnlyCollection<KeyValuePair<'Key,'Value>> interface IEnumerable interface IStructuralEquatable interface IComparable interface IEnumerable<KeyValuePair<'Key,'Value>> interface ICollection<KeyValuePair<'Key,'Value>> interface IDictionary<'Key,'Value> new: elements: ('Key * 'Value) seq -> Map<'Key,'Value> member Add: key: 'Key * value: 'Value -> Map<'Key,'Value> ...

--------------------
new: elements: ('Key * 'Value) seq -> Map<'Key,'Value>
val empty<'Key,'T (requires comparison)> : Map<'Key,'T> (requires comparison)
val event: version: int64 -> details: 'a -> Event<'a>
val version: int64
Multiple items
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. &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>
type Version = private | Version of int64 member Equals: Version * IEqualityComparer -> bool override ToString: unit -> string static member Value_: (Version -> int64) * (int64 -> Version -> Result<Version,ModelError>) static member Zero: Version
<summary> Aggregate Version </summary>
static member ValueLens.TryCreate: innerValue: 'Inner -> Result<'Wrapped,'Error> (requires member Value_)
val doc: Document.Root
module Document from Test-your-domain
static member Document.Root.TryCreate: id: Guid * title: string * content: string -> Result<Document.Root,string>
Guid.NewGuid() : Guid
val action: EventAction<Document.Event>
val decide: command: Command<Document.Command> -> state: Document.State -> EventAction<Document.Event>
union case Document.Command.CreateOrUpdate: Document.Root -> Document.Command
val initial: Document.State
module Expect from Expecto
<summary> A module for specifying what you expect from the values generated by your tests. </summary>
val equal: actual: 'a -> expected: 'a -> message: string -> unit (requires equality)
<summary> Expects the two values to equal each other. </summary>
union case Document.Event.Updated: Document.Root -> Document.Event
val publishedState: Document.State
type State = { Document: Root option Publication: PublicationStatus }
union case Document.PublicationStatus.Finished: slug: string * Document.PublicationResult -> Document.PublicationStatus
union case Document.PublicationResult.Published: Document.PublicationResult
val action2: EventAction<Document.Event>
union case Document.Command.FinishPublication: Document.PublicationResult -> Document.Command
union case Document.Event.PublicationFinished: Guid * slug: string * Document.PublicationResult -> Document.Event
Document.Root.Id: Guid
val state: Document.State
val fold: event: Event<Document.Event> -> state: Document.State -> Document.State
Document.State.Document: Document.Root option
val edited: Document.Root
val recovered: Document.State
Multiple items
module List from Microsoft.FSharp.Collections

--------------------
type List<'T> = | op_Nil | op_ColonColon of Head: 'T * Tail: 'T list interface IReadOnlyList<'T> interface IReadOnlyCollection<'T> interface IEnumerable interface IEnumerable<'T> member GetReverseIndex: rank: int * offset: int -> int member GetSlice: startIndex: int option * endIndex: int option -> 'T list static member Cons: head: 'T * tail: 'T list -> 'T list member Head: 'T member IsEmpty: bool member Item: index: int -> 'T with get ...
val fold<'T,'State> : folder: ('State -> 'T -> 'State) -> state: 'State -> list: 'T list -> 'State
val stored: Event<Document.Event>

Type something to start searching.