elixir

Building State Machines in Elixir with Ecto

Miguel Palhas

Miguel Palhas on

Building State Machines in Elixir with Ecto

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:

FSM

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:

Elixir
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).

Elixir
{: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:

Elixir
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:

Elixir
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:

Elixir
# 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:

Elixir
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:

Elixir
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:

Elixir
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!

Miguel Palhas

Miguel Palhas

Guest author Miguel is a professional over-engineer at Portuguese-based Subvisual. He works mostly with Ruby, Elixir, DevOps, and Rust. He likes building fancy keyboards and playing excessive amounts of online chess.

All articles by Miguel Palhas

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps