~ / track G / concurrency models
Event sourcing
AdvancedtheoryEvent sourcing stores your system's history as an append-only log of events and derives the current state by folding over them. Where a normal CRUD system overwrites a row, an event-sourced system writes an event ("OrderPlaced", "ItemShipped") and keeps every one forever. Current state becomes a cached view of the events, not the source of truth. A Kafka topic is the same shape at the infrastructure layer: producers append facts, consumers fold them into read models — no dedicated event-store lesson required to see the analogy.
The basic shape
State = reduce apply-event initial-state events.
That's almost the whole idea. In Clojure:
The "balance" is derived. Replay the events on another node and you get the same balance; replay only a subset and you get the past balance.
Why this matters
- Perfect audit trail. Every change to the system is on disk, with enough context to understand why the state moved.
- Time travel. Roll the event log forward to any point. Debugging a customer issue becomes "fold the events up to the moment of the bug."
- Multiple views. The same event stream can feed many read models: current balance, monthly statement, fraud detection, ML training data.
- Functional core, naturally. The reducer (event → state) is pure. Side effects live at the edges: appending events, projecting them into views. How many atoms hold those projections is an organizing state question; Datalog queries are a natural fit for read models built from event facts.
Events are facts, not commands
A key discipline: an event records something that happened, in the past
tense, as data. :money-deposited is an event; :deposit-money is a
command. Commands can be rejected; events cannot. The handler that
processes a command may validate it (against the current state) and then
emit zero or more events.
Command → Validator (current state) → Event(s) → Log → ProjectionsThis separation is what gives event sourcing its CQRS flavor (Command Query Responsibility Segregation): the write side and read sides have different shapes because they have different jobs.
Snapshots: a pragmatic optimization
For long-lived aggregates, replaying every event from the dawn of time gets
slow. The standard answer is snapshots: periodically persist the current
state and a pointer ("up to event N"); on load, start from the snapshot and
replay only the tail. The pure reducer makes this trivial — a snapshot is
just a precomputed state.
Failure modes to watch
- Schema evolution. Events live forever; old events written before a schema change must still be replayable. The reducer needs upgraders.
- Event store size. Every event ever, plus indexes, plus projections. Storage is cheap, but compaction and projection rebuild costs grow.
- Idempotency at the edges. Multiple consumers may process the same event; they must be safe to retry.
In a Clojure service
The write path appends an event to the log; the read path folds events into
current state with a pure apply-event reducer. Handlers validate commands
against the folded state, emit zero or more events, and return — they never
mutate a row in place. A snapshot table or atom caches the fold result for
fast reads; replay from the log rebuilds it after deploy or for debugging.
The imperative shell owns append-event! and persistence; the functional
core is reduce apply-event.
(defn apply-event [state {:keys [type] :as evt}] ;; pure reducer (sketch)
(case type
:order-placed (assoc state :status :placed :items (:items evt))
:order-shipped (assoc state :status :shipped)
state))
(defn current-state [order-id events]
(->> events (filter #(= order-id (:order-id %))) (reduce apply-event {})))
(defn place-order! [store cmd]
(let [evt {:type :order-placed :order-id (:id cmd) :items (:items cmd)}]
(append-event! store evt) ;; append-only write
evt))Check yourself
? quiz
In event sourcing, why is it important that events are stored in the past tense and treated as facts?
Exercise
Extend the example reducer to support a :transfer-out and :transfer-in
pair of events (modeling a transfer between two accounts as two events). Then
compute the final balances for two accounts given a list of events. Note
how neither event modifies both accounts — the reducer runs per-account.