DEV Community

Cover image for Designing solutions with state machines in Elixir
Norberto Oliveira Junior
Norberto Oliveira Junior

Posted on • Updated on

Designing solutions with state machines in Elixir

You may have had to design some feature that needed to accomplish an execution flow in your software, such as, wizards, admission processes, game rules, logic circuits, etc. A good fit to build this kind of solution is design it relying on a mathematical abstraction called state machines.

Basically a state machine is an implementation of an abstract machine that declares possible states and controls state transitions in order to guarantee a correct execution flow of something. A more detailed definition you can read from wikipedia.

In this post we are going to see how we can implement such a thing in Elixir and how amazing is implementing it in this language by writing functions pattern matching on its parameters making an intentional and declarative design without using if, switch or case statements.

Let's take as an example this diagram below. Here we have a hypothetical flow of a user registration in a website.

state machine diagram

Here is the flow:

  1. The user needs to submit a registration form that's the flow's starting point and the initial state of our state machine represented by the registration_form value;
  2. When the user submits the form we represent it as an event named form_submitted which triggers a transition to the next state awaiting_email_confirmation;
  3. At the awaiting_email_confirmation we have two possible transitions:
    1. The user can ask to resend the email confirmation which triggers the resend_email_confirmation event that keeps the state in awaiting_email_confirmation;
    2. The user confirms his/her email which triggers the email_confirmed event changing the state to registration_finished finishing the user registration flow.

Now let's see how we would translate this diagram to Elixir:

First, we could define a module named User that represents a user along with a struct with related fields and a state field that stores the state —as its name suggests— having by default an initial state value registration_form.

defmodule User do
  defstruct [:name, :email, state: :registration_form]
end
Enter fullscreen mode Exit fullscreen mode

If you define this module in the iex session and evaluates the module struct we'll have as a return this:

iex> %User{}
%User{
  email: nil,
  name: nil,
  state: :registration_form
}
Enter fullscreen mode Exit fullscreen mode

Note that we have the initial scenario of our diagram here, a user at the registration_form state. Ok, but how do we make the transitions? Is it just to update the state field when we want? No. Remember, we are implementing an abstract machine that must controls the execution flow and it needs to ensure us that the rules are being followed.

Implementing the transitions

Now comes the part of implementing the transitions where we rely on the amazing Elixir feature of pattern matching things. The idea here is to have this API below for our User module:

iex> User.transit(user, event: "form_submitted")
{:ok, %User{state: :awaiting_email_confirmation}}
Enter fullscreen mode Exit fullscreen mode

As a first parameter we provide a user variable that should be a %User{} struct and as a second parameter a keyword event representing the action we want to perform. And as a result the User.transit/2 function should return a tuple with the second value being the user with a new/next state.

Internally our state machine should check whether this event is allowed or not based on the user current state in combination with the event. So, let's implement our first diagram step and take a look on it.

defmodule User do
  defstruct [:name, :email, :password, state: :registration_form]

  def transit(%User{state: :registration_form} = user, event: "form_submitted") do
    {:ok, %User{user | state: :awaiting_email_confirmation}}
  end
end
Enter fullscreen mode Exit fullscreen mode

In this transit function we destructure the user attributes pattern matching on its state value, also on the second param —that is a keyword list with just one keyword event—. According to our diagram only a user with state registration_form and the event being form_submitted that transits the user to awaiting_email_confirmation. If this is not clear, go check again the diagram and see how this code fulfils the initial step.

Now, How do you think we should implement the next transition? Take a moment to think and try to implement yourself based on the diagram where the user is on the awaiting_email_confirmation state and the event is resend_email_confirmation. What state should our state machine transit to?

defmodule User do
  defstruct [:name, :email, :password, 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
end
Enter fullscreen mode Exit fullscreen mode

As you can see our next transition we are pattern matching on the combination we want (state + event) and just returns the user itself as it is because in this step we should keep the same user state.

Now let's implement the last transition: the user state as awaiting_confirmation_email and the event email_confirmed changing the user state to registration_finished

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

So far we have almost completed our implementation however we already have a functional state machine according to the diagram. Back to the iex session let's try it out:

iex> user = %User{name: "Luke", email: "example@mail.com"}
%User{name: "Luke", email: "example@mail.com", state: :registration_form}
Enter fullscreen mode Exit fullscreen mode
iex> {:ok, user} = User.transit(user, event: "form_submitted")
{:ok, %User{name: "Luke", email: "example@mail.com", state: :awaiting_email_confirmation}}
Enter fullscreen mode Exit fullscreen mode
iex> {:ok, user} = User.transit(user, event: "resend_email_confirmation")
{:ok, %User{name: "Luke", email: "example@mail.com", state: :awaiting_email_confirmation}}
Enter fullscreen mode Exit fullscreen mode
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

Cool, we have transited through all the diagram steps here but if we try an unexpected transition what happen?

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

This is expected, our state machine is working, we don't have and we mustn't allow this kind of transition, because this is a user in the registration_form state performing the email_confirmed event which is clearly trying to finish the registration process without step through the confirmation email.

If we want we can define a catch-all function that returns a not allowed transition error if none of the functions we have defined so far matches, thus not letting to crash the program. Defines this function below as the last one after all other ones in the module.

defmodule User do
  # ...
  def transit(_, _), do: {:error, :transition_not_allowed}
end
Enter fullscreen mode Exit fullscreen mode

So when we call User.transit/2 Elixir will check each function in the definition order and if none of them match this catch-all function will be called. Note that we just ignore what parameters are coming since it doesn't matter here. So let's try again:

iex> User.transit(%User{}, event: "email_confirmed")
{:error, :transition_not_allowed}
Enter fullscreen mode Exit fullscreen mode

Finally this is our final implementation which can be a layer helping us to guarantee the integrity of the functioning of our software, something decoupled of other parts.

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

State machine libs in Elixir

There are also some libs in Elixir that helps us implement state machines, one of them is the machinist that I have created to help me write state machines at work. I knew at the time there were others libs before I write that one but I needed something decoupled of ecto and processes, that were simple to use and have a nice DSL easy to read that even non-developer people in my team could understand the rules from the code as well as new comers developers not experienced in Elixir.

Let's write our example above using the machinist:

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

The api this code generates is the same as the examples we wrote in this post, the implementation above creates User.transit/2 functions in the User module.

Note how the DSL above helps us eliminate a lot of boilerplate making it easier to maintain and less prone to errors.

These below are other state machine libs that I know, maybe these other ones could be a better fit for your problem:

Conclusion

Designing solutions with state machines get our back, guaranteeing that we won't have a corrupt state and also securing our software of unexpected behaviours. Imagine maintain a big flow in our software, without state machines it could be more exposed to bugs. Beyond that implementing it in Elixir we have an intentional and declarative code and business logic flow that turns easier (in my opinion) to understand the big picture.

Hope you liked it and maybe next time you have a problem to solve that using state machines could help you.

Discussion (4)

Collapse
simonmcconnell profile image
Simon McConnell

Nice one, love me a good state machine 👍. I'm a big user of gen_statem for managing connections and stepped procedures with timeouts for retries.

Collapse
norbajunior profile image
Norberto Oliveira Junior Author

Nice! Sounds interesting Simon! I got curious now about state machines on these situations.

Collapse
miguelcoba profile image
Miguel Cobá

Very well explained, Norberto. Bookmarked it. Thanks for writing it.

Collapse
norbajunior profile image
Norberto Oliveira Junior Author

Thanks for your feedback @miguelcoba . I’m glad you liked it. ☺️