Machinist (Machinist v1.0.0) View Source
Machinist is a small library that allows you to implement finite state
machines in a simple way. It provides a simple DSL to write combinations of
transitions based on events.
A good example is how we would implement the behaviour of a door. With
machinist would be this way:
defmodule Door do
defstruct [state: :locked]
use Machinist
transitions do
from :locked, to: :unlocked, event: "unlock"
from :unlocked, to: :locked, event: "lock"
from :unlocked, to: :opened, event: "open"
from :opened, to: :closed, event: "close"
from :closed, to: :opened, event: "open"
from :closed, to: :locked, event: "lock"
end
endBy defining these rules with transitions and from macros, machinist
generates and injects into the module Door transit/2 functions like this
one:
def transit(%Door{state: :locked} = struct, event: "unlock") do
{:ok, %Door{struct | state: :unlocked}}
endSo that we can transit between states by relying on the state + event pattern matching.
Let's see this in practice:
By default, our Door is locked:
iex> door_locked = %Door{}
%Door{state: :locked}So let's change its state to unlocked and opened:
iex> {:ok, door_unlocked} = Door.transit(door_locked, event: "unlock")
{:ok, %Door{state: :unlocked}}
iex> {:ok, door_opened} = Door.transit(door_unlocked, event: "open")
{:ok, %Door{state: :opened}}If we try to make a transition that does not follow the rules, we get an error:
iex> Door.transit(door_opened, event: "lock")
{:error, :not_allowed}Guard conditions
We could also implement a state machine for an electronic door which should validate a passcode to unlock it. In this scenario, the machinist allows us to provide a function to evaluate a condition and return the new state.
Check out the diagram below representing it:

