Have you ever needed to build solutions related to execution flows like wizards, admission processes, or game rules in your software? If you do, you know it can be a nightmare of conditionals and complexity. We can make our lives easier by developing them using state machines.
In this post, we will see how easy the implementation of such a thing is in Elixir by using the pattern matching feature.
The basics
With a state machine, you can declare a set of states and transition controls to ensure the correct execution of a given flow. States represent data at a specific moment in time, and transition controls coordinate the change of states in a system.
Let’s use a website’s user registration process as an example. Take a look at the diagram below:
We will use three states (registration_form
, awaiting_email_confirmation
,
registration_finished
) and three events (form_submitted
, resend_email_confirmation
, email_confirmed
)
to meet our requirements. The transition control will guarantee that we will go to the correct state every
time an event fires.
Now, let’s understand how to do that in practice.
The implementation
Our code starts with the definition of a new struct called User
. The attribute that matters for our example is the state
,
with the initial value of registration_form
.
defmodule User do
defstruct [:name, :email, state: :registration_form]
end
We can check if everything is working by opening an iex session with our code:
iex> %User{}
%User{
email: nil,
name: nil,
state: :registration_form
}
Perfect. Our User
module also needs a function that changes the state from one value to another - the
transition control. Its signature will be User.transit(%User{}, event)
.
defmodule User do
defstruct [:name, :email, :password, state: :registration_form]
def transit(user, event) do
{:ok, user}
end
end
Remember that our transition controls exist to ensure our states follow the correct flow.
According to our diagram, the first change is from registration_form
to awaiting_email_confirmation
, and it translates to the following in our code:
defmodule User do
defstruct [:name, :email, :password, state: :registration_form]
def transit(%User{state: :registration_form}, event: "form_submitted") do
{:ok, %User{user | state: :awaiting_email_confirmation}
end
end
We pass the user struct and the event reporting what action happened, and it returns an updated struct with the next stage of our flow. The transition is straightforward, thanks to the power of pattern matching.
The remaining transitions use the same idea:
defmodule User do
defstruct [:name, :email, state: :registration_form]
def transit(%User{state: :registration_form} = user, event: "form_submitted") do
{:ok, %User{user | state: :awaiting_email_confirmation}}
end
def transit(%User{state: :awaiting_email_confirmation} = user, event: "resend_email_confirmation") do
{:ok, user}
end
def transit(%User{state: :awaiting_email_confirmation} = user, event: "email_confirmed") do
{:ok, %User{user | state: :registration_finished}}
end
end
Great! Let’s go back to our iex session to test our code. Remember to recompile it before the test.
iex> user = %User{name: "Luke", email: "example@mail.com"}
%User{name: "Luke", email: "example@mail.com", state: :registration_form}
iex> {:ok, user} = User.transit(user, event: "form_submitted")
{:ok, %User{name: "Luke", email: "example@mail.com", state: :awaiting_email_confirmation}}
iex> {:ok, user} = User.transit(user, event: "resend_email_confirmation")
{:ok, %User{name: "Luke", email: "example@mail.com", state: :awaiting_email_confirmation}}
iex> {:ok, user} = User.transit(user, event: "email_confirmed")
{:ok, %User{name: "Luke", email: "example@mail.com", state: :registration_finished}}
There’s one thing left in our code. Let’s see what happens if we try an unknown transition.
iex> User.transit(%User{}, event: "email_confirmed")
** (FunctionClauseError) no function clause matching in User.transit/2
The following arguments were given to User.transit/2:
# 1
%User{email: nil, name: nil state: :registration_form}
# 2
[event: "email_confirmed"]
That happens because no transitions allow a change from registration_form
with the email_confirmed
event. We need a catch-all function to prevent errors like that.
defmodule User do
# ...
def transit(_, _), do: {:error, :transition_not_allowed}
end
When we call User.transit/2
, Elixir will check each transition in their definition order, and if none matches, the catch-all function is called. Note that we are ignoring the parameters because they don’t matter. Let’s try again:
iex> User.transit(%User{}, event: "email_confirmed")
{:error, :transition_not_allowed}
Awesome! Now, let’s compare our initial diagram with its implementation.
defmodule User do
defstruct [:name, :email, state: :registration_form]
def transit(%User{state: :registration_form} = user, event: "form_submitted") do
{:ok, %User{user | state: :awaiting_email_confirmation}}
end
def transit(%User{state: :awaiting_email_confirmation} = user, event: "resend_email_confirmation") do
{:ok, user}
end
def transit(%User{state: :awaiting_email_confirmation} = user, event: "email_confirmed") do
{:ok, %User{user | state: :registration_finished}}
end
def transit(_, _), do: {:error, :transition_not_allowed}
end
Simple, huh? We did all that in 17 lines of code without using a single conditional.
State machine libs in Elixir
The Elixir ecosystem has some libs that abstract the logic of implementing the transition function to ourselves. One of them is the machinist, which I wrote to help me manage state machines that don’t require Ecto or processes. Also, non-developers can understand it thanks to its easy-to-read DSL.
Let’s write our example using the lib:
defmodule User do
defstruct [:name, :email, state: :registration_form]
use Machinist
transitions do
from :registration_form, to: :awaiting_email_confirmation, event: "form_submitted"
from :awaiting_email_confirmation, to: :awaiting_email_confirmation, event: "resend_email_confirmation"
from :awaiting_email_confirmation, to: :registration_finished, event: "email_confirmed"
end
end
This code generates the exact implementation we did above, but it eliminates all the boilerplate, making it easier to read and maintain and less prone to errors.
You can explore some additional libs that also handle state machines if the machinist doesn’t fit your needs:
- machinery;
- gen_statem (actually an Erlang module);
- fsmx.
Conclusion
Designing solutions with state machines get our back, ensuring we won’t have a corrupt state and securing our software from unexpected behaviors. Beyond that, implementing it in Elixir helps us write intentional and declarative code, allowing everyone to understand the big picture quickly.
I hope you learned something new today. Thanks for reading it!
See you in the next post!