skip to content

During command handling in an event-sourced aggregate, rehydration produces the current state, and then the incoming command has to be decided against it. Concretely, what are the two responsibilities involved - deciding vs. applying - and why should they not be merged into one function?

level: middleimportance: must knowfreq 65%

answer

  1. load-decide-apply-persist cycle
  2. decide = business rules, apply = fact recording
  3. apply must be unconditional/total
  4. same apply fn used in replay and live handling
  5. Decider pattern

basics

~20 s

There are two separate jobs: first, figure out if the command is allowed and what should happen (the 'decide' step, checking business rules against the current state you just rebuilt); second, once you know what happened, update the state to reflect the new event (the 'apply' step). Keeping them separate means the apply step never has to think about whether something is allowed - it just records it.

solid answer

~50 s

Command handling in event sourcing is a load-decide-apply-persist cycle. Load/rehydrate rebuilds current state by folding stored events. Decide takes that state plus the incoming command and business rules, and either rejects the command or produces new events representing what actually happened - this is where invariants like 'balance can't go negative' are enforced. Apply then folds those new events into the in-memory state exactly the same way replay does, using the same apply function used during rehydration, so the aggregate is ready to validate a follow-up command in the same unit of work. Persist appends the new events, typically with an optimistic-concurrency check against the version that was loaded. Keeping decide and apply separate matters because apply is reused verbatim during rehydration - if it contained validation logic, replaying old, already-valid events could spuriously fail or behave differently than they did originally.

go deeper

for a junior

Should be able to describe, at a high level, that you first load state, then check if the command is OK, then record what happened - without necessarily naming 'decide' and 'apply' as distinct functions.

for a middle

Should name the load-decide-apply-persist cycle explicitly and explain concretely why validation logic doesn't belong in apply.

for a senior

Should connect this to optimistic concurrency (version checks on persist, retry-on-conflict) and be able to design the decide/apply split for a non-trivial aggregate with multiple event types.

for a principal

Should be able to reason about how this split affects system evolution over years - rule changes, event schema evolution, and how a team accidentally coupling decide and apply causes cross-cutting replay failures discovered only during a projection rebuild.

