Engineering 5 min

Practical tips to improve your Elixir codebase

Written by Antonio Silva
Dec 4, 2020
Antonio Silva

Share

share to linkedInshare to Twittershare to Facebook
Link copied
to clipboard

Every person has a unique way of thinking and, therefore, a unique way of coding. Developing a solution that makes sense to every dev on your team can be difficult, especially the problems become more complex. If you're trying to improve an Elixir codebase, the complexity can quickly become overwhelming.

You have several options to improve the readability and maintainability of your code: for example, the SOLID principles. In this blog post, though, I will show you a few Elixir features to help make your code cleaner and more readable.

How to improve your Elixir codebase

We begin with a very powerful but underrated feature in Elixir that enables the developer to write multiple conditional statements, such as nested case statements in a cleaner way.

Elixir "with" statements

With can combine multiple clauses using pattern match. In case the right side of the operator, <- matches the term on the left-hand side the flow and continues to the next clause. If the terms don't match, it will skip to its else clause.

Whenever I'm writing a with statement, I like to start by defining the happy path and then handle all the possible errors (if needed).

plain
1with user when not is_nil(user) <- Accounts.get_user_by(%{name: "Pippin"}),
2 false <- is_going_on_an_adventure?(user) do
3 eat_second_breakfast(user)
4else
5 nil ->
6 {:error, "User not found"}
7
8 true ->
9 {:error, "User is not in the Shire"}
10end

Some notes on the code above:

  1. We can only reach Pippin's end goal, which is to eat second breakfast, if all the other conditions are met.

  2. We handled all unexpected errors gracefully in the else clause by setting custom error messages.

  3. The function eat_second_breakfast/1 returns a tuple {:ok, result} or {:error, reason}.

  4. This looks much cleaner than using multiple nested clauses, which makes the code easier to review.

Keep in mind that the else clause is not necessary if you don't need to handle the error when the pattern match fails.

plain
1with {:ok, user} <- fetch_user_by_name("Pedro Pascal"),
2 {:ok, _} <- rescue_baby_yoda(user) do
3 buy_new_suit(user)
4end
5
6....
7
8defp fetch_user_by_name(name) do
9 case Accounts.get_user_by(%{name: name}) do
10 nil -> {:error, "User with name #{name} not found"}
11 user -> {:ok, user}
12 end
13end

Here, all auxiliary functions return a tuple, and the with statement doesn't need to handle the error messages. This approach of returning the error in the auxiliary function is particularly helpful when we would have two functions returning the same value in the with statement. This is also less messy than adding the operation identifier on each with clause, like this:

plain
1with {:user, user} when not is_nil(user) <- {:user, Accounts.get_user_by(%{name: "Pippin"})},
2 {:starship, starship} when not is_nil(starship) <- {:starship, Starships.get_starship(1)} do
3 fly(user, starship)
4else
5 {:user, nil} ->
6 {:error, "User not found"}
7
8 {:starship, nil} ->
9 {:error, "Starship not found"}
10end

To finish this section, I'll illustrate how we can use with with Ecto.Repo.transaction/1.

plain
1Repo.transaction(fn ->
2 with {:ok, payment} <- create_payment(%{user_id: user_id, starship_id: starship.id}),
3 {:ok, _} <- update_starship(starship, %{owner_id: user_id}) do
4 payment
5 else
6 {:error, reason} ->
7 Repo.rollback(reason)
8 end
9end)

In the example above, if the update to the starship fails, the else clause will be executed and the transaction rolled back as expected.

For more details about with, read this documentation.

Improving readability through attribution with Elixir

Since Elixir is a functional language, it doesn't use references to objects like OOP languages. Therefore, every variable attribution is a memory copy, and we should develop taking that into consideration.

plain
1def find() do
2 ...
3 {:ok,
4 {
5 Accounts.get_user_by(%{name: "Aragorn"}),
6 Accounts.get_user_by(%{name: "Legolas"}),
7 Accounts.get_user_by(%{name: "Gimli"})
8 }
9 }
10end

The code above could be refactored to:

plain
1def find() do
2 with {:ok, aragorn} <- fetch_user_by_name("Aragorn"),
3 {:ok, legolas} <- fetch_user_by_name("Legolas"),
4 {:ok, gimli} <- fetch_user_by_name("Gimli") do
5 {:ok, {aragorn, legolas, gimli}}
6 end
7end
8...
9
10IconicTrio.find()

At Remote, we strive to keep our code as straightforward and simple as possible. Only after we achieve simplicity do we move on to making the code faster, if necessary. This way, we avoid wasting time over-optimizing code and can ship more code at a faster pace. These coding practices also help us maintain our focus on security and reliability.

Elixir naming conventions

Beyond following the conventions defined here, each team can also define its own naming convention to ensure everyone can get a basic understanding of what a function does just by reading the name.

For instance, when building user attributes:

plain
1# Instead of
2attrs = attrs(attrs, user, opts)
3# Do
4user_attrs = **build_user_attrs**(attrs, user, opts)

Elixir aliases

Disclaimer: this is mostly a pet peeve of mine, but it's still good coding practice worth knowing.

When I'm programming, I want to make it easier for others (and my future self) to find pieces of code like aliases, requires, module attributes, etc. My teams have always followed this style guide for module attributes/directives ordering, but it's just a guide. Teams should discuss and decide what it's best for them based on their own unique needs.

I'm used to having module attributes and directives on top of the file, each type grouped together and sorted alphabetically within the group. For example:

plain
1alias App.{Accounts, Skills, Starships}
2alias App.Accounts.ModuleA
3**a**lias App.Skills.{ModuleA, ModuleB}

Following a structured and ordered approach for module attributes and directives helps you avoid duplicated definitions and allows you to find what you need faster, because you always know where to look.

Elixir code formatting

Elixir ships with the mix task mix format, which allows you to format all your codebase with a single command. This is an awesome command to make sure your code is properly indented and avoid back and forth code reviews because you missed a space or an extra comma at the end of a list. The most popular code editors now support extensions that automatically run mix format on file save.

The mix task relies on the configs found in the file .formatter.exs, such as line_length and inputs (which describes the files to be used by the task). You can read more about it by visiting this resource.

Elixir code comments

Some people are against code comments. They believe code should be understandable enough to not need explanation or tests are the only documentation needed. In my humble opinion, code comments are essential to explain why we had to develop something in a particular way. This is useful for your future self and for team members when revisiting a part of your code/feature.

The more documented your code is, the more likely it is to be understandable by others and thus the easier it is to update. Using comments makes it less likely for your code to end up being considered legacy code just because others couldn't understand the repercussions of changing a file. Consider this example:

plain
1@doc """
2 Rescues Baby Yoda by navigating to the planet Arvala-7 and then clears the
3 encampment with the help of IG-11, neutralizing it in the process.
4
5 We have to neutralize IG-11 because IG-11 is a bounty-hunter droid that
6 intended to collect his prize money with Baby Yoda.
7"""
8def rescue_baby_yoda(mandalorian), do: ....

I hope you find this guide on improving your Elixir codebase to be helpful and informative. If you have any questions, feel free to reach out to me on Twitter @csilva_antonio. I'm always happy to help!

Subscribe to receive the latest
Remote blog posts and updates in your inbox.