code-of-kai
Crank - pure immutable FSMs with seamless gen_statem promotion
Hi everyone,
I’m happy to introduce Crank, a library that makes modelling complex stateful logic in Elixir much more enjoyable and maintainable.
Crank draws inspiration from the long evolution of finite state machines across Erlang and Elixir — from early recursive function patterns to modern OTP behaviours — and brings those ideas together in a clean, modern form. It lets you define your finite state machine as pure, immutable Elixir code first. This gives you state machines that are:
- Extremely clear and self-documenting (one explicit callback per transition)
- Trivially testable without starting any processes
- Fully reusable in any context — tests, LiveView, Oban jobs, scripts, or business logic layers
- Easy to reason about and debug
When your application needs real process features (supervision, timeouts, synchronous replies, telemetry, etc.), you can promote the exact same module to run as a full OTP :gen_statem using Crank.Server with almost no extra code.
You write the logic once in a clean, functional style, and get the best of both pure data-driven design and battle-tested OTP behaviours.
Example
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
Pure usage
machine =
MyApp.Door
|> Crank.new()
|> Crank.crank(:unlock)
|> Crank.crank(:open)
machine.state # => :opened
As a supervised process
{:ok, pid} = Crank.Server.start_link(MyApp.Door)
Same module, same logic — two powerful execution modes.
Crank is small, well-documented, and has no dependencies beyond OTP. It is production-ready and designed to feel like a natural part of the Elixir ecosystem.
You can find the full documentation and more examples (including a vending machine) here:
Update (April 2026): This post describes Crank v0.3.1’s API. v1.1.0 is a ground-up redesign — handle/3 → turn/3, effects → wants, return tuples no longer carry effects, and strict Moore discipline is now enforced structurally. See the v1.1.0 post below and the current README for the new shape.
Most Liked
aseigo
Just popping in here to say I recently had a chance to use Finitomata in prototyping a new service and it was an absolute joy to use. The diagrams made the FSMs semi-self-documenting, and it did exactly what was said on the tin. Kudos ![]()
code-of-kai
v1.1.0 landed — a ground-up redesign.
The core idea of the library is unchanged: pure state machine as data, process shell when you need it, no rewrite to promote. The architecture underneath has shifted.
Crank is now an opinionated Moore state machine library. Outputs are a function of the state, not of the edge that arrived there.
The API change, quickly
defmodule MyApp.VendingMachine do
use Crank
def start(opts), do: {:ok, :idle, %{price: opts[:price] || 100, balance: 0}}
# Transitions are pure state computation. No effects in the return.
def turn({:coin, amount}, :idle, memory) do
{:next, :accepting, %{memory | balance: amount}}
end
# Effects are declared per-state, separately.
def wants(:accepting, _memory), do: [{:after, 60_000, :refund_timeout}]
def wants(:dispensing, _memory), do: [{:after, 5_000, :jam}]
def wants(_, _), do: []
# What outside callers see. Pure projection of (state, memory).
def reading(:accepting, memory), do: %{status: :accepting, balance: memory.balance}
def reading(state, _memory), do: %{status: state}
end
-
handle/3andhandle_event/4→turn/3. One callback. No event-type argument. -
The
effectsfield →wantsfield. Still inert data in pure mode; still executed byCrank.Serverin process mode. -
Effects no longer come from
turn/3’s return tuple — the shape has no slot for them. They’re declared separately bywants/2, called on state arrival. -
New optional
reading/2callback projects(state, memory)for outside observers.Crank.Server.turn/2auto-replies with it, so user code never declares synchronous replies. -
New composability layer:
Crank.Wants(builder for effect lists),Crank.Turns(anEcto.Multianalogue for multi-machine commands),Crank.Server.Turns(process-mode executor for the same descriptor).
Why Moore
In Moore, the question “what does this state do?” has a single answer you can read in one place — the wants/2 clause for that state. In Mealy, which :gen_statem defaults to, the same question requires scanning every transition that arrives at the state and assembling the pieces.
Phoenix LiveView is Moore-shaped: handle_event/3 updates assigns, render/1 projects the UI from assigns as a pure function of state. No access to the triggering event. That discipline is a large part of why LiveView is ergonomic — “given this state, what should be on screen?” reduces to a single function.
Crank applies the same pattern to state machines: state-first, not edge-first.
Why the strict commitment
A Moore library that lets you attach effects to edges “just this once” isn’t a Moore library. The value of the discipline comes from knowing it holds without exception: every effect a state declares lives in one place, every time. Readers reason state-first because the API guarantees there’s no other way. As soon as escape hatches exist, that guarantee weakens to a convention, and the reasoning it enables erodes with it.
So the commitment is structural. turn/3’s return shape — {:next, state, memory}, {:stay, memory}, :stay, {:stop, reason, memory} — has no actions slot. Users can’t accidentally attach an effect to an edge because the API doesn’t permit it. :gen_statem is excellent when you want Mealy; Crank occupies the strict-Moore position instead.
Where to look
-
DESIGN.md — full spec and the design decisions list
-
Composing Work guide — the Wants / Turns layer for multi-machine work
-
Hexagonal Architecture guide — persistence, notifications, audit logging
-
CHANGELOG has the full list of API changes from 0.3.1
On hex.pm as {:crank, "~> 1.1"}. The 0.x versions are retired with a migration nudge.
code-of-kai
Good question.
gen_statem couples logic to the process by convention. The callbacks are functions, but there’s no struct, no pipeline, no ecosystem pattern for calling them outside a running process. Nothing stops side effects from landing inside handle_event/4, so plenty of code puts them there.
Crank: pure core, effects as data, same module runs supervised when you need timeouts and telemetry. crank/2 is a function, so property tests are cheap. The suite runs 26 properties at 10k iterations each, roughly 100M random sequences, in about 20 seconds.
State is any term, so each state can be its own struct with exactly the fields it needs. A %Dispensing{} can’t have a :change field because the struct doesn’t define one. Illegal states fail to compile.
Finitomata is schema-first and generates a lot for you, including those diagrams (very cool btw, great idea
). Crank is code-first and small: no DSL, no distribution, no diagrams, no auto-transitions.
0.3.0 added persistence (hexdocs.pm/crank).
Popular in Announcing
Other popular topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #websockets
- #supervisor
- #advent-of-code
- #elixirconf-us
- #distillery
- #processes
- #forms
- #api
- #metaprogramming
- #security
- #performance









