Control flow in Elixir

Elixir provides us with many different techniques for establishing control flow in our code. Coming from Ruby or Javascript, we have fancy new keywords like with, cond, and case at our disposal!

I like to think there is a natural progression in learning about these new techniques, over-utilizing them, and coming back down to earth after realizing there is more maintainable code that can be written (speaking from experience).

Let's talk through an example!

Make it rain

Consider having to write a method that will:

  • Retrieve a record based on a provided record ID
  • Update the record with new information
  • Insert a worker after the new information has been written iff a property exists on the updated record

(I'm pulling a random example from our Combo codebase!)

How can we build out our control flow?

Reaching for case

defmodule MyContext do
  alias MyContext.Record

  def perform_work(record_id, attrs) do
    case RecordContext.get_record(record_id) do
      nil ->
        {:error, "Record not found"}

      %Record{} = record ->
        case RecordContext.update_record(record, attrs) do
          {:ok, %Record{some_attribute: some_attribute} = record} ->
            case some_attribute do
              true -> SomeWorker.create(%{id: record.id})
              false -> :ok
            end

          {:error, reason} ->
            {:error, "Failed to update record"}
        end
    end
  end
end

For an Elixir beginner, this method will be difficult to grok. Further, what if the scope expanded in a way that another conditional needed to be introduced?

How about with?

defmodule MyContext do
  alias MyContext.Record

  def perform_work(record_id, attrs) do
    with %Record{} <- RecordContext.get_record(record_id),
          {:ok, %Record{some_attribute: some_attribute} = record} <-
            RecordContext.update_record(record, attrs) do
      case some_attribute do
        true -> SomeWorker.create(%{id: record.id})
        false -> :ok
      end
    else
      nil ->
        {:error, "Record not found"}

      {:error, reason} ->
        {:error, "Failed to update record"}
    end
  end
end

This looks a little bit better, but we're still left with some maintainability questions, particularly in our else clauses.

We're leaving it up to our colleagues or future-self to have the foresight of which chain in the conditional throws which error. What if we introduce another chain in our with clause that can also return nil or {:error, reason}?

Tidying it up

We could tidy this up to execute private functions for each conditional:

defmodule MyContext do
  def perform_work(record_id, attrs) do
    with {:ok, record} <- get_record(record_id),
         {:ok, %Record{some_attribute: some_attribute}} <- update_record(id, attrs) do
      case some_attribute do
        true -> SomeWorker.create(%{id: record.id})
        false -> :ok
      end
    else
      {:error, :get_record} ->
        {:error, "Resource not found"}

      {:error, :update_record} ->
        {:error, "Failed to update resource"}
    end
  end

  defp get_record(id) do
    case RecordContext.get_record(id) do
      nil -> {:error, :get_record}
      resource -> {:ok, resource}
    end
  end

  defp update_record(id, attrs) do
    case RecordContext.update_record(id, attrs) do
      {:ok, result} -> {:ok, result}
      {:error, reason} -> {:error, :update_record}
    end
  end
end

This is much clearer than our first attempt at using a with clause. Without much effort, a new reader could interpret our method. But there is one problem... this is a lot of code. Our context has doubled in size! While there is verbosity in this approach, it is tiring.

Rein it in

A common saying in the Elixir community is let it crash. This mainly refers to Elixir's actor model, but I like to think of it in terms of application code as well.

If we let our code throw errors when we couldn't execute our happy path, how would we write our above method?

defmodule MyContext do
  def perform_work(record_id, attrs) do
    record = RecordContext.get_record!(record_id)
    |> RecordContext.update_record!(attrs)

    if record.some_attribute do
      SomeWorker.create(%{id: record.id})
    end

    :ok
  rescue
    Ecto.NoResultsError ->
      {:error, "Resource not found"}

    Postgrex.Error ->
      {:error, "Failed to update record"}
  end
end

Instead of having to write private functions to defensively make our way through our method, we can instead just let our code throw an error and rescue from it.

We end up with code that is far more concise, easier to read, and maintain.