Crank behaviour (Crank v0.2.0)

Copy Markdown View Source

What this is

Crank lets you build a state machine as ordinary data. The machine is a struct, %Crank.Machine{}, which holds the current state, whatever data you've accumulated, and any side effects the last transition declared. To advance the machine, you call Crank.crank(machine, event). You get back a new struct. That's the whole interface.

There's no process involved, no message passing, no supervision tree. It's a function that takes a struct and an event and returns a new struct. You can call it in a test, in a LiveView, in an Oban worker, in a script. Anywhere you can call a function.

What you write

A Crank module is a set of functions. Crank calls them at the right moments — Elixir calls this pattern "callbacks." Define the function; the library calls it back when something happens. The functions are never called directly.

There are three callbacks:

init/1 — Crank calls this once when the machine is created. It returns the starting state and any initial data:

def init(opts) do
  {:ok, :idle, %{price: opts[:price] || 100, balance: 0}}
end

handle/3 — Crank calls this every time an event arrives, passing the event, the current state, and the accumulated data. It returns the next state:

def handle({:coin, amount}, :idle, data) do
  {:next_state, :accepting, %{data | balance: amount}}
end

def handle({:coin, amount}, :accepting, data) do
  {:next_state, :accepting, %{data | balance: data.balance + amount}}
end

Each function clause is one transition. Read it like a sentence: "When a coin arrives and we're idle, move to accepting and record the amount." The set of all clauses is the complete specification of the state machine. There's nothing else to configure, no tables to fill in, no DSL to learn.

on_enter/3 — Optional. Crank calls this after a state change, passing the old state, the new state, and the data. Useful for recording that a transition happened — a timestamp, a counter, a log entry — without cluttering the transition logic itself.

That's everything. The rest is pattern matching.

The struct

After each crank/2 call, you get back a %Crank.Machine{} with five fields:

  • module — the callback module (so the struct knows which functions to call)
  • state — the current state (an atom, a struct, a tuple — any Elixir term)
  • data — whatever your init/1 and handle/3 have accumulated
  • effects — side effects from the last transition, stored as data (never executed)
  • status:running or {:stopped, reason}

The effects field is important. When handle/3 returns actions like timeouts or replies, the pure core doesn't execute them. It stores them as a list in effects. They can be inspected, asserted on in tests, or ignored. They're just data until something decides to act on them.

Each crank/2 call replaces effects — they don't pile up from previous transitions.

Running it as a process

Everything above works without a process. But sometimes you need one. Maybe you want a timeout that fires after 30 seconds of inactivity. Maybe you want the machine to live in a supervision tree so it restarts on failure. Maybe another process needs to send it a message and get a reply.

Crank.Server handles this. It takes the same module — the exact same one, unchanged — and runs the functions inside OTP's gen_statem:

{:ok, pid} = Crank.Server.start_link(MyApp.VendingMachine, price: 75)
Crank.Server.cast(pid, {:coin, 25})

The logic is the same. What changes is the plumbing around it: who calls the functions, and what happens to the effects afterward. In pure mode, effects are stored as data. In process mode, gen_statem executes them — timeouts fire, replies get sent, telemetry events are emitted.

There isn't one module for pure and another for process. There is one module. It works in both contexts because it's just functions.

When you need handle_event/4

handle/3 is enough for most logic. But when a machine runs as a process, events arrive in different ways. A cast is asynchronous — fire and forget. A call is synchronous — the caller is waiting for a reply. A timeout fires because time passed. A raw message arrives from another process.

Sometimes the function needs to know which of these happened. That's what handle_event/4 is for. It's handle/3 with one extra argument — the event type — prepended:

def handle_event({:call, from}, :status, state, data) do
  {:keep_state, data, [{:reply, from, state}]}
end

The event types are:

  • :internal — programmatic events (this is what pure crank/2 always uses)
  • :cast — someone called Crank.Server.cast(pid, event)
  • {:call, from} — someone called Crank.Server.call(pid, event) and is waiting
  • :info — a raw Erlang message from another process
  • :timeout, :state_timeout, {:timeout, name} — a timer fired

If a module defines handle_event/4, Crank uses it instead of handle/3. If a module needs both — handle/3 for business logic and handle_event/4 for replies — the specific clauses go in handle_event/4, and a catch-all delegates everything else:

def handle_event({:call, from}, :status, state, data) do
  {:keep_state, data, [{:reply, from, state}]}
end

# Everything that isn't a call goes to handle/3
def handle_event(_event_type, event, state, data) do
  handle(event, state, data)
end

What you return

