Crank

Crank

License: MIT

Pure state machines for Elixir. Testable data structures first, optional gen_statem process adapter.

How state machines evolved in Erlang and Elixir

Plain Erlang (1980s--1990s). Before OTP existed, Erlang state machines were mutually recursive functions. Each state was a function. The process sat inside that function, waiting for a message. When it transitioned, it tail-called into the next state's function:

locked(Event, Data) ->
    case Event of
        unlock -> unlocked(Data);
        _      -> locked(Data)
    end.

unlocked(Event, Data) ->
    case Event of
        lock -> locked(Data);
        open -> opened(Data)
    end.

The state was which function the process was executing. The data available in each state was whatever that function received as arguments -- nothing more. You couldn't accidentally read policy inside locked/2 because locked/2 was never given a policy. The call stack scoped your data. And the logic was just functions -- you could call them directly without a process.

gen_fsm (OTP, late 1990s). OTP formalized the pattern into a behaviour. Each state was still a callback function -- locked/2, unlocked/2 -- and the framework dispatched to the right one. This preserved the function-per-state model but coupled it to a process. You couldn't use the logic without starting a gen_fsm. The state machine was now inseparable from the process running it.

gen_statem (OTP 19, 2016). Replaced gen_fsm entirely. Added two callback modes: state_functions (same as gen_fsm -- each state is a function name, state must be an atom) and handle_event_function (one function, state is a parameter, state can be any term). The second mode was more flexible but moved further from function-per-state -- you're now inside one function with access to everything, regardless of which state you're in. Still coupled to a process.

Elixir and GenServer (2012--present). Most Elixir developers never touched gen_statem. They came from Ruby and JavaScript, not Erlang. GenServer became the primary OTP abstraction, and GenServer has no concept of states at all -- just handle_call, handle_cast, handle_info with one blob of data. If you needed a state machine, you put a :status atom in that blob and pattern-matched on it. The function-per-state idea didn't carry over from Erlang. It was left behind.

Two things changed in this progression:

  1. Data scoping disappeared. In locked/2, you could only see what locked/2 was given. In a GenServer with %{status: :quoted, violations: [], policy: nil, ...}, every handler can see every field. Nothing stops you from reading policy when the status is :quoted.

  2. State machine logic became inseparable from processes. In the original Erlang model, state machine logic was just functions calling functions. gen_fsm (1990s) coupled it to a process. gen_statem (2016) continued that coupling. GenServer dropped the state machine primitives entirely. Each step moved further from state machine logic you could call directly.

What Crank recovers

Crank separates the two concerns that OTP fused together in gen_fsm: state machine logic and process lifecycle.

The pure core (Crank.crank/2) is a function that takes a machine and an event and returns a new machine. No process, no mailbox, no side effects. The process shell (Crank.Server) wraps the same callback module in gen_statem when you need timeouts, supervision, and telemetry. Same logic, both modes. Write it once, test it pure, run it in production as a process.

For data scoping, Crank supports struct-per-state -- each state is its own struct with exactly the fields that exist in that state (see Struct states below). A %Quoted{} can't have a violations field because the field doesn't exist on that struct. This recovers the guarantee that Erlang's function-per-state model provided, but as portable data instead of a running process.

Why not just use gen_statem?

You can. Crank's callback signature is :gen_statem's handle_event_function mode verbatim -- same arguments, same order -- and the Server is a ~100-line pass-through. If your machine will always live in a process, gen_statem alone is fine.

Crank exists for the cases where that's not enough:

Property testing. Crank's test suite runs 26 properties at 10,000 iterations each -- roughly 100 million random cranks in ~20 seconds. That's feasible because crank/2 returns a struct. No start_link/stop per iteration, no :sys.get_state, no process lifecycle noise. Pure functions compose with StreamData trivially; processes don't.

Non-process hosts. LiveView reducers, Oban workers, Phoenix.Channel assigns, ETS-backed workflows -- these are real contexts where you need FSM logic but spawning a gen_statem would be architecturally wrong. With Crank, the same callback module works in both contexts without adaptation.

Effect inspection. When a callback returns [{:state_timeout, 86_400_000, :delivery_timeout}], pure code stores it in machine.effects as inert data. You can assert on exactly what effects a transition would produce without executing them. gen_statem executes effects immediately -- there's no way to inspect intent separately from execution.

When Crank probably isn't worth it: If your machine is always supervised, never tested with random sequences, and you don't need the logic outside a process, the pure layer is overhead. Use gen_statem directly.

How it works

You write one callback module. That module is always both pure and process-ready -- there's nothing to switch on or off. Crank.crank/2 calls your handle_event/4 directly as a pure function. Crank.Server calls the exact same handle_event/4 through :gen_statem. Same function, two callers.

