Command Handlers
A projection tells you how to reconstruct state. A command handler tells you what to do with it: validate an incoming command against the current state and either reject it or produce new events.
The type
data CommandHandler state event command err = CommandHandler
{ decide :: state -> command -> Either err [event],
projection :: Projection state event
}decide is where all the domain logic lives — a pure function. Current
state and a command go in; either a typed rejection or a list of new events
comes out. The handler bundles decide with the Projection it uses to rebuild
state before making that call.
handleAccountCommand :: Account -> AccountCommand -> Either AccountCommandError [AccountEvent]
handleAccountCommand account (OpenAccountAccountCommand cmd)
| isJust account.owner = Left AccountAlreadyOpen
| cmd.initialFunding < 0 = Left InvalidInitialDeposit
| otherwise = Right [AccountOpenedAccountEvent (AccountOpened cmd.owner cmd.initialFunding)]
handleAccountCommand account (TransferToAccountAccountCommand cmd)
| isNothing account.owner = Left AccountNotOpen
| accountAvailableBalance account - cmd.amount < 0 =
Left (InsufficientFunds (accountAvailableBalance account))
| otherwise = Right [AccountTransferStartedAccountEvent (...)]
accountCommandHandler :: CommandHandler Account AccountEvent AccountCommand AccountCommandError
accountCommandHandler = CommandHandler handleAccountCommand accountProjectionBecause decide is pure, the entire domain is testable with no infrastructure —
pass in a state and a command, assert on the Either:
accountCommandHandler.decide stateAfterStarted (DebitAccountAccountCommand (DebitAccount 9 "rent"))
`shouldBe` Left (InsufficientFunds 4)Running a command
applyCommandHandler ties a handler to a store and runs it end to end:
applyCommandHandler ::
(Monad m) =>
VersionedEventStoreWriter m event ->
VersionedEventStoreReader m event ->
CommandHandler state event command err ->
UUID ->
command ->
m (Either (CommandHandlerError err) [event])It runs three phases:
- Read — replay the aggregate’s events into current state, capturing the stream’s version.
- Decide — call your pure
decidewith that state and the command. - Write — if
decidereturnedRight, append the new events, asserting the stream is still at the version we read.
No lock is held during the decide phase.
Optimistic concurrency
Two handlers could read the same aggregate at version 5, both run their logic, and both try to write. Allowing both would corrupt the stream — the events were validated against the same state, not against each other.
Eventium prevents this optimistically. When writing, the handler asserts the
stream’s expected position via ExpectedPosition:
data ExpectedPosition position
= AnyPosition -- don't care where the stream is
| NoStream -- the stream shouldn't exist yet
| StreamExists -- the stream should already exist
| ExactPosition position -- the stream must be exactly hereapplyCommandHandler uses ExactPosition with the version it read. If another
writer landed in between, the version check fails and the write is rejected — no
lock was ever held during business logic.
Two kinds of failure
The result type keeps domain rejection and infrastructure conflict distinct:
data CommandHandlerError err
= CommandRejected err -- your domain said no
| ConcurrencyConflict (EventWriteError EventVersion) -- someone else wrote firstInsufficientFunds and AccountNotOpen live in your err type; a version
mismatch is a ConcurrencyConflict. The compiler makes you handle each
appropriately. No retries are built in — retry strategy depends on the domain
(some commands are safe to replay, others aren’t), so that decision is left to
the caller.
Long streams: caching
Replaying every event on every command is fine for short streams but costs more
as history grows. applyCommandHandlerWithCache takes a
ProjectionCache and only replays events since the
last snapshot:
applyCommandHandlerWithCache ::
(Monad m) =>
VersionedEventStoreWriter m event ->
VersionedEventStoreReader m event ->
VersionedProjectionCache state m ->
CommandHandler state event command err ->
UUID ->
command ->
m (Either (CommandHandlerError err) [event])Because streams are append-only, a cached state at version N is always valid — there’s no cache-invalidation problem, only replay-forward.
Next
Command handlers act within a single aggregate. Process managers coordinate workflows across aggregates.