Designing solutions with state machines in Elixir

norbajunior

Norberto Oliveira Junior

Posted on November 7, 2021

Designing solutions with state machines in Elixir

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:

state-diagram




The system will send an email confirmation after the submission of the form. We can also resend it if something unexpected happens.


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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}}
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

Awesome! Now, let's compare our initial diagram with its implementation.

state-diagram

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

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!

💖 💪 🙅 🚩
norbajunior
Norberto Oliveira Junior

Posted on November 7, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related