Every handle/3 (or handle_event/4) clause returns a tuple that tells Crank what should happen next. The most common ones:

  • {:next_state, new_state, new_data} — move to a different state
  • {:next_state, new_state, new_data, actions} — move and declare side effects
  • {:keep_state, new_data} — stay in the same state, update the data
  • {:stop, reason, new_data} — shut down the machine

There are a few more ({:keep_state, new_data, actions}, :keep_state_and_data, {:keep_state_and_data, actions}). These match :gen_statem's return values exactly. {:next_state, ...} and {:keep_state, ...} cover nearly everything.

The actions list is where side effects are declared: timeouts, replies, internal events. In pure mode these get stored in machine.effects. In process mode gen_statem executes them.

Example

A door with three states — locked, unlocked, opened — and four transitions:

defmodule MyApp.Door do
  use Crank

  @impl true
  def init(_opts), do: {:ok, :locked, %{}}

  @impl true
  def handle(:unlock, :locked, data), do: {:next_state, :unlocked, data}
  def handle(:lock, :unlocked, data), do: {:next_state, :locked, data}
  def handle(:open, :unlocked, data), do: {:next_state, :opened, data}
  def handle(:close, :opened, data), do: {:next_state, :unlocked, data}
end

Use it:

machine =
  MyApp.Door
  |> Crank.new()
  |> Crank.crank(:unlock)
  |> Crank.crank(:open)

machine.state
#=> :opened

Four clauses. Four transitions. That's the whole machine. If an event arrives that no clause matches — say, :open when the door is :locked — Elixir raises a FunctionClauseError. That's deliberate. A state machine that silently ignores unexpected events is hiding bugs.

Summary

Types

How an event was delivered. This is the first argument to handle_event/4.

What handle/3 or handle_event/4 returns. The tuple tells Crank what to do next — move to a new state, stay in the current one, or stop. Matches :gen_statem return values exactly.

What init/1 returns — either {:ok, state, data} to start, or {:stop, reason} to refuse.

What on_enter/3 returns. Can only keep the current state (optionally updating data).

Callbacks

Crank calls this every time an event arrives. This is the simplified signature — just the event, the current state, and the data. No event type.

Crank calls this every time an event arrives. This is the full signature — it includes the event type as the first argument, which tells the function how the event was delivered.

Crank calls this once when the machine is created. It returns the starting state and any data the machine should carry.

Crank calls this after the machine enters a new state. Optional.

Functions

Send an event to the machine. Returns a new machine with the updated state.

Same as crank/2, but raises if the transition stops the machine.

Create a new machine.

Types

event_type()

@type event_type() ::
  :internal
  | :cast
  | {:call, from :: GenServer.from()}
  | :info
  | :timeout
  | :state_timeout
  | {:timeout, name :: term()}

How an event was delivered. This is the first argument to handle_event/4.

In pure mode (crank/2), the event type is always :internal. The other types only appear when the machine runs as a process via Crank.Server.

handle_event_result()

@type handle_event_result() ::
  {:next_state, new_state :: term(), new_data :: term()}
  | {:next_state, new_state :: term(), new_data :: term(),
     actions :: [Crank.Machine.action()]}
  | {:keep_state, new_data :: term()}
  | {:keep_state, new_data :: term(), actions :: [Crank.Machine.action()]}
  | :keep_state_and_data
  | {:keep_state_and_data, actions :: [Crank.Machine.action()]}
  | {:stop, reason :: term(), new_data :: term()}

What handle/3 or handle_event/4 returns. The tuple tells Crank what to do next — move to a new state, stay in the current one, or stop. Matches :gen_statem return values exactly.

init_result()

@type init_result() ::
  {:ok, state :: term(), data :: term()} | {:stop, reason :: term()}

What init/1 returns — either {:ok, state, data} to start, or {:stop, reason} to refuse.

on_enter_result()

@type on_enter_result() ::
  {:keep_state, new_data :: term()}
  | {:keep_state, new_data :: term(), actions :: [Crank.Machine.action()]}

What on_enter/3 returns. Can only keep the current state (optionally updating data).

Callbacks

handle(event, state, data)

(optional)
@callback handle(
  event :: term(),
  state :: term(),
  data :: term()
) :: handle_event_result()

Crank calls this every time an event arrives. This is the simplified signature — just the event, the current state, and the data. No event type.

This is the callback for business logic. Each clause is one transition:

def handle({:coin, amount}, :accepting, data) do
  {:next_state, :accepting, %{data | balance: data.balance + amount}}
end

Read it as: "When a coin event arrives and the machine is in the accepting state, stay in accepting and add the amount to the balance."

