Control flow in Elixir
Elixir provides many 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!
There’s a natural progression in learning about these 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 writing a function 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
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 function is difficult to follow. 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{} = 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 better, but we’re still left with some maintainability questions, particularly in our else clauses.
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 writing private functions to defensively navigate the function, we can let our code throw an error and rescue from it.
We end up with code that is concise, readable, and maintainable.