This content originally appeared on DEV Community and was authored by Norberto Oliveira Junior
You have already probably found yourself having 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 an hypotetical flow of a user registration in a website.
Here is the flow:
- 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; - When the user submits the form we represent it as an event named
form_submitted
which triggers a transition to the next stateawaiting_email_confirmation
; - At the
awaiting_email_confirmation
we have two possible transitions:- The user can ask to resend the email confirmation which triggers the
resend_email_confirmation
event that keeps the state inawaiting_email_confirmation
; - The user confirms his/her email which triggers the
email_confirmed
event changing the state toregistration_finished
finishing the user registration flow.
- The user can ask to resend the email confirmation which triggers the
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, :password, state: :registration_form]
end
If you define this module in the iex session and evaluates the module struct we'll have as a return this:
iex> user = %User{}
%User{
email: nil,
name: nil,
state: :registration_form
}
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}}
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
In this transit
function we destructure the user params 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
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
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}
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: "resent_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}}
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"]
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
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}
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
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
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:
- machinery
- gen_statem (actually an Erlang module)
- fsmx
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.
This content originally appeared on DEV Community and was authored by Norberto Oliveira Junior
Norberto Oliveira Junior | Sciencx (2021-11-07T02:27:23+00:00) Designing solutions with state machines in Elixir. Retrieved from https://www.scien.cx/2021/11/07/designing-solutions-with-state-machines-in-elixir/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.