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:

Hexdocs: Crank — Crank v0.2.0

Let me know what you think or if you have any questions!

2 Likes

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!

1 Like

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?

1 Like

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:

3 Likes

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:

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.


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

1 Like