Commands
Commands are the write entry points for aggregations. They provide a safe place to validate business rules against the current aggregate state before emitting events that become immutable facts.
Why Commands Exist
Every command passes through two validation gates:
- Superficial validation — Required fields, format checks, valid ranges. Happens before the command reaches the domain layer (e.g., at the API boundary).
- Domain validation — Business rules that depend on aggregate state. Happens inside the command method. Examples: "withdrawal cannot exceed balance", "order cannot be cancelled after shipping".
Commands are the right place for domain validation because they run against the most recent state of the aggregation.
The Command Lifecycle
Command called
│
▼
┌─────────────────────────────────┐
│ 1. ^update: Reset + Replay │
│ • Reset all mutable attrs │
│ to their defaults │
│ • Replay ALL events from │
│ the store onto the agg │
│ • Aggregate is now fresh │
└──────────────┬──────────────────┘
│
▼
┌─────────────────────────────────┐
│ 2. Execute command body │
│ • Validate against state │
│ • die() if validation fails │
│ • Emit events via $.event │
└──────────────┬──────────────────┘
│
▼
┌─────────────────────────────────┐
│ 3. Optimistic lock check │
│ • Store detects if new │
│ events appeared since │
│ the ^update call │
│ • Throws X::OptimisticLocked │
│ if contention detected │
└──────────────┬──────────────────┘
│
┌──────┴──────┐
│ │
Success Contention
│ │
▼ ▼
Return result Retry (up to 5x)
Go to step 1
│
▼ (all retries exhausted)
Throw X::OptimisticLocked
Automatic Retry
When X::OptimisticLocked is thrown, the command automatically retries — up to 5 attempts. This works because:
- Command parameters are the intent, not the state
- The aggregate is rebuilt from events — no "merged state" confusion
- Command is re-validated against freshly loaded state each retry
- State is deterministic: same events + same command = same result
After 5 failed attempts, the last X::OptimisticLocked exception is re-thrown to the caller.
Command Patterns
Simple Command
method increment(Int $amount) is command {
$.incremented: :$amount;
}
Command with Validation
method withdraw(Rat $amount) is command {
die "Account is closed" unless $!is-open;
die "Insufficient funds: $!balance < $amount" if $!balance < $amount;
$.amount-withdrawn: :$amount;
}
Command Emitting Multiple Events
method place-order() is command {
die "Cannot place empty order" if @!items.elems == 0;
$.order-placed;
$.payment-requested: total => @!items.map(*.price).sum;
}
Best Practices
- Validate before emitting. A
dieprevents any events from being persisted. - Never mutate state directly. State changes happen only through event emission and
applymethods. - Keep validation logic idempotent. Same state + same arguments = same result.
- No side effects beyond event emission. Don't write to databases, send emails, or call APIs from commands.
A CRUD UPDATE users SET balance = balance - 100 mutates state directly. A command validates against current state, then emits an event that describes the change. The event is the source of truth, not the resulting state.
Next Steps
- Learn about Optimistic Locking — how concurrency conflicts are handled
- See the Bank Account Example for commands in action
- Read about Traits — how
is commandworks