jonnifyelixir
F5 / F5.05 / reducer

F5.05 · part 3 of 3

The engine as a reducer

Once every change is an event, state stops being something you mutate and becomes something you fold. The engine is two pure functions: decide takes the current state and a command and returns the events it produces, and evolve takes the state and one event and returns the next state. Run evolve across the whole event log with a single Enum.reduce/3 and you have replayed the engine to its current state. Because the functions are pure and the events are ordered, the result is deterministic: the same log always folds to the same state — on restart, in a test, or on another node.

Three events, folded left into state. Select one to see the step it performs.

Folding the log

Start from an empty state, s0. Apply the first event to get s1, the second to get s2, and so on; the last state is the current one. Each step uses only the running accumulator and the next event, which is the shape of a left fold. Select a step.

Reduce over events · select a step

s0 LearnerEnrolled s1 LessonDelivered s2 ProgressRecorded s3 state = Enum.reduce(events, s0, &evolve/2)

step: apply LearnerEnrolled

transition: s0 → s1

State is a fold of its history. Keep the events and you can always recompute the present — which is why the log, not the snapshot, is the thing that matters.

In code

Two pure functions and a reduce. evolve/2 has one clause per event and returns the next state; replay/1 folds the whole log from the empty state. A command path runs decide to produce events, appends them, and applies them — the same evolve on the way in and on replay.

# the engine is two pure functions:
#   decide: (state, command) -> [event]   — validate, then emit (F5.04 contract runs here)
#   evolve: (state, event)   -> state      — fold one event in

def replay(events), do: Enum.reduce(events, initial_state(), &evolve/2)

defp evolve(%LearnerEnrolled{} = e, state),
  do: put_in(state.enrollments[e.user_id], e)

defp evolve(%ProgressRecorded{} = e, state),
  do: put_in(state.enrollments[e.enrollment_id].progress, e.percent)

decide, then evolve

A command produces events; each event folds into the next state.

one reduce

Folding the log replays the engine to now — deterministic, every time.

That closes F5.05: writes and reads are separate, changes are events, and state is their fold. The next module, F5.06 — Where engine state lives, picks the process that holds this folded state — a GenServer, an Agent, ETS. Back to the module overview or the chapter.

Replaying to a point: state as of an earlier event

The replay/1 above folds the whole log into the present: Enum.reduce(events, initial_state(), &evolve/2). Nothing about that fold demands the full history. Hand it a prefix instead — the first n events — and the same evolve clauses reconstruct the state the engine held as of that earlier point, with no snapshot kept for the moment. Where replay/1 answers “what is the state now?”, folding a prefix answers “what was the state then?”.

The selector picks the prefix; replay/1 folds it. Enum.take(log, n) cuts by count; Enum.take_while(log, pred) cuts at the first event that fails a test — up to a given timestamp or id, say. Either feeds the prefix into the established fold: Enum.reduce(prefix, initial_state(), &evolve/2). The result depends only on which events precede the cut and on their order, so the reconstruction is deterministic and repeatable.

This is the temporal query Martin Fowler attaches to Event Sourcing: because the current state is the left fold of an ordered, append-only log, any earlier state is the fold of an earlier prefix — the history is auditable and every past moment is recomputable without storing it. The one rule is the rule of any fold: the events, folded in their recorded order, must always yield the same state.

Folding a prefix of the log to reconstruct an earlier state

EVENT LOG · APPEND-ONLY %LearnerEnrolled{"USR-9"} %LearnerEnrolled{"USR-4"} take(log, 1) · the cut after the cut — not folded Enum.reduce(prefix, initial_state(), &evolve/2) state as of the cut enrollments: %{ "USR-9" => %LearnerE… } map_size: 1 · "USR-4" absent

prefix of 1 event → enrollments holds only "USR-9" · the second enrolment is after the cut

# reuse the engine's replay/1 unchanged — the whole-log fold from above:
#   def replay(events), do: Enum.reduce(events, initial_state(), &evolve/2)
# a prefix selector + the same fold reconstructs the state as of an earlier event.
def replay_until(events, n), do: events |> Enum.take(n) |> replay()

log = [
  %LearnerEnrolled{id: "EVT-1", user_id: "USR-9", course_id: "CRS-7", at: t1},
  %LearnerEnrolled{id: "EVT-2", user_id: "USR-4", course_id: "CRS-7", at: t2}
]

# now: the whole log folded — both enrolments are present
replay(log).enrollments |> Map.keys() |> Enum.sort()
# => ["USR-4", "USR-9"]

# then: replay to the point after the first event only — a prefix fold,
# with no snapshot kept for that moment (audit / temporal query)
as_of_first = replay_until(log, 1)
Map.keys(as_of_first.enrollments)
# => ["USR-9"]
Map.has_key?(as_of_first.enrollments, "USR-4")
# => false   — USR-4 enrolled after the cut, so the earlier state never held it

# the cut can be a predicate instead of a count — fold up to a timestamp/id:
before_evt2 = log |> Enum.take_while(fn e -> e.id != "EVT-2" end) |> replay()
map_size(before_evt2.enrollments)
# => 1   — same prefix, same reconstructed state

A prefix of the log folds to the state as of its last event. Keep the events in order and any past moment is recomputable from them — the log is auditable, and an earlier state is the fold of an earlier prefix, never a stored snapshot.

References

Primary sources for this lesson, and where it connects in the course.

Sources

Related in this course