Pure Process
APICrank.new/2 + Crank.crank/2Crank.Server.start_link/3
What you get A plain %Crank.Machine{} struct A supervised :gen_statem process
Side effects None -- effects stored as inert data Executed by :gen_statem
Telemetry None [:crank, :transition] on every state change
Good for Tests, LiveView reducers, Oban workers, scripts Production supervision, timeouts, replies

This means your development workflow is: write the logic, test it purely with property tests (thousands or millions of random event sequences), and deploy it as a supervised process. When you need to change a state or add a transition, you change the callback module and run the property tests again. If they pass, the process version works too -- because it's the same code. The only difference between pure and process is who calls your function and what happens to the effects afterward.

Quick start

Define a state machine by implementing the Crank behaviour:

defmodule MyApp.Door do
  use Crank

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

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

Pure usage

No process, no setup, no cleanup:

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

machine.state   #=> :opened
machine.effects #=> []

Process usage

Full OTP supervision and :gen_statem power:

{:ok, pid} = Crank.Server.start_link(MyApp.Door, [])
Crank.Server.cast(pid, :unlock)
Crank.Server.call(pid, :status)  # when you have a {:call, from} clause

Callback signature

handle_event/4 matches :gen_statem's handle_event_function callback mode exactly -- same arguments, same order:

def handle_event(event_type, event_content, state, data)
Argument Description
event_type:internal, :cast, {:call, from}, :info, :timeout, :state_timeout, {:timeout, name}
event_content The event payload
state Current state
data Accumulated machine data

In pure code, event_type is always :internal. Use _ for the type to write clauses that work in both contexts:

# Works everywhere
def handle_event(_, :activate, :idle, data), do: {:next_state, :active, data}

# Server-only: reply to synchronous calls
def handle_event({:call, from}, :status, state, data) do
  {:keep_state, data, [{:reply, from, state}]}
end

Return values

All :gen_statem return values are supported:

Effects as data

When a callback returns actions (timeouts, replies, postpone, etc.), the pure core stores them in machine.effects as inert data. It never executes them. The Server executes them via :gen_statem.

def handle_event(_, :ship, :paid, data) do
  {:next_state, :shipped, data, [{:state_timeout, 86_400_000, :delivery_timeout}]}
end
machine = Crank.crank(machine, :ship)
machine.effects
#=> [{:state_timeout, 86_400_000, :delivery_timeout}]

Each crank/2 call replaces effects -- they don't accumulate across pipeline stages.

Enter callbacks

Optional on_enter/3 fires after state changes:

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

Stopped machines

{:stop, reason, data} sets machine.status to {:stopped, reason}. Further cranks raise Crank.StoppedError. Use crank!/2 in tests to raise immediately on stop results.

Unhandled events

No catch-all. Unhandled events crash with FunctionClauseError. This is deliberate -- a state machine that silently ignores events is hiding bugs. Let it crash; let the supervisor handle it.

Telemetry

Crank.Server emits a [:crank, :transition] event on every state change with the following metadata:

%{
  module: MyApp.Door,
  from: :locked,       # nil on initial enter
  to: :unlocked,
  event: :unlock,      # nil on enter
  data: %{}
}

Attach handlers for persistence, notifications, audit logging, PubSub -- see the Hexagonal Architecture guide for patterns.

Struct states

The standard Elixir approach is one struct with a :stage atom and every field present in every state:

%Submission{stage: :quoted, violations: [], quotes: [...], policy: nil, bound_at: nil}
# violations shouldn't be here. policy shouldn't be here.
# But nothing stops it. You have to know which fields matter.

Crank supports an alternative: each state is its own struct. The struct defines exactly what data exists in that state. No optional fields, no "only set when the state is X" comments:

defmodule Validating, do: defstruct violations: []
defmodule Quoted,     do: defstruct quotes: [], selected: nil
defmodule Bound,      do: defstruct quote: nil, bound_at: nil
defmodule Declined,   do: defstruct reason: nil

This works today because Crank.Machine.state is term() -- atoms, structs, tagged tuples all work. Pattern matching on the struct type gives you the state and its data in one destructure:

def handle_event(_, :bind, %Quoted{selected: sel}, data) when sel != nil do
  {:next_state, %Bound{quote: sel, bound_at: :now}, data}
end

State-specific data lives in the struct. Cross-cutting concerns (parameters, audit logs) live in data. When a field changes on the current state struct, use {:next_state, %SameType{updated}, data} -- the state value changed, so it's a transition. :keep_state is reserved for data-only changes.

The type annotations are written for Elixir's set-theoretic type system:

@type state :: Validating.t() | Quoted.t() | Bound.t() | Declined.t()

When the compiler can check this (expected mid-2026+), unhandled state variants will produce compiler warnings with zero code changes. Until then, property tests enforce the same guarantee dynamically -- see Crank.Examples.Submission for the full example.

Design principles

See DESIGN.md for the full specification and rationale behind every decision.

Installation

def deps do
  [
    {:crank, "~> 0.1.0"}
  ]
end

Documentation

License

MIT