Define an aggregate
An aggregate owns the rules and state required to decide commands for one entity. Choose the boundary before writing the types: every rule that must be decided atomically needs to fit inside the state of one aggregate instance. FCQRS processes its commands sequentially, eliminating races within that boundary.
The registration example gives each account one registered name. Define the
messages and the two functions in Account.fs, or the aggregate class in Account.cs:
module Account =
open FCQRS.Common
open FCQRS.FSharp
type RegisterUser = RegisterUser of name: string
type UserRegistered = UserRegistered of name: string
Fcqrs_450-how-to_002-define-an-aggregate.md_page.AccountFCQRSFCQRS.CommonContains common types like Events and Commands Functionality for Write Side.
FCQRS.FSharpIdiomatic-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 -> ...)
Fcqrs_450-how-to_002-define-an-aggregate.md_page.Account.RegisterUserRegisterUsername: stringstringAn abbreviation for the CLI type . Basic Types
Fcqrs_450-how-to_002-define-an-aggregate.md_page.Account.UserRegisteredUserRegisteredusing static FCQRS.Common;
using static FCQRS.CSharp;
public sealed record RegisterUser(string Name);
public sealed record UserRegistered(string Name);
public sealed record AccountState(string? Name = null);
let decide (command: Command<RegisterUser>) (state: string option) =
let (RegisterUser name) = command.CommandDetails
persistIf state.IsNone (UserRegistered(defaultArg state name))
let fold (event: Event<UserRegistered>) (_state: string option) =
let (UserRegistered name) = event.EventDetails
Some name
decide: Command<RegisterUser> -> string option -> EventAction<UserRegistered>command: Command<RegisterUser>FCQRS.Common.Command`1Represents a command to be processed by an aggregate actor. <typeparam name="'CommandDetails">The specific type of the command payload.</typeparam>
Fcqrs_450-how-to_002-define-an-aggregate.md_page.Account.RegisterUserstate: string optionstringAn abbreviation for the CLI type . Basic Types
optionThe type of optional values. When used from other CLI languages the empty option is the null value. Use the constructors Some and None to create values of this type. Use the values in the Option module to manipulate values of this type, or pattern match against the values directly. 'None' values will appear as the value null to other CLI languages. Instance methods on this type will appear as static methods to other CLI languages due to the use of null as a value representation. Options
RegisterUsername: stringCommandDetails: 'CommandDetailsThe specific details or payload of the command.
persistIf: bool -> 'e -> EventAction<'e>Persist the event when `shouldPersist`, else defer it (published and folded but not journalled). The deferred fold should preserve state, because it cannot be replayed. This is the idempotent "emit this verdict, write it only once" shape.
IsNone: boolReturn 'true' if the option is a 'None' value.
UserRegistereddefaultArg: 'T option -> 'T -> 'TUsed to specify a default value for an optional argument in the implementation of a function An option representing the argument. The default value of the argument. The argument value. If it is None, the defaultValue is returned. type Vector(x: double, y: double, ?z: double) = let z = defaultArg z 0.0 member this.X = x member this.Y = y member this.Z = z let v1 = Vector(1.0, 2.0) v1.Z // Evaluates to 0. let v2 = Vector(1.0, 2.0, 3.0) v2.Z // Evaluates to 3.0
fold: Event<UserRegistered> -> string option -> string optionevent: Event<UserRegistered>FCQRS.Common.Event`1Represents 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>
Fcqrs_450-how-to_002-define-an-aggregate.md_page.Account.UserRegistered_state: string optionEventDetails: 'EventDetailsThe specific details or payload of the event.
SomeThe representation of "Value of type 'T" The input value. An option representing the value.
public sealed class Account : Aggregate<AccountState, RegisterUser, UserRegistered>
{
public override string EntityName => "RegistrationCSharpAccount";
public override AccountState InitialState => new();
public override EventAction<UserRegistered> HandleCommand(
Command<RegisterUser> command, AccountState state) =>
EventActions.PersistConditionally(state.Name is null,
new UserRegistered(state.Name ?? command.CommandDetails.Name));
public override AccountState ApplyEvent(Event<UserRegistered> stored, AccountState state) =>
new(stored.EventDetails.Name);
}
The condition persists the first registration and defers subsequent replies using the saved name. The fold applies either outcome; replay applies only stored events.
Register the aggregate with the runtime before
sending commands. F# supplies the initial state and functions in the registration record; C# supplies
them through the Aggregate<,,> base class.
Fcqrs.aggregate registers the sharding region and returns an AggregateHandle with two members:
.Send cid id command filter: send a command and await the first matching aggregate reply. This does not wait for a projection; use Read your writes for that..Factory: an entity-ref factory passed to a saga so it can target this aggregate.
For an edit based on a previously observed version, use Fcqrs.sendIfVersion in F# or
SendIfVersionAsync in C#. Send at an expected version shows how to reject a
stale command before the decision function runs and handle the version conflict.
Snapshots and Passivation are the two operational fields. Both default to configuration, and
Default in each is the right answer until a measurement says otherwise: Snapshots sets how much
of the journal a recovery replays, Passivation how often a recovery happens at all. In C# they are
the overridable SnapshotPolicy and PassivationPolicy properties on Aggregate<>. See
Configuration for the resolution order and the configuration-only forms.
Bundle application handlers
From FCQRS 6.6.0, an F# application can expose account commands through a record of functions:
Shared setup
open FCQRS.Model.Data
open FCQRS.Common
open FCQRS.FSharp
FCQRSModelFCQRS.Model.DataFCQRS.CommonContains common types like Events and Commands Functionality for Write Side.
FCQRS.FSharpIdiomatic-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 -> ...)
type CommandHandlers = {
Accounts: Handler<Account.RegisterUser, Account.UserRegistered>
}
let registerHandlers actorApi accountDefinition =
{ Accounts = Fcqrs.handler actorApi accountDefinition }
Fcqrs_450-how-to_002-define-an-aggregate.md_page.CommandHandlersAccounts: Handler<Account.RegisterUser,Account.UserRegistered>HandlerAn application command handler: event filter, correlation id, aggregate id, then command. Returns the matching event payload without its envelope. Construct with Fcqrs.handler; completion does not wait for a projection.
Fcqrs_450-how-to_002-define-an-aggregate.md_page.AccountFcqrs_450-how-to_002-define-an-aggregate.md_page.Account.RegisterUserFcqrs_450-how-to_002-define-an-aggregate.md_page.Account.UserRegisteredregisterHandlers: IActor -> Aggregate<'a,Account.RegisterUser,Account.UserRegistered> -> CommandHandlersactorApi: IActoraccountDefinition: Aggregate<'a,Account.RegisterUser,Account.UserRegistered>FCQRS.FSharp.Fcqrshandler: IActor -> Aggregate<'State,'Command,'Event> -> ('Event -> bool) -> CID -> AggregateId -> 'Command -> Async<'Event>Register an aggregate immediately and return a reusable application handler. The returned function takes filter, cid, aggregate id, and command, in that order, and sends when its Async is executed. It returns EventDetails from the first matching reply; it does not wait for a projection or wire saga starters. Use aggregate instead when the caller needs the envelope or entity-ref factory.
Fcqrs.handler registers the aggregate immediately and returns a reusable function with signature
filter -> cid -> aggregateId -> command -> Async<event>. Execute the returned async computation to
send a command. It returns the matching event's payload, including deferred replies, without waiting
for a projection. Register once during startup and call Fcqrs.wireSagaStarters after registering the
aggregates and sagas, including an empty list when there are no sagas.
Keep Fcqrs.aggregate and its handle when callers need .Factory, the event version, or the
Journaled flag used for projection waiting. C# applications continue to use
the existing FCQRS.CSharp.Handler<,> delegate, which returns the full event envelope in a Task.
Choose the action
| Action | Stored | Folded into state | Returned to caller | Sent to projections |
|---|---|---|---|---|
PersistEvent event |
yes | yes | yes | yes |
DeferEvent reply |
no | yes, in memory | yes | no |
IgnoreEvent |
no | no | no | no |
UnhandledEvent |
no | no | handled as unhandled | no |
Persist a fact required to recover the aggregate. Defer a rejection or repeated verdict whose fold leaves the current state unchanged. FCQRS folds the deferred event in the live actor, but recovery cannot replay it. A state change caused only by a deferred event therefore disappears after restart. A deferred reply never wakes a journal projection subscription.
Deferring, snapshots, and passivation explains why these choices remain correct after the actor leaves memory and later recovers.
Keep replay deterministic
fold runs both after persistence and during recovery. It must not read the clock, generate ids, call
services, or write to another store. Capture changing values before persistence and put them in the
event.
decide should also remain a deterministic domain function. It may read values already carried by the
command envelope, including CreationDate, but should not perform I/O. Use a
saga for durable cross-boundary work or an
async effect for best-effort work that may be lost on restart.
Keep identities stable
The aggregate Name / EntityName identifies its sharding and persistence type. Keep it stable after events have
been written. Each entity id identifies one aggregate instance, so route every command for the same
business entity with the same id.
When a stored event payload changes shape, register Fcqrs.withEventUpcaster before the aggregate in
F# or WithEventUpcaster on the C# host builder. Evolve persisted events shows a
conversion chain and explains why journal recovery, live messages, and snapshots need separate checks.
See Aggregates and the write side for the reasoning, and Test your domain to test these two functions directly.