## The load-decide-apply-persist cycle Command handling in an event-sourced aggregate follows a small, consistent cycle usually described as load - decide - apply - persist, and rehydration sits right at the start of it. When a command like `WithdrawMoney(accountId, amount)` arrives, the handler doesn't operate on a cached in-memory object sitting around from before: 1. **Load.** It loads the aggregate's event stream from the store and rehydrates — folding every stored event through the `apply` function — to get a fresh, authoritative current state. 2. **Decide.** Only once that state exists does "deciding" happen: a `decide` (sometimes called `handle`, or a pure `decide(state, command)` function) inspects the command against the current state and the aggregate's business invariants and returns either a rejection (the command is invalid — insufficient funds, account closed, whatever the rule is) or a list of new domain events representing what actually happened (`MoneyWithdrawn(amount)`). 3. **Apply.** Those new events are then run through the exact same `apply` function that rehydration used, updating the in-memory state so the aggregate is consistent if another command needs to be decided against it in the same request. 4. **Persist.** Finally, persist appends the new events to the stream in the store. ## Why apply must never carry business rules The critical design rule is that decide and apply are different functions with different responsibilities, and `apply` must never contain business-rule logic. The reason is that apply is not just used once, right after a command — it's the exact same function replay uses to rebuild state from history, potentially thousands of times over an aggregate's life, and by definition every event that's already in the stream already happened and was already validated when it was written. If you put a check like "reject if amount > balance" inside apply, you're implicitly re-validating history every time you rehydrate, which is not just redundant but dangerous: - Business rules can legitimately change over time — a withdrawal limit might be raised next year. - If apply enforces today's rule against yesterday's already-valid event, replaying old streams can throw or silently corrupt state that was perfectly fine when it happened. Apply must be an **unconditional, total function** — given any state and any event of a type the aggregate knows about, it always produces a next state, no exceptions, no branching on "should this be allowed." ## Why the split exists This split exists because event sourcing treats "what should happen" and "what did happen" as fundamentally different concerns living at different points in time. | Function | What it is | |---|---| | `decide` | a point-in-time judgment call made once, using whatever business rules and external context (permissions, current inventory, fraud checks) are relevant right now | | `apply` | a permanent, timeless fact-recording step that must remain valid forever, because the events it operates on are immutable and will be replayed indefinitely into the future by code that may not even exist yet (a future team building a new analytics projection, say) | Conflating them means every future replay is implicitly re-running today's business logic against yesterday's facts, which breaks the append-only, immutable-history contract that event sourcing is built on. ## The trade-off The trade-off of this separation is a small amount of ceremony: you write two functions per aggregate instead of one, and engineers used to simpler designs sometimes ask why `WithdrawMoney` can't just directly mutate a balance field with a guard clause. The payoff is that: - **rehydration stays trivially reliable** — it's just a fold, nothing can go wrong there structurally; - and all the places bugs actually live (business rules) are **isolated to the decide function**, which is easy to unit-test by feeding it a state and a command and asserting on the returned events or rejection, with no need to fake a database or an event store. ## Failure modes The most common failure mode in real codebases is exactly the collapse of decide and apply: a team new to event sourcing writes one `applyWithdrawal(state, event)` method that both mutates the balance and throws if the balance would go negative, because it feels natural coming from a CRUD/OOP mutator-method background. This works until the first time the stream is replayed for a reason other than "handle a new live command" — e.g., rebuilding a read-model projection after fixing a bug in it, or replaying into a test harness with a different starting balance for scenario testing — at which point the throw fires on perfectly legitimate historical data and rehydration itself fails, taking down every subsequent command against that aggregate. A related, subtler failure is decide functions that aren't given the fully rehydrated state — e.g., an implementation that decides against a stale cached copy from before the last few events were appended — which produces decisions based on out-of-date information and, downstream, wrong events being appended, silently corrupting the aggregate's history going forward. Frameworks like **Axon Framework** and libraries built around the **Decider** pattern (popularized in the F#/functional event-sourcing community and adopted broadly) make this split an explicit, enforced part of the API precisely to prevent this failure.

  • What is optimistic concurrency control's role in the persist step of this cycle?
    When persisting the newly decided events, the store checks that the stream's version hasn't changed since it was loaded/rehydrated - if another writer appended events in between, the append is rejected with a concurrency conflict, and the handler typically reloads, re-rehydrates, and retries the decide step against the fresh state. This prevents two concurrent commands from both deciding against the same stale state and silently overwriting each other's intent.
  • Why can't the decide function safely be replayed the same way apply is?
    Decide is meant to run exactly once, at the moment the command arrives, using live business context (current time, current rules, possibly external calls) - it's inherently a point-in-time judgment, not a timeless fact-recording step. Replaying it later would mean re-deciding history with today's rules and today's context, which can produce a different verdict than the one that actually happened.
  • If a business rule changes (say, the maximum withdrawal limit increases), does that affect already-stored events or the apply function?
    No - it only affects the decide function going forward; already-stored events like MoneyWithdrawn(amount) remain valid historical facts regardless of today's rules, and apply keeps folding them the same way it always did. This is exactly why keeping business rules out of apply matters: it lets rules evolve without invalidating history.

Decide is like a judge ruling on a case (weighing arguments, applying today's law, and issuing a verdict); apply is like the court clerk who then just writes the verdict into the permanent record - the clerk never re-argues the case, they just record what was decided.

saying these in an interview costs you the question

  • Puts a validation/guard clause inside the apply/fold function
  • Can't distinguish 'deciding what happened' from 'recording what happened'
  • Thinks apply can safely throw an exception on invalid historical events
  • Doesn't rehydrate before deciding - decides against stale or cached state
  • Believes business rules changing over time should retroactively affect already-stored events

context