jonnifyelixir
F5 / F5.06 / genserver

F5.06 · part 2 of 3

The engine GenServer

Three callbacks carry the whole engine. init/1 runs once on start: it folds the event log into the starting state — the F5.05 reduce, run here. handle_call for a command runs decide (the F5.04 contract, emitting events) and evolve (folding them in), replies :ok, and keeps the new state. handle_call for a query reads the state and replies with data, leaving it unchanged. The command/query split from F5.05 becomes two clauses, and because a GenServer handles one message at a time, writes serialize for free.

One process, three callbacks. Select one to see what runs and how the state threads through.

Three callbacks

Each callback takes the current state and returns the next one alongside a reply. init builds the first state from the log; a command threads state in and a changed state out; a query threads state in and the same state out. The single mailbox means these never overlap. Select a callback.

Engine callback · select one

INIT/1 fold the log → {:ok, state} on start HANDLE_CALL · COMMAND decide · evolve → {:reply, :ok, new_state} state changes HANDLE_CALL · QUERY read → {:reply, data, state} state unchanged

callback: init/1

does: fold the log into state

A GenServer is the F5.05 reducer wearing a uniform: init folds, a command evolves, a query reads — and the mailbox keeps them in line.

In code

The engine in three callbacks. init folds the log once with the F5.05 replay; the command clause runs decide and folds its events; the query clause reads and replies. The contract from F5.04 lives inside decide, so a bad command returns {:error, _} and leaves the state alone.

defmodule Portal.Engine do
  use GenServer

  # init folds the event log (F5.05) into the starting state, once
  @impl true
  def init(events), do: {:ok, replay(events)}

  # a command: run decide (contract + emit), evolve, keep the new state
  @impl true
  def handle_call({:enroll, user_id, course_id}, _from, state) do
    case decide(state, {:enroll, user_id, course_id}) do
      {:ok, events}      -> {:reply, :ok, Enum.reduce(events, state, &evolve/2)}
      {:error, _} = err -> {:reply, err, state}
    end
  end

  # a query: read the state and reply with data; state is unchanged
  @impl true
  def handle_call({:courses_of, user_id}, _from, state),
    do: {:reply, State.enrollments_for(state, user_id), state}
end

one message at a time

A GenServer's mailbox serializes calls, so writes never race — ordering is free.

split into clauses

The F5.05 command/query split becomes two handle_call clauses.

Next: supervision — what restarts this process when it crashes, and how init replays the log to rebuild its state.

References

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

Sources

Related in this course