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

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.

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

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:

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!