~ / track G / concurrency models

Event sourcing

Advancedtheory

Event 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:

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

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.

CommandValidator (current state) → Event(s) → LogProjections

This 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.

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate
 status: new