Building State Machines in Elixir with Ecto
Tags: elixir, webdev
Published: 2020-07-30 15:46:16

Among the many useful patterns in computer science, there is the concept of a [Finite-state machine](https://en.wikipedia.org/wiki/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](https://blog.appsignal.com/images/blog/2020-07/fsm.png)

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`](https://github.com/subvisual/fsmx), but other packages such as [`machinery`](https://github.com/joaomdmoura/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`](https://github.com/thoughtbot/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](https://blog.appsignal.com/category/elixir-alchemy.html#elixir-alchemy)!**

*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.*