Crank
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:
Data scoping disappeared. In
locked/2, you could only see whatlocked/2was given. In a GenServer with%{status: :quoted, violations: [], policy: nil, ...}, every handler can see every field. Nothing stops you from readingpolicywhen the status is:quoted.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 | |
|---|---|---|
| API | Crank.new/2 + Crank.crank/2 | Crank.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}
endPure 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} clauseCallback 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}]}
endReturn values
All :gen_statem return values are supported:
{:next_state, new_state, new_data}{:next_state, new_state, new_data, actions}{:keep_state, new_data}{:keep_state, new_data, actions}:keep_state_and_data{:keep_state_and_data, actions}{:stop, reason, new_data}
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}]}
endmachine = 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())}
endStopped 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
- Pure core, effectful shell. Business logic is pure data transformation. Side effects live at the boundary.
- No magic. Crank passes
:gen_statemtypes and return values through unchanged. If you know:gen_statem, you know Crank. - No hidden state. No
states/0callback, no registered names, no catch-all defaults. Function clauses declare the machine. - Let it crash. Unhandled events are bugs. Crank surfaces them immediately.
- ~400 lines. Small enough to read in one sitting. No framework, just a library.
See DESIGN.md for the full specification and rationale behind every decision.
Installation
def deps do
[
{:crank, "~> 0.1.0"}
]
endDocumentation
- DESIGN.md -- Full specification and design rationale
- Hexagonal Architecture guide -- Integration patterns for persistence, notifications, and audit logging
- CHANGELOG.md -- Version history
License
MIT