FSM implementation generated from Mermaid/PlantUML textual representation
Elixir
130
137 commits
updated Aug 1, 2026
Finitomata The FSM boilerplate based on callbacks
Finitomata provides a boilerplate for FSM implementation, allowing to concentrate on the business logic rather than on the process management and transitions/events consistency tweaking.
It reads a description of the FSM from a string in PlantUML, Mermaid, or even custom format.
Syntax Definition {: .tip}
Mermaidstate diagram format is literally the same asPlantUML, so if you want to use it, specifysyntax: :state_diagramand if you want to use mermaid graph, specifysyntax: :flowchart. The latter is the default.
Basically, it looks more or less like this
PlantUML / :state_diagram[*] --> s1 : to_s1
s1 --> s2 : to_s2
s1 --> s3 : to_s3
s2 --> [*] : ok
s3 --> [*] : ok
Mermaid / :flowcharts1 --> |to_s2| s2
s1 --> |to_s3| s3
Using
syntax: :flowchart{: .tip}
Mermaiddoes not allow to explicitly specify transitions (and hence event names) from the starting state and to the end state(s), these states names are implicitly set to:*and events to:__start__and:__end__respectively.
Finitomata validates the FSM is consistent, namely it has a single initial state, one or more final states, and no orphan states. If everything is OK, it generates a GenServer that could be used both alone, and with provided supervision tree. This GenServer requires to implement six callbacks
on_transition/4 — mandatoryon_failure/3 — optionalon_enter/2 — optionalon_exit/2 — optionalon_terminate/1 — optionalon_timer/2 — optionalAll the callbacks do have a default implementation, that would perfectly handle transitions having a single to state and not requiring any additional business logic attached.
Upon start, it moves to the next to initial state and sits there awaiting for the transition request. Then it would call an on_transition/4 callback and move to the next state, or remain in the current one, according to the response.
Upon reaching a final state, it would terminate itself. The process keeps all the history of states it went through, and might have a payload in its state.
If the event name is ended with a bang (e. g. idle --> |start!| started) and
this event is the only one allowed from this state (there might be several transitions though,)
it’d be considered as determined and FSM will be transitioned into the new state instantly.
If the event name is ended with a question mark (e. g. idle --> |start?| started,)
the transition is considered as expected to fail; no on_failure/2 callback would
be called on failure and no log warning will be printed.
If timer: non_neg_integer() option is passed to use Finitomata,
then c:Finitomata.on_timer/2 callback will be executed recurrently.
This might be helpful if FSM needs to update its state from the outside
world on regular basis.
If auto_terminate: true() | state() | [state()] option is passed to use Finitomata,
the special __end__ event to transition to the end state will be called automatically
under the hood, if the current state is either listed explicitly, or if the value of
the parameter is true.
If ensure_entry: true() | [state()] option is passed to use Finitomata, the transition
attempt will be retried with {:continue, {:transition, {event(), event_payload()}}} message
until succeeded. Neither on_failure/2 callback is called nor warning message is logged.
The payload would be updated to hold __retries__: pos_integer() key. If the payload was not a map,
it will be converted to a map %{payload: payload}.
See examples directory for
real-life examples of Finitomata usage.
Let’s define the FSM instance
defmodule MyFSM do
@fsm """
s1 --> |to_s2| s2
s1 --> |to_s3| s3
"""
use Finitomata, fsm: @fsm, syntax: :flowchart
## or uncomment lines below for `:state_diagram` syntax
# @fsm """
# [*] --> s1 : to_s1
# s1 --> s2 : to_s2
# s1 --> s3 : to_s3
# s2 --> [*] : __end__
# s3 --> [*] : __end__
# """
# use Finitomata, fsm: @fsm, syntax: :state_diagram
@impl Finitomata
def on_transition(:s1, :to_s2, _event_payload, state_payload),
do: {:ok, :s2, state_payload}
end
Now we can play with it a bit.
# or embed into supervision tree using `Finitomata.child_spec()`
{:ok, _pid} = Finitomata.start_link()
Finitomata.start_fsm MyFSM, "My first FSM", %{foo: :bar}
Finitomata.transition "My first FSM", {:to_s2, nil}
Finitomata.state "My first FSM"
#⇒ %Finitomata.State{current: :s2, history: [:s1], payload: %{foo: :bar}}
Finitomata.allowed? "My first FSM", :* # state
#⇒ true
Finitomata.responds? "My first FSM", :to_s2 # event
#⇒ false
Finitomata.transition "My first FSM", {:__end__, nil} # to final state
#⇒ [info] [◉ ⇄] [state: %Finitomata.State{current: :s2, history: [:s1], payload: %{foo: :bar}}]
Finitomata.alive? "My first FSM"
#⇒ false
Typically, one would implement all the on_transition/4 handlers, pattern matching on the state/event.
def deps do
[
{:finitomata, "~> 1.0.0-rc.0"}
]
end
1.0.0-rc.0 — [CHR] first release candidate for 1.0.00.43.0 — [UPD] :stream_data is a true optional dependency now (only: [:dev, :test, :finitomata, :ci]), and Finitomata.ExUnit.event_generator/1 is guarded behind Code.ensure_loaded?(StreamData) so Finitomata.ExUnit compiles without it;0.43.0 — [UPD] Finitomata.Persistency.Protocol.store/3/store_error/4 forward any persisted value to Finitomata.Persistency.Persistable and rescue a missing implementation into :ok, instead of only attempting structs;0.43.0 — [FIX] Finitomata.Persistency.Persistable's tuple-based struct loader looks up the generated Protocol.StructModule directly instead of impl_for/1, avoiding a Dialyzer false positive on consolidated protocols;0.42.1 — [FIX] setup_finitomata/1 supports _FSM_s declaring persistency:: the payload is no longer pinned to the one the test passed (loading decides it) and an FSM resumed with a :loaded lifecycle, which has no entry transition to await, is settled by reading its state and reports the state it resumed in; [FIX] an unloaded Finitomata.Listener module is no longer mistaken for a process name (send/2 to an unregistered atom used to raise inside the transition and take the FSM down)0.41.0 — [UPD] hibernate: accepts a state()/[state()] to hibernate only on selected states (#116); a transition resolving to a state not allowed from the current one is now rolled back instead of committed inevitably — persistency/listener are no longer touched and a new optional c:Finitomata.on_rollback/3 callback lets consumers compensate (#121); the listener is notified about failed on_fork/2 resolutions via the new optional c:Finitomata.Listener.after_fork_failure/3 (#118)0.40.0 — [UPD] Finitomata.ExUnit testing improvements: a dependency-free Finitomata.ExUnit.Listener (test without Mox), assert_no_transition/3 for failure assertions, event_generator/1 for property-based fuzzing, configurable assert_receive/refute_receive/flush timeouts, and a Mox.stub/3-by-default listener (the exact transition_count is opt-in); [DOC] documented the Access requirement for ~>, the :_ timer sugar, and fixed the moduledoc examples0.39.0 — [UPD] extracted the use Finitomata compile-time option parsing into Finitomata.ConfigBuilder and moved the safe_on_* callback wrappers' bodies into Finitomata.Engine, shrinking the generated macro (cyclomatic complexity 97 → 37); the file-level Credo suppressions on lib/finitomata.ex are gone and the thresholds were lowered (no public API change)0.38.0 — [UPD] built-in dependency-free persistence adapters Finitomata.Persistency.ETS (in-memory, survives FSM restart) and Finitomata.Persistency.DETS (disk-durable across node restart); [DOC] corrected the Finitomata.Persistency load/1 contract (it receives a {type, fields} descriptor and returns {lifecycle, {state, payload}})0.37.1 — [UPD] graph search-depth caps in Finitomata.Transition are now configurable (defaults unchanged), mix credo --strict runs on push CI; [DOC] documented why Infinitomata keeps :rpc.block_call rather than :erpc0.37.0 — [UPD] completed Finitomata.Engine extraction: the transition lifecycle, init/1, and all GenServer callbacks now live in a shared, unit-testable module, leaving each generated FSM as thin delegations plus per-module telemetry wrappers (no public API change); [FIX] credo cleanups, Credo.Check.Refactor.Nesting no longer suppressed0.36.0 — [UPD] ETS-backed state cache (configurable via :cache_backend), Finitomata.Error struct in last_error, Finitomata.Engine seam, Infinitomata RPC timeouts + backoff; [FIX] mix test alias, format_status/1 for OTP25+, OTP detection via :pg.monitor/1; deprecation warning for ambiguous start_fsm/40.30.0 — [UPD] Finitomata.Flow, backport Infinitomata to OTP25-, tons of tiny improvements0.28.0 — [UPD] initial telemetria integration0.27.0 — [UPD] options hibernate: boolean() and cache_state: boolean()0.26.0 — [UPD] a lot of tiny improvements, Finitomata.Accessible, reset_timer message + tests, experimental Finitomata.Cache0.25.0 — [UPD] allow assertions of entry states in Finitomata.ExUnit0.24.2 — [UPD/FIX] many fixes for better diagnostics in Finitomata.ExUnit0.23.7 — [UPD] allow both :mox and {:mox, MyApp.Listener} as well as just MyApp.Listener as a listener in FSM definition0.23.4 — [FIX] many fixes to a Finitomata.ExUnit test scaffold generation0.23.0 — [UPD] mix finitomata.generate.test --module MyApp.FSM to generate a Finitomata.ExUnit test scaffold0.22.0 — [FIX] Infinitomata.start_fsm/4 is finally 102% sync0.21.4 — [FIX] Finitomata.Pool initialization in cluster0.21.3 — [FIX] proper return from Infinitomata.start_fsm/40.21.1 — [UPD] listener: :mox and better Finitomata.ExUnit docs0.20.2 — [UPD] allow guard matches in the RHO of ~> operator in assert_transition/30.20.0 — [FIX] starting pool on distribution, re-synch on :badrpc failure0.19.0 — [UPD] Finitomata.ExUnit lighten options check (compile-time module dependencies suck in >=1.16)0.18.0 — [UPD] asynchronous Finitomata.Pool on top of Infinitomata0.17.0 — [UPD] careful naming and Finitomata.Throttler0.16.0 — [UPD] Infinitomata as a self-contained distributed implementation leveraging :pg0.15.0 — [UPD] support snippet formatting for modern Elixir0.14.6 — [FIX] persistency flaw when loading [credits @peaceful-james]0.14.5 — [FIX] require Logger in Hook0.14.4 — [FIX] Docs cleanup (credits: @TwistingTwists), PlantUML proper entry0.14.3 — [FIX] Draw diagram in docs0.14.2 — [FIX] Stop Events process0.14.1 — [FIX] Incorrect detection of superfluous determined transitions0.14.0 — Finitomata.ExUnit improvements0.13.0 — compile-time helpers for FSM, Finitomata.ExUnit0.12.1 — c:Finitomata.on_start/1 callback0.11.3 — [FIX] better error message for options (credits @ray-sh)0.11.2 — [DEBT] exported Finitomata.fqn/20.11.1 — Inspect, :flowchart/:state_diagram as default parsers, behaviour Parser0.11.0 — {:ok, state_payload} return from on_timer/2, :persistent_term to cache state0.10.0 — support for several supervision trees with ids, experimental support for persistence scaffold0.9.0 — [FIX] malformed callbacks had the FSM broken0.8.2 — last error is now kept in the state (credits to @egidijusz)0.8.1 — improvements to :finitomata compiler0.8.0 — :finitomata compiler to warn/hint about not implemented ambiguous transitions0.7.2 — [FIX] banged! transitions must not be determined0.6.3 — soft? events which do not call on_failure/2 and do not log errors0.6.2 — ensure_entry: option to retry a transition0.6.1 — code cleanup + auto_terminate: option to make :__end__ transition imminent0.6.0 — on_timer/2 and banged imminent transitions0.5.2 — state() type on generated FSMs0.5.1 — fixed specs [credits @egidijusz]0.5.0 — all callbacks but on_transition/4 are optional, accept impl_for: param to use Finitomata0.4.0 — allow anonymous FSM instances0.3.0 — en_entry/2 and on_exit/2 optional callbacks0.2.0 — Mermaid supportElixir
100.0%
FSM implementation generated from Mermaid/PlantUML textual representation
Elixir
130
137 commits
updated Aug 1, 2026
Finitomata The FSM boilerplate based on callbacks
Finitomata provides a boilerplate for FSM implementation, allowing to concentrate on the business logic rather than on the process management and transitions/events consistency tweaking.
It reads a description of the FSM from a string in PlantUML, Mermaid, or even custom format.
Syntax Definition {: .tip}
Mermaidstate diagram format is literally the same asPlantUML, so if you want to use it, specifysyntax: :state_diagramand if you want to use mermaid graph, specifysyntax: :flowchart. The latter is the default.
Basically, it looks more or less like this
PlantUML / :state_diagram[*] --> s1 : to_s1
s1 --> s2 : to_s2
s1 --> s3 : to_s3
s2 --> [*] : ok
s3 --> [*] : ok
Mermaid / :flowcharts1 --> |to_s2| s2
s1 --> |to_s3| s3
Using
syntax: :flowchart{: .tip}
Mermaiddoes not allow to explicitly specify transitions (and hence event names) from the starting state and to the end state(s), these states names are implicitly set to:*and events to:__start__and:__end__respectively.
Finitomata validates the FSM is consistent, namely it has a single initial state, one or more final states, and no orphan states. If everything is OK, it generates a GenServer that could be used both alone, and with provided supervision tree. This GenServer requires to implement six callbacks
on_transition/4 — mandatoryon_failure/3 — optionalon_enter/2 — optionalon_exit/2 — optionalon_terminate/1 — optionalon_timer/2 — optionalAll the callbacks do have a default implementation, that would perfectly handle transitions having a single to state and not requiring any additional business logic attached.
Upon start, it moves to the next to initial state and sits there awaiting for the transition request. Then it would call an on_transition/4 callback and move to the next state, or remain in the current one, according to the response.
Upon reaching a final state, it would terminate itself. The process keeps all the history of states it went through, and might have a payload in its state.
If the event name is ended with a bang (e. g. idle --> |start!| started) and
this event is the only one allowed from this state (there might be several transitions though,)
it’d be considered as determined and FSM will be transitioned into the new state instantly.
If the event name is ended with a question mark (e. g. idle --> |start?| started,)
the transition is considered as expected to fail; no on_failure/2 callback would
be called on failure and no log warning will be printed.
If timer: non_neg_integer() option is passed to use Finitomata,
then c:Finitomata.on_timer/2 callback will be executed recurrently.
This might be helpful if FSM needs to update its state from the outside
world on regular basis.
If auto_terminate: true() | state() | [state()] option is passed to use Finitomata,
the special __end__ event to transition to the end state will be called automatically
under the hood, if the current state is either listed explicitly, or if the value of
the parameter is true.
If ensure_entry: true() | [state()] option is passed to use Finitomata, the transition
attempt will be retried with {:continue, {:transition, {event(), event_payload()}}} message
until succeeded. Neither on_failure/2 callback is called nor warning message is logged.
The payload would be updated to hold __retries__: pos_integer() key. If the payload was not a map,
it will be converted to a map %{payload: payload}.
See examples directory for
real-life examples of Finitomata usage.
Let’s define the FSM instance
defmodule MyFSM do
@fsm """
s1 --> |to_s2| s2
s1 --> |to_s3| s3
"""
use Finitomata, fsm: @fsm, syntax: :flowchart
## or uncomment lines below for `:state_diagram` syntax
# @fsm """
# [*] --> s1 : to_s1
# s1 --> s2 : to_s2
# s1 --> s3 : to_s3
# s2 --> [*] : __end__
# s3 --> [*] : __end__
# """
# use Finitomata, fsm: @fsm, syntax: :state_diagram
@impl Finitomata
def on_transition(:s1, :to_s2, _event_payload, state_payload),
do: {:ok, :s2, state_payload}
end
Now we can play with it a bit.
# or embed into supervision tree using `Finitomata.child_spec()`
{:ok, _pid} = Finitomata.start_link()
Finitomata.start_fsm MyFSM, "My first FSM", %{foo: :bar}
Finitomata.transition "My first FSM", {:to_s2, nil}
Finitomata.state "My first FSM"
#⇒ %Finitomata.State{current: :s2, history: [:s1], payload: %{foo: :bar}}
Finitomata.allowed? "My first FSM", :* # state
#⇒ true
Finitomata.responds? "My first FSM", :to_s2 # event
#⇒ false
Finitomata.transition "My first FSM", {:__end__, nil} # to final state
#⇒ [info] [◉ ⇄] [state: %Finitomata.State{current: :s2, history: [:s1], payload: %{foo: :bar}}]
Finitomata.alive? "My first FSM"
#⇒ false
Typically, one would implement all the on_transition/4 handlers, pattern matching on the state/event.
def deps do
[
{:finitomata, "~> 1.0.0-rc.0"}
]
end
1.0.0-rc.0 — [CHR] first release candidate for 1.0.00.43.0 — [UPD] :stream_data is a true optional dependency now (only: [:dev, :test, :finitomata, :ci]), and Finitomata.ExUnit.event_generator/1 is guarded behind Code.ensure_loaded?(StreamData) so Finitomata.ExUnit compiles without it;0.43.0 — [UPD] Finitomata.Persistency.Protocol.store/3/store_error/4 forward any persisted value to Finitomata.Persistency.Persistable and rescue a missing implementation into :ok, instead of only attempting structs;0.43.0 — [FIX] Finitomata.Persistency.Persistable's tuple-based struct loader looks up the generated Protocol.StructModule directly instead of impl_for/1, avoiding a Dialyzer false positive on consolidated protocols;0.42.1 — [FIX] setup_finitomata/1 supports _FSM_s declaring persistency:: the payload is no longer pinned to the one the test passed (loading decides it) and an FSM resumed with a :loaded lifecycle, which has no entry transition to await, is settled by reading its state and reports the state it resumed in; [FIX] an unloaded Finitomata.Listener module is no longer mistaken for a process name (send/2 to an unregistered atom used to raise inside the transition and take the FSM down)0.41.0 — [UPD] hibernate: accepts a state()/[state()] to hibernate only on selected states (#116); a transition resolving to a state not allowed from the current one is now rolled back instead of committed inevitably — persistency/listener are no longer touched and a new optional c:Finitomata.on_rollback/3 callback lets consumers compensate (#121); the listener is notified about failed on_fork/2 resolutions via the new optional c:Finitomata.Listener.after_fork_failure/3 (#118)0.40.0 — [UPD] Finitomata.ExUnit testing improvements: a dependency-free Finitomata.ExUnit.Listener (test without Mox), assert_no_transition/3 for failure assertions, event_generator/1 for property-based fuzzing, configurable assert_receive/refute_receive/flush timeouts, and a Mox.stub/3-by-default listener (the exact transition_count is opt-in); [DOC] documented the Access requirement for ~>, the :_ timer sugar, and fixed the moduledoc examples0.39.0 — [UPD] extracted the use Finitomata compile-time option parsing into Finitomata.ConfigBuilder and moved the safe_on_* callback wrappers' bodies into Finitomata.Engine, shrinking the generated macro (cyclomatic complexity 97 → 37); the file-level Credo suppressions on lib/finitomata.ex are gone and the thresholds were lowered (no public API change)0.38.0 — [UPD] built-in dependency-free persistence adapters Finitomata.Persistency.ETS (in-memory, survives FSM restart) and Finitomata.Persistency.DETS (disk-durable across node restart); [DOC] corrected the Finitomata.Persistency load/1 contract (it receives a {type, fields} descriptor and returns {lifecycle, {state, payload}})0.37.1 — [UPD] graph search-depth caps in Finitomata.Transition are now configurable (defaults unchanged), mix credo --strict runs on push CI; [DOC] documented why Infinitomata keeps :rpc.block_call rather than :erpc0.37.0 — [UPD] completed Finitomata.Engine extraction: the transition lifecycle, init/1, and all GenServer callbacks now live in a shared, unit-testable module, leaving each generated FSM as thin delegations plus per-module telemetry wrappers (no public API change); [FIX] credo cleanups, Credo.Check.Refactor.Nesting no longer suppressed0.36.0 — [UPD] ETS-backed state cache (configurable via :cache_backend), Finitomata.Error struct in last_error, Finitomata.Engine seam, Infinitomata RPC timeouts + backoff; [FIX] mix test alias, format_status/1 for OTP25+, OTP detection via :pg.monitor/1; deprecation warning for ambiguous start_fsm/40.30.0 — [UPD] Finitomata.Flow, backport Infinitomata to OTP25-, tons of tiny improvements0.28.0 — [UPD] initial telemetria integration0.27.0 — [UPD] options hibernate: boolean() and cache_state: boolean()0.26.0 — [UPD] a lot of tiny improvements, Finitomata.Accessible, reset_timer message + tests, experimental Finitomata.Cache0.25.0 — [UPD] allow assertions of entry states in Finitomata.ExUnit0.24.2 — [UPD/FIX] many fixes for better diagnostics in Finitomata.ExUnit0.23.7 — [UPD] allow both :mox and {:mox, MyApp.Listener} as well as just MyApp.Listener as a listener in FSM definition0.23.4 — [FIX] many fixes to a Finitomata.ExUnit test scaffold generation0.23.0 — [UPD] mix finitomata.generate.test --module MyApp.FSM to generate a Finitomata.ExUnit test scaffold0.22.0 — [FIX] Infinitomata.start_fsm/4 is finally 102% sync0.21.4 — [FIX] Finitomata.Pool initialization in cluster0.21.3 — [FIX] proper return from Infinitomata.start_fsm/40.21.1 — [UPD] listener: :mox and better Finitomata.ExUnit docs0.20.2 — [UPD] allow guard matches in the RHO of ~> operator in assert_transition/30.20.0 — [FIX] starting pool on distribution, re-synch on :badrpc failure0.19.0 — [UPD] Finitomata.ExUnit lighten options check (compile-time module dependencies suck in >=1.16)0.18.0 — [UPD] asynchronous Finitomata.Pool on top of Infinitomata0.17.0 — [UPD] careful naming and Finitomata.Throttler0.16.0 — [UPD] Infinitomata as a self-contained distributed implementation leveraging :pg0.15.0 — [UPD] support snippet formatting for modern Elixir0.14.6 — [FIX] persistency flaw when loading [credits @peaceful-james]0.14.5 — [FIX] require Logger in Hook0.14.4 — [FIX] Docs cleanup (credits: @TwistingTwists), PlantUML proper entry0.14.3 — [FIX] Draw diagram in docs0.14.2 — [FIX] Stop Events process0.14.1 — [FIX] Incorrect detection of superfluous determined transitions0.14.0 — Finitomata.ExUnit improvements0.13.0 — compile-time helpers for FSM, Finitomata.ExUnit0.12.1 — c:Finitomata.on_start/1 callback0.11.3 — [FIX] better error message for options (credits @ray-sh)0.11.2 — [DEBT] exported Finitomata.fqn/20.11.1 — Inspect, :flowchart/:state_diagram as default parsers, behaviour Parser0.11.0 — {:ok, state_payload} return from on_timer/2, :persistent_term to cache state0.10.0 — support for several supervision trees with ids, experimental support for persistence scaffold0.9.0 — [FIX] malformed callbacks had the FSM broken0.8.2 — last error is now kept in the state (credits to @egidijusz)0.8.1 — improvements to :finitomata compiler0.8.0 — :finitomata compiler to warn/hint about not implemented ambiguous transitions0.7.2 — [FIX] banged! transitions must not be determined0.6.3 — soft? events which do not call on_failure/2 and do not log errors0.6.2 — ensure_entry: option to retry a transition0.6.1 — code cleanup + auto_terminate: option to make :__end__ transition imminent0.6.0 — on_timer/2 and banged imminent transitions0.5.2 — state() type on generated FSMs0.5.1 — fixed specs [credits @egidijusz]0.5.0 — all callbacks but on_transition/4 are optional, accept impl_for: param to use Finitomata0.4.0 — allow anonymous FSM instances0.3.0 — en_entry/2 and on_exit/2 optional callbacks0.2.0 — Mermaid supportElixir
100.0%