If a module also defines handle_event/4, Crank uses that instead. This allows process-specific concerns (replies, timeouts) to live in handle_event/4 while everything else delegates:

def handle_event({:call, from}, :status, state, data) do
  {:keep_state, data, [{:reply, from, state}]}
end

def handle_event(_, event, state, data), do: handle(event, state, data)

handle_event(event_type, event_content, state, data)

(optional)
@callback handle_event(
  event_type :: event_type(),
  event_content :: term(),
  state :: term(),
  data :: term()
) :: handle_event_result()

Crank calls this every time an event arrives. This is the full signature — it includes the event type as the first argument, which tells the function how the event was delivered.

Most of the time the delivery method doesn't matter. handle/3 drops the event type and is simpler. handle_event/4 is for when the function needs to reply to a synchronous call, distinguish timeouts from casts, or handle raw process messages:

# Reply to a synchronous caller
def handle_event({:call, from}, :status, state, data) do
  {:keep_state, data, [{:reply, from, state}]}
end

The event types:

  • :internal — pure cranks via Crank.crank/2 (always this in pure mode)
  • :cast — async, via Crank.Server.cast/2
  • {:call, from} — sync, via Crank.Server.call/3 (caller is waiting)
  • :info — raw Erlang message from another process
  • :timeout / :state_timeout / {:timeout, name} — a timer fired

If a module defines both handle_event/4 and handle/3, Crank uses handle_event/4.

init(args)

@callback init(args :: term()) :: init_result()

Crank calls this once when the machine is created. It returns the starting state and any data the machine should carry.

def init(opts) do
  {:ok, :idle, %{price: opts[:price] || 100, balance: 0}}
end

Returns {:ok, state, data} to start the machine, or {:stop, reason} to refuse.

on_enter(old_state, new_state, data)

(optional)
@callback on_enter(
  old_state :: term(),
  new_state :: term(),
  data :: term()
) :: on_enter_result()

Crank calls this after the machine enters a new state. Optional.

Receives the state the machine just left, the state it just entered, and the current data. Only fires on actual state changes — when handle/3 returns {:next_state, ...} with a different state.

Useful for recording that a transition happened without cluttering the transition logic:

def on_enter(_old_state, _new_state, data) do
  {:keep_state, Map.put(data, :entered_at, System.monotonic_time())}
end

Functions

crank(machine, event)

@spec crank(Crank.Machine.t(), event_content :: term()) :: Crank.Machine.t()

Send an event to the machine. Returns a new machine with the updated state.

This is the core operation. Calls handle/3 (or handle_event/4) with the event, the current state, and the data. Whatever the function returns becomes the new machine.

If the function returns {:stop, reason, data}, the machine's status changes to {:stopped, reason}. After that, any further crank/2 calls raise Crank.StoppedError — a stopped machine can't process events.

If the function returns actions (timeouts, replies), they're stored in machine.effects as inert data. Each crank/2 replaces effects from the previous call — they don't accumulate.

Works naturally in pipelines:

Examples

iex> machine = Crank.new(Crank.Examples.Door) |> Crank.crank(:unlock)
iex> machine.state
:unlocked

iex> machine = Crank.new(Crank.Examples.Turnstile) |> Crank.crank(:coin) |> Crank.crank(:push)
iex> machine.state
:locked
iex> machine.data
%{coins: 1, passes: 1}

Pipeline style:

iex> machine =
...>   Crank.Examples.Door
...>   |> Crank.new()
...>   |> Crank.crank(:unlock)
...>   |> Crank.crank(:open)
iex> machine.state
:opened

crank!(machine, event)

@spec crank!(Crank.Machine.t(), event_content :: term()) :: Crank.Machine.t()

Same as crank/2, but raises if the transition stops the machine.

In tests and scripts, a stop usually means something went wrong. This lets you write a pipeline without checking for stops at each step — if any transition returns {:stop, reason, data}, you get a Crank.StoppedError immediately.

Examples

iex> Crank.new(Crank.Examples.Door) |> Crank.crank!(:unlock) |> Map.get(:state)
:unlocked

new(module, args \\ [])

@spec new(module(), term()) :: Crank.Machine.t()

Create a new machine.

Takes a callback module and any arguments for init/1. Calls init/1, gets the starting state and data, and returns a %Crank.Machine{} struct ready to receive events.

Raises if the module doesn't define the required callbacks, or if init/1 returns {:stop, reason}.

Examples

iex> machine = Crank.new(Crank.Examples.Door)
iex> machine.state
:locked
iex> machine.effects
[]
iex> machine.status
:running

iex> machine = Crank.new(Crank.Examples.Turnstile)
iex> machine.data
%{coins: 0, passes: 0}