code-of-kai

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 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.

Showing Posts 1 to 8

Asd

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 :sys module 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.ex must be outside of lib, because production builds of the library dont need examples in them. Just move it to separate directory outside of lib like in example/example1.ex

  • defp dispatch_event(module, event_type, event_content, state, data) do
      if function_exported?(module, :handle_event, 4) do
        module.handle_event(event_type, event_content, state, data)
      else
        module.handle(event_content, state, data)
      end
    end
    

    This 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 :hello state. event_type can’t be just omitted

  • It 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, Repatch can help

In 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

mudasobwa

Creator of Cure

When I decided to roll on my own FSM implementation finitomata, I knew exactly what am I missing from the gen_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 Crank over gen_statem?

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:

mudasobwa

mudasobwa

Creator of Cure

Thanks, I really appreciate this!

Have you had any chance to test the Finitomata.ExUnit testing framework? The feedback on it would be much appreciated. Everything else bugs me less :slight_smile:

code-of-kai

code-of-kai OP

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/3 dropping event_type: you’re right that it bites under Crank.Server. In pure mode it’s always :internal so 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 the Crank.Server moduledoc. 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 the Crank.Server moduledoc 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/stop per 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/2 helper 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

code-of-kai OP


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).

code-of-kai

code-of-kai OP

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 OP

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/3 is 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 a Repo.get/2 into 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 your turn/3 is 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_compile hook walks the AST of every turn/3 clause 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. Hard CompileError at compile time. Most mistakes die here.

But you can defeat that checker without trying. You write a function called update_total/1 that looks innocent, and it calls Repo.update/1. The blacklist sees a call to update_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.Repo at all — directly or transitively, through any chain of helpers — then the helper trick stops working, because the helper itself can’t compile against Repo either. This is what Boundary does, and as of v2.0 it’s a hard dep. mix crank.gen.config writes the starter config that draws the :domain / :infrastructure cut. 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.PurityTrace runs turn/3 inside an isolated :trace.session_create/3 session — the OTP 26 session-scoped tracing API — and reports any blacklisted call anywhere in the dynamic call graph. Crank.PropertyTest.assert_pure_turn/3 wires 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/3 API 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 :exceptions entries handle topology. Programmatic :allow opts on the property test handle runtime trace observations. Try to suppress a topology violation with a source comment and you get CRANK_META_004 pointing 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 under guides/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:

use Crank,
  states: [Drafting, Priced, Placed, Confirmed, Cancelled],
  memory: MyApp.OrderMemory

It’s opt-in. What it gives you: a closed state/0 union so Dialyzer can check turn/3 returns; a refusal to let function/0, module/0, or pid() appear in state or memory typespecs (each of those silently breaks snapshot/restore); and a compile-time check that every turn/3 return 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 exhaustive turn/3 warnings 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.config is the one-time setup — it wires :crank into compilers:, writes the starter Boundary config, amends .credo.exs. Idempotent. mix crank.check is the single CI gate — it wraps compile --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.

— All posts loaded —

Where Next? Top

Trending in Announcing Top

bluzky
You may know https://ui.shadcn.com/, a UI component library for React. I really love it’s design style and components. I’ve built some co...
387 15136 120
New
woylie
Flop is an Elixir library that applies filtering, ordering and pagination parameters to your Ecto queries. offset-based pagination with...
New
restlessronin
The repo is at GitHub - cyberchitta/openai_ex: Community maintained Elixir library for OpenAI API · GitHub. Docs are at OpenaiEx User Gu...
152 11030 135
New
JesseHerrick
Hey, I’m Jesse and I’m the main contributor behind Dexter, a full-featured, lightning-fast Elixir LSP optimized for large codebases. It s...
New
shahryarjb
The Chelekom project is a library of Phoenix and LiveView components generated via Mix tasks to fit developer needs seamlessly. One of i...
New
woylie
Phoenix components for pagination, sortable tables and filter forms with Flop and (optionally) Ecto. pagination cursor pagination sorta...
New
kip
Please say hi to a new lib, Astro that aims to deliver easy-to-consume astronomy calculations of practical use. For now it only calculat...
New

Other Trending Topics Top

akoutmos
@hugobarauna and I (Alex Koutmos) have been hard at work on writing a book on Nerves that takes you from simply blinking LEDs to building...
New
spammy
I’m looking to build a personal workflow to quickly deploy web applications written in elixir/phoenix, for local consumption (ie not on t...
New
alexslade
Fly’s CEO posted this recently - Turn And Face The Strange · The Fly Blog It says that Fly is going all-in on sprites, which is a worry ...
New
bartblast
Hey folks, I just published a post about Hologram’s funding and where the project goes next - the short version: Curiosum as Main Spons...
New
Herve37
We’re evaluating API mocking tools for OpenAPI-based projects and would love to hear what other teams are using. We’re particularly inte...
New
mudasobwa
I am seeing a lot of aplications of Argumentum ad Vericundiam in software discussions. They do link some piece of writing and point us to...
New

We're in Beta

About us Mission Statement

Options

Thread Display Mode




Thread Preview

Skip Thread Previews