Optimistic Concurrency in Event Sourcing
Optimistic concurrency protects a stream by accepting a write only when its expected version matches the current version. A mismatch means another command changed the aggregate first, so the caller must reload events, re-evaluate the command, and either append a new valid event or report a domain conflict.
Problem
Why this pattern exists
Concurrent writers can both read version 12, make decisions from the same state, and attempt version 13. Without a compare-and-append guard, both facts may enter history even when they violate an invariant such as spending the same balance twice or confirming an already-cancelled booking.
A conflict is not a generic transport failure. Blindly retrying the same payload can preserve an invalid decision. Correct handling depends on command semantics: some operations can be recalculated, some are naturally idempotent, and others must return a business-level conflict for a person or upstream workflow to resolve.
Design decisions
Make boundaries explicit
- 01
Read with version
Load the aggregate's ordered events and retain latest version alongside derived state. Command handling must carry that version into append; dropping it creates a time-of-check/time-of-use gap.
- 02
Compare at commit
Validate expected version inside event-store write path, not in application code before network transit. Only storage layer can make comparison and append one atomic decision for stream.
- 03
Retry decisions, not writes
After mismatch, reload intervening events and run domain decision again. Reusing stale event payload is safe only when operation is explicitly commutative and idempotent.
AllSource implementation
Apply pattern to durable Core history
AllSource Core supports expected-version checks in its ingest path. Build aggregate state from tenant-scoped stream, capture current version, then append with that expectation. Core rejects a mismatch before adding event. Record command ID or idempotency key in metadata so network retries can be distinguished from new business commands.
Instrument conflict rate by event type and stream. A sudden rise often signals aggregate boundary that is too broad, a client holding state too long, or duplicate delivery without idempotency. Do not hide persistent conflicts behind infinite retry loops; cap automatic retries, add jitter for transient contention, and surface invariant failures distinctly from version mismatches.
read account-42 -> version 18, available = 5000
decide withdraw 2000 -> account.withdrawal_requested
append expected v18 -> accepted as version 19
second append expects 18 -> concurrency error
reload version 19 -> re-evaluate available balanceFailure modes
Detect weak implementations early
Two conflicting commands both become accepted facts.
Fix: Require expected version on invariant-sensitive appends.
Client retries stale event until it eventually appends.
Fix: Reload and rerun domain decision after every version mismatch.
Unrelated commands conflict on a high-traffic global stream.
Fix: Narrow aggregate boundary and move cross-stream views into projections.
Production checklist
Ready when each statement is true
- Reads return aggregate version with derived state.
- Invariant-sensitive appends include expected version.
- Conflict and duplicate-command errors are separate.
- Retries rerun decision logic against fresh history.
- Conflict metrics identify hot streams and poor boundaries.
Related patterns
Continue through adjacent decisions
Aggregate streams
An aggregate stream is the ordered history for one consistency boundary, such as an order, account, or workflow. Give every aggregate a stable entity ID, append only facts owned by that boundary, and derive current state by folding its events in version order.
Read nextIdempotent consumers
An idempotent event consumer produces the same durable result when it receives an event more than once. Use stable event IDs, entity versions, unique effect keys, and acknowledge only after processing succeeds; at-least-once delivery then becomes recoverable instead of corrupting state.
Read nextEvent replay
Event replay reads immutable events again in their original stream order and applies them to a new or reset consumer. Use replay to rebuild projections, reproduce historical state, test new reducers, or backfill derived outputs—never to re-trigger uncontrolled external side effects.
Read nextStore history once