And to have this condition for the unlock event, use the event macro passing the guard option with a one-arity function:
# ..
transitions do
event "unlock", guard: &check_passcode/1 do
from :locked, to: :unlocked
from :locked, to: :locked
end
end
defp check_passcode(door) do
if some_condition, do: :unlocked, else: :locked
endSo when we call Door.transit(%Door{state: :locked}, event: "unlock") the guard function check_passcode/1 will be called with the struct door as the first parameter and returns the new state to be set.
Setting a different attribute name that holds the state
By default, machinist expects the struct being updated to hold a state
attribute, if you have state in a different attribute, pass the name as an
atom, as follows:
transitions attr: :door_state do
# ...
endAnd then machinist will set the state in that attribute.
iex> Door.transit(door, event: "unlock")
{:ok, %Door{door_state: :unlocked}}Implementing different versions of a state machine
Let's suppose we want to build a selection process app that handles applications of candidates, and they may go through different versions of the process. For example:
A Selection Process V1 with the following sequence of stages: [Registration] -> [Code test] -> [Enrollment]
And a Selection Process V2 with these ones: [Registration] -> [Interview] -> [Enrollment]
The difference here is in V1 candidates must take a Code Test and V2 an Interview.
So, we could have a %Candidate{} struct that holds these attributes:
defmodule SelectionProcess.Candidate do
defstruct [:name, :state, test_score: 0]
endAnd a SelectionProcess module that implements the state machine. Notice this
time we don't want to implement the rules in the module that holds the state,
in this case, it makes more sense for the SelectionProcess to keep the rules, also
because we want more than one state machine version handling candidates as
mentioned before. This is our V1 of the process:
defmodule SelectionProcess.V1 do
use Machinist
alias SelectionProcess.Candidate
@minimum_score 70
transitions Candidate do
from :new, to: :registered, event: "register"
from :registered, to: :started_test, event: "start_test"
event "send_test", guard: &check_score/1 do
from :started_test, to: :approved
from :started_test, to: :reproved
end
from :approved, to: :enrolled, event: "enroll"
end
defp check_score(%Candidate{test_score: score}) do
if score >= @minimum_score, do: :approved, else: :reproved
end
endIn this code, we pass the Candidate module as a parameter to transitions to
tell machinist that we expect V1.transit/2 functions with a %Candidate{}
struct as first argument and not the %SelectionProcess.V1{} which would be by
default.
def transit(%Candidate{state: :new} = struct, event: "register") do
{:ok, %Candidate{struct | state: :registered}}
endAlso notice we provided the function &check_score/1 to the option to:
instead of an atom, to decide the state based on the candidate
test_score value.
In version 2, we replaced the Code Test stage with the Interview
which has different state transitions:
defmodule SelectionProcess.V2 do
use Machinist
alias SelectionProcess.Candidate
transitions Candidate do
from :new, to: :registered, event: "register"
from :registered, to: :interview_scheduled, event: "schedule_interview"
from :interview_scheduled, to: :approved, event: "approve_interview"
from :interview_scheduled, to: :repproved, event: "reprove_interview"
from :approved, to: :enrolled, event: "enroll"
end
endNow let's see how we can test it:
V1: A registered candidate wants to start his test.
iex> candidate1 = %Candidate{name: "Ada", state: :registered}
iex> SelectionProcess.V1.transit(candidate1, event: "start_test")
%{:ok, %Candidate{state: :test_started}}V2: A registered candidate wants to schedule the interview
iex> candidate2 = %Candidate{name: "Jose", state: :registered}
iex> SelectionProcess.V2.transit(candidate1, event: "schedule_interview")
%{:ok, %Candidate{state: :interview_scheduled}}That's great because we also can implement many state machines for only one entity and test different scenarios, evaluate and collect data for deciding which one is better.
machinist gives us this flexibility since it's just pure Elixir.
Transiting from any state to another
Sometimes we need to define a from any state transition.
Still, in the selection process example, candidates can abandon the process in
a given state, and we want to be able to transit them to
application_expired from any state. To do so, we just define a from with an
underscore variable for the current state to be ignored.
defmodule SelectionProcess.V2 do
use Machinist
alias SelectionProcess.Candidate
transitions Candidate do
# ...
from _state, to: :application_expired, event: "application_expired"
end
endCode formatter
Elixir formatter (mix format) puts parenthesis around the macros.
from(:some_state, to: :another, event: "some_event")
from :some_state do
to(:another, event: "some_event")
endHowever, the machinist's.formatter.exs is configured to not use parenthesis. In order to follow the code style without parenthesis you can export the machinist config in your project.
In your .formatter.exs file, just add the following:
[
# ...
import_deps: [:machinist]
]And you're good to go 🧙♀️.
How does the DSL works?
The use of transitions in combination with each from statement will be
transformed in functions that will be injected into the module that is using
machinist.
This implementation:
defmodule Door do
defstruct state: :locked
use Machinist
transitions do
from :locked, to: :unlocked, event: "unlock"
from :unlocked, to: :locked, event: "lock"
from :unlocked, to: :opened, event: "open"
from :opened, to: :closed, event: "close"
from :closed, to: :opened, event: "open"
from :closed, to: :locked, event: "lock"
end
endIt is the same as:
defmodule Door do
defstruct state: :locked
def transit(%__MODULE__{state: :locked} = struct, event: "unlock") do
{:ok, %__MODULE__{struct | state: :unlocked}}
end
def transit(%__MODULE__{state: :unlocked} = struct, event: "lock") do
{:ok, %__MODULE__{struct | state: :locked}}
end
def transit(%__MODULE__{state: :unlocked} = struct, event: "open") do
{:ok, %__MODULE__{struct | state: :opened}}
end
def transit(%__MODULE__{state: :opened} = struct, event: "close") do
{:ok, %__MODULE__{struct | state: :closed}}
end
def transit(%__MODULE__{state: :closed} = struct, event: "open") do
{:ok, %__MODULE__{struct | state: :opened}}
end
def transit(%__MODULE__{state: :closed} = struct, event: "lock") do
{:ok, %__MODULE__{struct | state: :locked}}
end
# a catchall function in case of unmatched clauses
def transit(_, _), do: {:error, :not_allowed}
endSo, as we can see, we can eliminate a lot of boilerplate with machinist
making it easier to maintain and less prone to errors.
Link to this section Summary
Functions
Defines an event block grouping a same-event from -> to transitions
Defines an event block grouping a same-event from -> to transitions
with a guard function that should evaluates a condition and returns a new state.
Defines a state transition with the given state, and the list of options [to: new_state, event: event]
Defines a block of transitions.
Defines a block of transitions for a specific struct or defines a block of
transitions just passing the attr option to define the attribute holding the state
Defines a block of transitions for a specific struct with attr option
defining the attribute holding the state
Link to this section Functions
Defines an event block grouping a same-event from -> to transitions
event "form_submitted" do
from :form1, to: :form2
from :form2, to: :test
end
Defines an event block grouping a same-event from -> to transitions
with a guard function that should evaluates a condition and returns a new state.
event "update_score"", guard: &check_score/1 do
from :test, to: :approved
from :test, to: :reproved
end
defp check_score(%{score: score}) do
if score >= 70, do: :approved, else: :reproved
end
Defines a state transition with the given state, and the list of options [to: new_state, event: event]
from 1, to: 2, event: "next"It's also possible to define a from any state transition to another specific one, by just passing an underscore variable in place of a real state value
from _state, to: :expired, event: "enrollment_expired"
Defines a block of transitions.
By default transitions/1 expects the module using Machinist has a struct
defined with a state attribute
transitions do
# ...
end
Defines a block of transitions for a specific struct or defines a block of
transitions just passing the attr option to define the attribute holding the state
Examples
A Candidate being handled by two different versions of a SelectionProcess
defmodule Candidate do
defstruct state: :new
end
defmodule SelectionProcess.V1 do
use Machinist
transitions Candidate do
from :new, to: :registered, event: "register"
end
end
defmodule SelectionProcess.V2 do
use Machinist
transitions Candidate do
from :new, to: :enrolled, event: "enroll"
end
end
Providing the attr option to define the attribute holding the state
defmodule Candidate do
defstruct candidate_state: :new
use Machinist
transitions attr: :candidate_state do
from :new, to: :registered, event: "register"
end
end
Defines a block of transitions for a specific struct with attr option
defining the attribute holding the state
transitions Candidate, attr: :candidate_state do
# ...
end