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.
Motivation: Choose the boundary before the code because sequential handling can protect only the facts inside it. No handler implementation can make an invariant atomic after its required state has been split across independent aggregates.
The implementation has three domain types and two pure functions. This example extends the tutorial's
document aggregate with a Delete command so all three common actions appear:
open FCQRS.Common
open FCQRS.FSharp
type State = { Document: Root option }
let initial = { Document = None }
type Command =
| CreateOrUpdate of Root
| Delete
type Event =
| Updated of Root
| Deleted
| DocumentNotFound
// decide (handleCommand): command + state -> action
let decide (cmd: Command<Command>) state =
match cmd.CommandDetails, state.Document with
| CreateOrUpdate doc, _ -> Updated doc |> PersistEvent
| Delete, Some _ -> Deleted |> PersistEvent
| Delete, None -> DocumentNotFound |> DeferEvent
// fold (applyEvent): event -> new state
let fold (event: Event<Event>) state =
match event.EventDetails with
| Updated doc -> { Document = Some doc }
| Deleted -> { Document = None }
| DocumentNotFound -> state
// Registering the aggregate returns its typed handle.
let register (api: IActor) =
Fcqrs.aggregate api
{ Name = "Document"; Initial = initial; Decide = decide; Fold = fold
Snapshots = Default } // snapshot cadence: Default | NoSnapshots | Every n
Every Command case is covered above, so no catch-all is needed. When you do add one, choose
UnhandledEvent for "this command is not valid here" (the caller's wait times out with a
TimeoutException) and IgnoreEvent when producing no reply is the intended outcome.
|
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.
Choose the action
Action |
Stored |
Folded into state |
Returned to caller |
Sent to projections |
|---|---|---|---|---|
|
yes |
yes |
yes |
yes |
|
no |
yes, in memory |
yes |
no |
|
no |
no |
no |
no |
|
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 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.
See Aggregates and the write side for the reasoning, and Test your domain to test these two functions directly.
module Event from Microsoft.FSharp.Control
--------------------
type Event = | Updated of obj | Deleted | DocumentNotFound
--------------------
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>
FCQRS