Aggregates and the write side
An aggregate is easier to understand as a promise before it is understood as an actor or a type: all decisions for one identity inspect and change one current state, one at a time.
For account alice, every Deposit, Withdraw, and SendTransfer command is routed to the same
logical owner. Commands for account bob have a different owner and may run concurrently.
Begin with the rule that must never race
Suppose a withdrawal must not overdraw the account. Two withdrawals of 60 arrive together, and the balance is 70. If separate request handlers load the same database row, both can observe 70 and both can pass their rule before either save becomes visible.
The aggregate boundary places the state and both decisions behind one queue. One command runs first, produces an event, and changes current state. The second command sees that new state, a balance of 10, and is rejected.
The guarantee is local to one aggregate identity. It does not lock all accounts, and it does not make a rule spanning two accounts, such as a transfer, atomic.
When a caller's edit depends on an earlier version, use Send at an expected version. FCQRS compares that version with the aggregate's persisted version before deciding the command, so a stale edit can be rejected even if it would otherwise satisfy the domain rules.
Motivation: Routing one identity through one queue turns “check the rule, then save” into one ordered decision. The second command cannot make its choice from the state that existed before the first command completed.
Find the boundary from invariants
An invariant is a rule that must remain true after every accepted command. Examples include:
- an account cannot be overdrawn;
- an account opens only once;
- a transfer ID moves money at most once.
Put the state needed to decide one invariant inside one aggregate. Do not start by copying a database entity graph into aggregate state. Start from the decision and ask which facts it must inspect atomically.
Larger boundaries can enforce more rules in one decision, but they also serialize more unrelated work, recover longer histories, and create contention around one identity. Smaller boundaries increase parallelism but move cross-boundary work into sagas. The goal is the smallest boundary that can decide the rule correctly.
The domain has four parts
An FCQRS aggregate definition separates four values:
| Part | Question it answers | Account example |
|---|---|---|
| Command | What does the caller want? | Withdraw 60 |
| State | What must this aggregate remember to decide? | the owner and the balance |
| Event | What outcome occurred? | Withdrawn 60 |
| Initial state | What is true before any events exist? | no owner, balance 0 |
It also provides two functions:
decide : command + current state -> event action
fold : event + current state -> next state
decide contains the business rule. fold contains no decision: it applies an outcome that has
already happened.
For example:
Shared setup
open System
open FCQRS.Model.Data
open FCQRS.Common
open FCQRS.FSharp
type AccountCommand = Withdraw of amount: decimal
type AccountState = { Balance: decimal }
type AccountEvent =
| Withdrawn of amount: decimal
| Rejected of reason: string
SystemFCQRSModelFCQRS.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 / Projections.startTracked / 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_350-concepts_003-aggregates.md_page.AccountCommandWithdrawamount: decimaldecimalAn abbreviation for the CLI type . Basic Types
Fcqrs_350-concepts_003-aggregates.md_page.AccountStateBalance: decimalFcqrs_350-concepts_003-aggregates.md_page.AccountEventWithdrawnRejectedreason: stringstringAn abbreviation for the CLI type . Basic Types
let decide command state =
match command with
| Withdraw amount when amount > state.Balance ->
Rejected "Insufficient funds" |> DeferEvent
| Withdraw amount -> Withdrawn amount |> PersistEvent
let fold event state =
match event with
| Withdrawn amount -> { state with Balance = state.Balance - amount }
| Rejected _ -> state
decide: AccountCommand -> AccountState -> EventAction<AccountEvent>command: AccountCommandstate: AccountStateWithdrawamount: decimal(>): 'T -> 'T -> boolStructural greater-than The first parameter. The second parameter. The result of the comparison. 5 > 1 // Evaluates to true 5 > 5 // Evaluates to false (1, "a") > (1, "z") // Evaluates to false
Balance: decimalRejected(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
DeferEventPublish and fold the event in the live actor without storing it or incrementing the persisted version. A deferred event does not start a saga; running sagas still receive it.
WithdrawnPersistEventPersist the event to the journal. The actor's state will be updated using the event handler *after* persistence succeeds.
fold: AccountEvent -> AccountState -> AccountStateevent: AccountEventFcqrs_350-concepts_003-aggregates.md_page.AccountStateEventAction<AccountEvent> HandleCommand(
AccountCommand command, AccountState state) =>
command switch
{
Withdraw withdraw when withdraw.Amount > state.Balance =>
EventActions.Defer<AccountEvent>(
new Rejected("Insufficient funds")),
Withdraw withdraw =>
EventActions.Persist<AccountEvent>(new Withdrawn(withdraw.Amount))
};
AccountState ApplyEvent(AccountEvent outcome, AccountState state) =>
outcome switch
{
Withdrawn withdrawn =>
state with { Balance = state.Balance - withdrawn.Amount },
Rejected => state
};
The real functions receive FCQRS command and event envelopes, and handle every command, as the
tutorial's second step shows. decide and fold keep the same
roles there.
Choose what becomes history
An EventAction tells FCQRS what to do with the outcome. The F# names are shown; the C# equivalents
are EventActions.Persist, EventActions.Defer, and EventActions.Ignore.
| Action | Stored | Folded live | Published to projections | Typical use |
|---|---|---|---|---|
PersistEvent |
yes | yes | yes | a fact needed for recovery |
DeferEvent |
no | yes | no | a rejection or repeated verdict |
IgnoreEvent |
no | no | no | intentionally no outcome |
A persisted event increments the aggregate's persisted version. A deferred event does not. FCQRS folds a deferred event in the live actor, but recovery cannot replay it because it is absent from the journal. Its fold should therefore preserve state. If a deferred event changes state, that change disappears after restart.
FCQRS also exposes PersistAllEvents (an atomic batch for one aggregate), PersistAndSnapshot (a
manual checkpoint), PublishEvent (publish without persisting or folding), and UnhandledEvent
(the command is not valid for this state; the caller's wait times out). The
aggregate how-to gives the complete action table. The conceptual
choice remains: persist every fact required to reconstruct the future decision state.
Deferring, snapshots, and passivation follows the stored and transient paths through recovery in detail.
Recovery explains the purity rules
FCQRS keeps aggregate state in memory while the actor is active. When the actor starts again, it loads
the latest snapshot if one exists, then replays later journal events through fold.
That is why fold must not read the clock, generate an id, call HTTP, send e-mail, or write another
database. Replay is rebuilding memory, not repeating the past. Every value that affects future state
must already be inside the stored event.
Keep decide free of I/O as well. Put input such as the current time or a generated id into the command
before it reaches the aggregate. Durable cross-boundary work belongs in a saga. Best-effort work that
may safely be lost can use an async effect.
The actor is the runtime boundary
In FCQRS, each active aggregate identity is represented by an Akka.NET actor. An actor processes one mailbox message at a time. Cluster sharding locates the actor across nodes, so callers address the aggregate by its type and entity id rather than by server.
Passivation may stop an idle actor. The next command activates it and recovery rebuilds state. The actor is therefore not a permanently allocated object, and in-memory state is a cache of journaled history rather than the source of truth.
Sequential processing prevents races inside the boundary. It does not guarantee that projections are current, that a command to another aggregate succeeds, or that an external service performs an operation exactly once.
Test the model before the runtime
Because the rule and fold are functions, the most valuable tests do not start Akka.NET:
- Given a state, send a command and assert the selected event action.
- Given a state, apply one event and assert the next state.
- Given a complete history, fold every event and assert the recovered state.
- Verify every deferred event leaves recoverable state unchanged.
If those tests are difficult to write, the aggregate may own too many responsibilities or perform work that belongs outside the decision.
Check your boundary
Write one invariant as a sentence. List every fact needed to decide it. Give the owner a stable identity. Then ask whether two instances may decide independently. If they cannot, they are probably inside the same aggregate boundary. If the rule truly spans independent owners, model the temporary inconsistency and coordinate it with a saga.
The tutorial puts these rules into code. Use Define an aggregate for the implementation recipe and Test your domain for the test shapes.