Global HR — 10 min
Engineering — 5 min
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.
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.
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).
1with user when not is_nil(user) <- Accounts.get_user_by(%{name: "Pippin"}),2 false <- is_going_on_an_adventure?(user) do3 eat_second_breakfast(user)4else5 nil ->6 {:error, "User not found"}78 true ->9 {:error, "User is not in the Shire"}10end
Some notes on the code above:
We can only reach Pippin's end goal, which is to eat second breakfast, if all the other conditions are met.
We handled all unexpected errors gracefully in the else clause by setting custom error messages.
The function eat_second_breakfast/1 returns a tuple {:ok, result} or {:error, reason}.
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.
1with {:ok, user} <- fetch_user_by_name("Pedro Pascal"),2 {:ok, _} <- rescue_baby_yoda(user) do3 buy_new_suit(user)4end56....78defp fetch_user_by_name(name) do9 case Accounts.get_user_by(%{name: name}) do10 nil -> {:error, "User with name #{name} not found"}11 user -> {:ok, user}12 end13end
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:
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)} do3 fly(user, starship)4else5 {:user, nil} ->6 {:error, "User not found"}78 {: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.
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}) do4 payment5 else6 {:error, reason} ->7 Repo.rollback(reason)8 end9end)
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.
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.
1def find() do2 ...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:
1def find() do2 with {:ok, aragorn} <- fetch_user_by_name("Aragorn"),3 {:ok, legolas} <- fetch_user_by_name("Legolas"),4 {:ok, gimli} <- fetch_user_by_name("Gimli") do5 {:ok, {aragorn, legolas, gimli}}6 end7end8...910IconicTrio.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.
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:
1# Instead of2attrs = attrs(attrs, user, opts)3# Do4user_attrs = **build_user_attrs**(attrs, user, opts)
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:
1alias App.{Accounts, Skills, Starships}2alias App.Accounts.ModuleA3**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 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.
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:
1@doc """2 Rescues Baby Yoda by navigating to the planet Arvala-7 and then clears the3 encampment with the help of IG-11, neutralizing it in the process.45 We have to neutralize IG-11 because IG-11 is a bounty-hunter droid that6 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.