loading...
Cover image for Building State Machines in Elixir with Ecto
AppSignal

Building State Machines in Elixir with Ecto

naps62 profile image Miguel Palhas Originally published at blog.appsignal.com ・5 min read

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:

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.

Posted on by:

naps62 profile

Miguel Palhas

@naps62

Professional over-engineer @subvisual. Pathologically sarcastic. Will one day go to outer space.

AppSignal

AppSignal is made for teams that want to build high quality applications. It offers amazing insights into errors and performance issues, plus host monitoring and an easy to use custom metrics platform

Discussion

markdown guide