Among the many useful patterns in computer science, there is the concept of a Finite-state machine (FSM).
It's a great abstraction in many different scenarios, where you want to model a certain process that goes through a predefined set of states, with different behaviors, depending on what state it is in.
In this post, you'll learn how to implement this pattern with Elixir's Ecto and when to use it.
Use Cases
When you model a long-running flow that requires multiple steps, and where each step has different
requirements, a state machine can be a good choice as an abstraction. A few examples:
- A user onboarding flow where the user first signs up, then adds some extra required info, confirms his email, then enables 2FA, and only then is allowed into the system
- A shopping cart which starts out empty, allows products to be added indefinitely and can proceed to payment/shipping if enough products are added
- A task in a project management pipeline. e.g: Tasks start out in the backlog, can be assigned to people, moved to "in progress", and later to "done"
An Example of a Finite-State Machine
For this post, we'll stick with a small example that illustrates the flow of a state machine: A door.
A door can be locked or unlocked. It can also be opened or closed. While unlocked, it can also be opened.
We could model this as a finite-state machine, such as the following:
This FSM has:
- 3 possible states: Locked, Unlocked, Opened
- 4 possible transitions or events: Unlock, Open, Close, Lock
It can be inferred from the diagram that it's impossible to transition from Locked
to Opened
. Or in plain words: you need to
unlock the door first.
The state machine diagram describes the behavior. But how can we go about implementing it?
State Machines as Elixir Processes
Since OTP 19, Erlang provides a :gen_statem
module that allows implementing gen_server-like processes that behave as state machines (where the current state influences their behavior). Let's see what that would look like for our door
example:
defmodule Door do
@behaviour :gen_statem
def start_link do
:gen_statem.start_link( __MODULE__,:ok,[] )
end
@impl :gen_statem
def init(_), do: {:ok, :locked, nil}
@impl :gen_statem
def callback_mode, do: :handle_event_function
@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 implements a process that starts out in the :locked
state. By sending appropriate events, we are able to match
the current state with the transition requested and perform the required transformations. An additional data
argument
is kept for any additional state that is needed, but we're not using that in this case.
To use this process, you can call it with the desired transition that you want to execute. If the current state allows that
transition, it will work. Otherwise, an error is returned (due to the last, catch-all match of the code snippet).
{: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-oriented than process-oriented, we may want to go with a different
approach...
State Machines as Ecto Models
There are a couple of Elixir packages that deal with this problem. For this post, I'll be using
fsmx
, but other packages such as machinery
also provide similar features.
This package allows us to model the same kind of states and transitions within an 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
We can see that Fsmx.Struct
receives all possible transitions as an argument. This allows it to check for unwanted
transitions and prevent them from happening. Now, we can transition using a 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 an Ecto changeset:
# get an existing door from the Database
door = PersistedDoor |> Repo.one()
Fsmx.transition_changeset(door, "unlocked")
|> Repo.update()
This changeset only updates the :state
field. But we can extend it to include additional fields, as well as
validations. Let's say that, in order to open a door, we need to accept its terms & conditions:
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 an optional transition_changeset/4
function in your schema and calls it with the previous state as
well as the next one. You can pattern match on those to add specific conditions for each transition.
Dealing With Side Effects
It's one thing to transition the state machine itself and move forward with the state.
But another big benefit of state machines is the ability to deal with particular side effects that come out of each
state.
Let's say, for example, you want to get notified every time someone unlocks your door. You might want to trigger an email
when it happens. But you want these two operations to be a single, atomic operation.
Ecto deals with atomicity via Ecto.Multi
which groups multiple operations inside a database transaction. It also has
an Ecto.Multi.run/3
function that allows you to run arbitrary code within that same transaction.
Fsmx
in turn, integrates with Ecto.Multi
by providing you with a way to run state transitions as part of an
Ecto.Multi
, while also providing you an additional callback that is executed in that case:
defmodule PersistedDoor do
# ...
def after_transaction_multi(changeset, _from, "unlocked", params) do
Emails.door_unlocked()
|> Mailer.deliver_later()
end
end
Now, you can execute a 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
from above to compute the necessary changes to the model
and will include the new callback as an Ecto.Multi.run
call. The result is that an email is sent (asynchronously,
using Bamboo
, so as not to run within the transaction itself). If the
changeset is invalidated for some reason, the email ends up never being sent, resulting in an atomic execution of both
operations.
Conclusion
Next time you're modeling some kind of stateful behavior, consider looking into a state-machine approach. Regardless of
which flavor you use, the ability to translate a concrete diagram to actual code and the ability to test each state,
transition, and side effect as a separate piece is a huge advantage.
P.S. If you'd like to read Elixir Alchemy posts as soon as they get off the press, subscribe to our Elixir Alchemy newsletter and never miss a single post!
Guest author Miguel is a professional over-engineer at Portuguese-based Subvisual. He works mostly with Elixir, DevOps, and Rust. He likes to build fancy keyboards and playing excessive amounts of online chess.
Top comments (0)