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 order 123, every AddItem, Pay, Ship, and Cancel command is routed to the same logical owner.
Commands for order 456 have a different owner and may run concurrently.
Begin with the rule that must never race
Suppose an order can be cancelled only before shipping. Two requests arrive together: ShipOrder and
CancelOrder. If separate request handlers load the same database row, both can observe “paid” 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.
The guarantee is local to one aggregate identity. It does not lock all orders, and it does not make a rule spanning an order, a warehouse item, and a payment account atomic.
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 order cannot ship twice;
- a bank account cannot spend beyond its permitted limit;
- a document cannot be published before it exists.
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 |
Order example |
|---|---|---|
Command |
What does the caller want? |
|
State |
What must this aggregate remember to decide? |
current lifecycle status |
Event |
What outcome occurred? |
|
Initial state |
What is true before any events exist? |
no order yet |
It also provides two functions:
|
decide contains the business rule. fold contains no decision: it applies an outcome that has
already happened.
For example:
let decide command state =
match command, state with
| CancelOrder, Shipped -> OrderAlreadyShipped |> DeferEvent
| CancelOrder, Cancelled -> AlreadyCancelled |> DeferEvent
| CancelOrder, _ -> OrderCancelled |> PersistEvent
let fold event state =
match event with
| OrderCancelled -> Cancelled
| OrderAlreadyShipped
| AlreadyCancelled -> state
|
The real functions receive FCQRS command and event envelopes, but the domain relationship stays this simple.
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 |
|---|---|---|---|---|
|
yes |
yes |
yes |
a fact needed for recovery |
|
no |
yes |
no |
a rejection or repeated verdict |
|
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.
Chapter 1 of the tutorial builds this model step by step. Use Define an aggregate for the implementation recipe and Test your domain for the test shapes.
FCQRS