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
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
EventAction<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.

A command is routed to one aggregate identity, decided against current state, persisted, folded, and published

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:

  1. Given a state, send a command and assert the selected event action.
  2. Given a state, apply one event and assert the next state.
  3. Given a complete history, fold every event and assert the recovered state.
  4. 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.