code-of-kai
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.
Trending in Announcing
Other Trending 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
- #blog-post
- #phoenix_html
- #iex
- #graphql
- #ai
- #genstage
- #elixirconf-us
- #websockets
- #supervisor
- #advent-of-code
- #distillery
- #processes
- #api
- #forms
- #metaprogramming
- #security
- #hex










Showing Posts 1 to 8- Show Best Posts
- Show All Posts (oldest first)
- Show All Posts (newest first)
Asd
Hi, very cool idea, good library. I’ve read the code and I have these comments
About library usability. It is possible to test state machine processes and some people would argue that it makes more sense to test processes as processes, not just the logic they execute, because processes have a lot of inherent behavior which drastically changes the testing approach (for example, process can be killed and execution aborted at any point). It is also possible to access state and hook onto state changes with
:sysmodule for testing of process internals.And there are a bunch of problems which can’t be solved with this approach. For example, testing two state machine processes interacting. You wouldn’t be able to write code for processes then have a test which would use state machines as structures. So, given how many cases can’t be covered by this approach, I suggest you to make this library for state machine structures, not the state machine processes.
lib/crank/examples.exmust be outside oflib, because production builds of the library dont need examples in them. Just move it to separate directory outside ofliblike inexample/example1.exThis approach looks misleading. If user implements the state machine as documentation says (with
handle), they won’t be able to tell the difference between:gen_statem.cast(pid, :hello),:gen_statem.call(pid, :hello)and state change to:hellostate.event_typecan’t be just omittedIt emits telemetry, but in only two places on gen_statem state changes. I guess that’s used for crank’s own tests. I’d suggest to use some library which introduces no production dependency and overhead for code which is executed used in tests. For example,
Repatchcan helpIn the end, again very cool library, I like the logo. It would be really nice to see the comments addressed in the future releases!
mudasobwa
When I decided to roll on my own FSM implementation
finitomata, I knew exactly what am I missing from thegen_statem: persistence, distribution, self-documentation, auto-transitions, and conprehensive testing.What exactly were you lacking so that you decided to create another implementation of Finite Automata? Just curious, why would I choose
Crankovergen_statem?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
mudasobwa
Thanks, I really appreciate this!
Have you had any chance to test the
Finitomata.ExUnittesting framework? The feedback on it would be much appreciated. Everything else bugs me lesscode-of-kai
Thanks for the review. Most of this is fixed in 0.3.0 (hex.pm/packages/crank).
Examples moved to
test/support/so they don’t ship.On
handle/3droppingevent_type: you’re right that it bites underCrank.Server. In pure mode it’s always:internalso the drop is honest, but the surprise is real when you cross into the Server. 0.3.0 names the tradeoff at both the README callback section and the top of theCrank.Servermoduledoc. I kept the convenience instead of forbidding it.On telemetry: it’s not for Crank’s tests. It’s the outbound port for persistence, notifications, audit, PubSub. The hex guide and the new Persistence section both hang off
[:crank, :transition]. That said, if a careful reader got that wrong from the source, the source wasn’t saying it loudly enough. 0.3.0 adds a line to theCrank.Servermoduledoc calling it out.On process vs struct testing: I think they’re layers, not alternatives. Pure tests run 100M random sequences in 20s, which is impossible with
start_link/stopper iteration. Process tests cover what only processes can do. Separating logic from lifecycle is precisely so you can test each where it’s cheap.Your specific example, two machines interacting, is actually where pure-first shines, and it convinced me the README should show it. 0.3.0 has a “Testing machines that interact” section with a two-machine test and a four-line
relay/2helper that feeds one machine’s effects into another’s events. No processes.One thing I noticed: paragraph one argues process testing is more faithful, paragraph two concludes the library should drop process support. I think the real point is that a library is clearest committed to one layer, which I agree with. Crank’s split is one file for the struct, one for the adapter, and you never have to touch the adapter. It’s not a second library, it’s an optional wrapper for the things your first paragraph said processes are good for.
Thanks again, and for the logo compliment. If you pull 0.3.0 and anything still feels off, let me know.
code-of-kai
Good question.
gen_statemcouples 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 insidehandle_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/2is 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:changefield 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).
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
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/2clause for that state. In Mealy, which:gen_statemdefaults to, the same question requires scanning every transition that arrives at the state and assembling the pieces.Phoenix LiveView is Moore-shaped:
handle_event/3updates assigns,render/1projects 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_statemis excellent when you want Mealy; Crank occupies the strict-Moore position instead.Where to look
README on GitHub
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
v2.0.0 — Layered purity enforcement (mostly invisible)
Quick update on where Crank has gone since the v1.1 Moore redesign.
In v1.1, the central claim was that
turn/3is pure: same inputs, same outputs, no side effects. That claim was a sentence in the docs. It worked for the careful reader. It did not work for the third contributor on a tired Friday who slips aRepo.get/2into a guard clause. v2.0.0 (released 2026-05-04) is mostly about taking that sentence out of the prose and putting it in the toolchain. If yourturn/3is genuinely pure, you won’t notice any of it.So: how do you actually check that a function is pure? Suppose you sit down to write the checker. The first thing you’d try is to read the code and look for the obvious tells — calls to
Process.send/2,:ets.insert/2,IO.puts/1,:rand.uniform/0, and so on. That’s exactly what the first layer does. A@before_compilehook walks the AST of everyturn/3clause and rejects calls to anything on a known-impure blacklist. A paired Credo check uses the same blacklist (single source-of-truth, so the two never disagree) to surface the same violations as warnings during editing. It also flags_ = local_call(...)— a discarded return from a local call is a static tell that the call exists for side effects, which the blacklist can’t otherwise see through. HardCompileErrorat compile time. Most mistakes die here.But you can defeat that checker without trying. You write a function called
update_total/1that looks innocent, and it callsRepo.update/1. The blacklist sees a call toupdate_total/1, which isn’t on the list, and shrugs. The impurity is real but hidden one level down.The way to catch that is to stop looking at individual call sites and start looking at the module graph. If your machine module is forbidden from depending on
MyApp.Repoat all — directly or transitively, through any chain of helpers — then the helper trick stops working, because the helper itself can’t compile againstRepoeither. This is what Boundary does, and as of v2.0 it’s a hard dep.mix crank.gen.configwrites the starter config that draws the:domain/:infrastructurecut. The two layers are complementary: the first catches obvious impurity at the spot it appears; the second catches structural impurity that’s been hidden behind a polite name.You can still defeat both of them. Static analysis doesn’t see through dynamic dispatch,
apply/3, or metaprogramming that constructs a module name at runtime. So the third layer steps outside static analysis entirely and watches the function while it runs.Crank.PurityTracerunsturn/3inside an isolated:trace.session_create/3session — the OTP 26 session-scoped tracing API — and reports any blacklisted call anywhere in the dynamic call graph.Crank.PropertyTest.assert_pure_turn/3wires this into StreamData so every property test you already have becomes a purity test as well. One helper turns “did this clause produce the right state” into “did this clause produce the right state and touch nothing it shouldn’t have.” This is why OTP 26+ is now required: the older:erlang.trace/3API leaks across processes and isn’t sound for this job.Three layers, three different things they can see, three different things they can miss. The layers compose: each catches the holes the others can’t.
Suppression has to follow the same shape, because each layer observes violations differently. Source comments (
# crank-allow: CRANK_PURITY_001 # reason: ...) handle AST-level violations. Boundary:exceptionsentries handle topology. Programmatic:allowopts on the property test handle runtime trace observations. Try to suppress a topology violation with a source comment and you getCRANK_META_004pointing you at the right place — conflating the layers was the most common failure mode in drafts. Every code (CRANK_PURITY_001,CRANK_DEP_002,CRANK_TYPE_003, …) is frozen and has a per-code doc page underguides/violations/.There’s also a type layer worth describing on its own, because it sits parallel to all of this rather than inside it. The macro form looks like:
It’s opt-in. What it gives you: a closed
state/0union so Dialyzer can checkturn/3returns; a refusal to letfunction/0,module/0, orpid()appear in state or memory typespecs (each of those silently breaks snapshot/restore); and a compile-time check that everyturn/3return is one of the declared states. The thinking behind it is that if you tighten the shape of your state — one struct per state, each carrying only the fields valid in that state — most of the enforcement falls out of Elixir’s compiler and Dialyzer for free, with no Crank-specific runtime cost. The macro form is forward-compatible with set-theoretic exhaustiveness: declare the closed union now, get exhaustiveturn/3warnings as the language work matures. Full discipline in the typing-state-and-memory guide.The whole thing surfaces to users through two mix tasks.
mix crank.gen.configis the one-time setup — it wires:crankintocompilers:, writes the starter Boundary config, amends.credo.exs. Idempotent.mix crank.checkis the single CI gate — it wrapscompile --warnings-as-errors,credo --strict,dialyzer, the Boundary check, and the property-test suite into one command, non-zero on any failure. Those two are the surfaces you actually touch. Everything else — the trace sessions, the AST walker, the topology integration, the violation catalog, the suppression routing — sits behind them.One caveat on adoption: the runtime trace layer is the one least battle-tested outside the example suite. If you try it on a real machine and hit something the OTP 26 trace session does that I haven’t anticipated, please open an issue — that’s the most useful feedback I can get right now.