code-of-kai

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 redesignhandle/3turn/3, effectswants, 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

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 :slight_smile:

code-of-kai

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/3 and handle_event/4turn/3. One callback. No event-type argument.

  • The effects field → wants field. Still inert data in pure mode; still executed by Crank.Server in process mode.

  • Effects no longer come from turn/3’s return tuple — the shape has no slot for them. They’re declared separately by wants/2, called on state arrival.

  • New optional reading/2 callback projects (state, memory) for outside observers. Crank.Server.turn/2 auto-replies with it, so user code never declares synchronous replies.

  • New composability layer: Crank.Wants (builder for effect lists), Crank.Turns (an Ecto.Multi analogue 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

On hex.pm as {:crank, "~> 1.1"}. The 0.x versions are retired with a migration nudge.

code-of-kai

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 :pinched_fingers:). Crank is code-first and small: no DSL, no distribution, no diagrams, no auto-transitions.

0.3.0 added persistence (hexdocs.pm/crank).

Where Next?

Popular in Announcing Top

deadtrickster
I’ve just released stable versions of my Prometheus Elixir libs: Elixir client [docs]; Ecto collector [docs]; Plugs instrumenter/Export...
New
OvermindDL1
I created a new library (rather I pulled out a couple files from my big project), it manages an operating system PID file for the BEAM. ...
New
sorentwo
Hello! tl;dr Announcing Oban, an Ecto based job processing library with a focus on reliability and historical observability. After spen...
985 43487 311
New
ityonemo
Currently just starting out on a new mini-project - getting zig NIFs to run in elixir. https://github.com/ityonemo/zigler The idea here...
New
msaraiva
Surface is an experimental library built on top of Phoenix LiveView and its new LiveComponent API that aims to provide a more declarative...
564 43757 214
New
oltarasenko
Dear Elixir community, After a year of development, bug fixes, and improvements, we are proudly ready to share the release of Crawly 0.1...
New
sbs
Only 650 LOC, wrote for fun :slight_smile: https://github.com/sunboshan/qrcode
New
achempion
Hi, I would like to tell about my initiative to further maintain and develop Waffle project which is the fork of Arc library. The progre...
New
Hal9000
Here is my first stab at this. README pasted below. https://github.com/Hal9000/elixir_random Comments and critiques are welcome. Thank...
New
benlime
LiveMotion enables high performance animations declared on the server and run on the client. As a follow up to my previous thread A libr...
New

Other popular topics Top

9mm
I am constructing a JSON object (map) and I need to conditionally set a field. I’m trying to write proper elixir-way code… and I’m at a l...
New
lessless
I believe there are people here who are dealing with CSV files import on the daily basis, and since Excel is a really popular tool there ...
New
stefanchrobot
What’s the safe way to decode a JSON string into a struct? I want to avoid calling String.to_atom. Jason.decode can give me a map with st...
New
AngeloChecked
What learn first? Rust or Elixir Hi Elixir community! I’m here because i want learn a new language. I’m a junior developer and mainly i ...
New
freewebwithme
Using vs code and installed ElixirLS: support and debugger. And I got an error popped up on start up says Failed to run ‘elixir’ comma...
New
joeerl
Hello again - after a longish gap I’ve decided I really must dig into Elixir and see what’s been happening here - so I have a few questio...
New
sergio_101
I am VERY much an elixir newbie. I have taken one elixir course and one phoenix course on Udemy. During that course, I saw the instructor...
New
rms.mrcs
Hi, I need to transform a list of numbers into a map where the keys are the indexes and the values are the original values of the list. ...
New
shijith.k
I am trying to start a new phoenix project with elixir 1.9, but mix phx.new does not work. It says that ** (Mix) The task "phx.new" could...
New
hariharasudhan94
Lets say I have map like this fetching from my database %{"_id" => #BSON.ObjectId<58eb1a7a9ad169198c3dXXXX>, "email" => ...
New

Latest on Elixir Forum

Elixir Forum

We're in Beta

About us Mission Statement