Building a state machine in Elixir and Ecto

There are many useful design patterns, and the concept of a state machine is one of the useful design patterns.



A state machine is great when you are modeling a complex business process in which states transition from a predefined set of states and each state must have its own predefined behavior.



In this post, you will learn how to implement this pattern with Elixir and Ecto.



Use cases



A state machine can be a great choice when you are modeling a complex, multi-step business process and where specific requirements are imposed on each step.



Examples:



  • Registration in your personal account. In this process, the user first signs up, then adds some additional information, then confirms his email, then turns on 2FA, and only after that he gets access to the system.
  • Shopping basket. At first it is empty, then you can add products to it, and then the user can proceed to payment and delivery.
  • A pipeline of tasks in project management systems. For example: initially the tasks have the " created " status , then the task can be " assigned " to the executor, then the status will change to " in progress ", and then to " completed ".


State machine example



Here's a small case study to illustrate how a state machine works: door operation.



The door can be locked or unlocked . It can also be opened or closed . If it is unlocked, then it can be opened.



We can model this as a state machine:



image



This state machine has:



  • 3 possible states: locked, unlocked, open
  • 4 possible state transitions: unlock, open, close, lock


From the diagram we can conclude that it is impossible to go from locked to open. Or in simple words: first you need to unlock the door, and only then open it. This diagram describes the behavior, but how do you implement it?



State machines as Elixir processes



Since OTP 19, Erlang provides a module : gen_statem that allows you to implement gen_server-like processes that behave like state machines (in which the current state affects their internal behavior). Let's see how it will look for our door example:



defmodule Door do
  @behaviour :gen_statem
 #  
 def start_link do
   :gen_statem.start_link(__MODULE__, :ok, [])
 end
 
 #  ,   , locked - 
 @impl :gen_statem
 def init(_), do: {:ok, :locked, nil}
 
 @impl :gen_statem
 def callback_mode, do: :handle_event_function
 
 #   :   
 # next_state -   -  
 @impl :gen_statem
 def handle_event({:call, from}, :unlock, :locked, data) do
   {:next_state, :unlocked, data, [{:reply, from, {:ok, :unlocked}}]}
 end
 
 #   
 def handle_event({:call, from}, :lock, :unlocked, data) do
   {:next_state, :locked, data, [{:reply, from, {:ok, :locked}}]}
 end
 
 #   
 def handle_event({:call, from}, :open, :unlocked, data) do
   {:next_state, :opened, data, [{:reply, from, {:ok, :opened}}]}
 end
 
 #   
 def handle_event({:call, from}, :close, :opened, data) do
   {:next_state, :unlocked, data, [{:reply, from, {:ok, :unlocked}}]}
 end
 
 #     
 def handle_event({:call, from}, _event, _content, data) do
   {:keep_state, data, [{:reply, from, {:error, "invalid transition"}}]}
 end
end


This process starts in the state : locked . By dispatching the appropriate events, we can match the current state with the requested transition and perform the necessary transformations. The extra data argument is saved for any other extra state, but we don't use it in this example.



We can call it with the state transition we want. If the current state allows this transition, then it will work. Otherwise, an error will be returned (due to the last event handler catching anything that does not match valid events).



{:ok, pid} = Door.start_link()
:gen_statem.call(pid, :unlock)
# {:ok, :unlocked}
:gen_statem.call(pid, :open)
# {:ok, :opened}
:gen_statem.call(pid, :close)
# {:ok, :closed}
:gen_statem.call(pid, :lock)
# {:ok, :locked}
:gen_statem.call(pid, :open)
# {:error, "invalid transition"}


If our state machine is more data-driven than process-driven, then we can take a different approach.



Finite State Machines as Ecto Models



There are several Elixir packages that solve this problem. I will be using Fsmx in this post , but other packages like Machinery provide similar functionality as well.



This package allows us to simulate exactly the same states and transitions, but in the existing Ecto model:



defmodule PersistedDoor do
 use Ecto.Schema
 
 schema "doors" do
   field(:state, :string, default: "locked")
   field(:terms_and_conditions, :boolean)
 end
 
 use Fsmx.Struct,
   transitions: %{
     "locked" => "unlocked",
     "unlocked" => ["locked", "opened"],
     "opened" => "unlocked"
   }
end


As we can see, Fsmx.Struct takes all possible branches as an argument. This allows him to check for unwanted transitions and prevent them from occurring. We can now change state using the traditional, non-Ecto approach:



door = %PersistedDoor{state: "locked"}
 
Fsmx.transition(door, "unlocked")
# {:ok, %PersistedDoor{state: "unlocked", color: nil}}


But we can also ask for the same in the form of Ecto changeset (the Elixir word for โ€œchangesetโ€):



door = PersistedDoor |> Repo.one()
Fsmx.transition_changeset(door, "unlocked")
|> Repo.update()


This changeset only updates the : state field . But we can expand it to include additional fields and validations. Let's say to open the door, we need to accept its terms:



defmodule PersistedDoor do
 # ...
 
 def transition(changeset, _from, "opened", params) do
   changeset
   |> cast(params, [:terms_and_conditions])
   |> validate_acceptance(:terms_and_conditions)
 end
end


Fsmx looks for the optional transition_changeset / 4 function in your schema and calls it with both the previous state and the next one. You can pattern them to add specific conditions for each transition.



Dealing with side effects



Moving a state machine from one state to another is a common task for state machines. But another big advantage of state machines is the ability to deal with side effects that can occur in every state.

Let's say we want to be notified every time someone opens our door. We may want to send an email when this happens. But we want these two operations to be one atomic operation.



Ecto works with atomicity through the Ecto.Multi package , which groups multiple operations within a database transaction. Ecto also has a feature called Ecto.Multi.run/3 which allows arbitrary code to run within the same transaction.



Fsmxin turn integrates with Ecto.Multi, giving you the ability to perform state transitions as part of Ecto.Multi, and also provides an additional callback that is executed in this case:



defmodule PersistedDoor do
 # ...
 
 def after_transaction_multi(changeset, _from, "unlocked", params) do
   Emails.door_unlocked()
   |> Mailer.deliver_later()
 end
end


Now you can make the transition as shown:



door = PersistedDoor |> Repo.one()
 
Ecto.Multi.new()
|> Fsmx.transition_multi(schema, "transition-id", "unlocked")
|> Repo.transaction()


This transaction will use the same transition_changeset / 4 as described above to calculate the required changes in the Ecto model. And will include a new callback as a call to Ecto.Multi.run . As a result, the email is sent (asynchronously, using Bamboo to avoid being triggered within the transaction itself).



If a changeset is invalidated for any reason, the email will never be sent, as a result of atomic execution of both operations.



Conclusion



The next time you are modeling some behavior with state, think about the approach using a state machine (state machine) pattern, this pattern can be a good helper for you. It is both simple and effective. This template will allow the modeled state transition diagram to be easily expressed in code, which will speed development.



I will make a reservation, perhaps the actor model contributes to the simplicity of the state machine implementation in Elixir \ Erlang, each actor has its own state and a queue of incoming messages, which sequentially change its state. In the book " Designing scalable systems in Erlang / OTP " about finite state machines is very well written, in the context of the actor model.



If you have your own examples of the implementation of finite state machines in your programming language, then please share a link, it will be interesting to study.



All Articles