Writing Predictable Elixir Code with Reducers

Marcos Ramos

Marcos Ramos on

Writing Predictable Elixir Code with Reducers

This is the first part of a two-part series about maintainable code in Elixir. In this part, we will show how code predictability plays a crucial role in a project's short and long-term health. We will use Elixir's built-in features for this, like the pipe operator, tuples, and with blocks.

First, we'll explain what predictability is and why it is so important. Then we will go through some tools that Elixir already has and how you can use them to write better code.

Finally, we'll demonstrate how some simple rules can help developers write code that is easy to read, write, and maintain.

Let's get started!

On Complexity and Predictability

I still remember when I first started learning about functions in my math class. Things seemed simple and easy to understand:

L=12ρv2SCLL = \frac{1}{2} \rho v^2 S C_L
y(x)=2xy(x) = 2x

One function, one variable, and one simple operation.

But as the years passed and more complex things were added to the basics, the small and easy functions were gone. I was reading stuff like this:

f(x,y)=αlog(x2y3)θ˘f(x, y) = {\alpha\log({x^2 \over y^3}) \over \u\theta}

...What? Alpha? Theta with an inverted hat?

Eventually, though, after rereading and rewriting these kinds of functions over and over, they also become easy to work with.

In the excellent book The Programmer's Brain: What Every Programmer Needs to Know About, Felienne Hermans does an amazing job of explaining the different mechanisms that our brain uses to read, write, and understand code, as well as techniques to perform these tasks as efficiently as possible.

In short: the amount of new information that our brains can handle is actually pretty small, and there is not really much we can do about it.

The good news is that our memory is very different from a computer — and we can take advantage of that. With enough repetition, we eventually start to store patterns in our long-term memory and then use that knowledge to understand new information faster.

That's why a skill becomes easier with more practice, be it math, playing the guitar, or programming. The more we make things look and behave the same, the easier and faster it is to absorb them.

Why Should I Make My Elixir Code Predictable?

When it comes to understanding what we read and how well other people will understand the code that we write, being predictable is essential.

Three key elements make code predictable:

  1. Structure: different blocks of code look and behave the same. That is the main reason it is easy to understand a for loop, even in languages that you've never used before.
  2. Size: smaller blocks (like functions, modules, classes, etc.) are easier to remember.
  3. Simplicity: less moving parts, like function parameters, help our brain keep track of what is happening.

One good example of predictability in the Elixir world is the Plug library because:

  • It uses behaviours to keep all implementations with the same structure (expected input and output).
  • Each plug is expected to be as small as possible since they add latency to the HTTP response.
  • They have a pretty simple shape: two functions, each with two parameters.

So, what if we make our entire code predictable?

Elixir's Tools for Writing Predictable Code

Elixir itself has a couple of nice tools that, when used correctly, help us create predictable code: its pipe operator and with statement.

Keep in mind that neither of these will enforce a pattern or design principle — they are just tools.

Elixir's Pipe Operator

The pipe |> operator is an amazing tool that helps us express a chain of function calls as a simple sequence of actions.

Even if you've never written any Elixir code, you probably understand what this piece of code is trying to do:

form_params |> validate_form() |> insert_user() |> report() |> write_response()

It's simple to read and easy to understand what is happening.

However, because it's so simple, we're also very limited in what we can do with it. Since all functions are chained, they depend on the previous result. If any of the functions break, there's not much we can do about it unless we add error handling to all of them.

For a proper pipeline where we can handle errors and don't want to make the functions dependent on one another, it's better to use the with statement.

Elixir's with Statement

Pipe operators are simple, but we often need to check the return to ensure we have a valid state.

Let's take the previous example and rewrite it with a with block:

with %User{} = user_data <- validate_form(form_params), {:ok, user} <- insert_user(user_data), :ok <- report(user) do write_response(user) else error -> report(error) handle_error(error) end

Now we have proper error handling and a chance to do something if our user data is invalid or it's not possible to insert the user. However, even in this simple example, it's a bit harder to read because of the added complexity. Note that since functions can return any value, we have to handle the return of each step explicitly.

Let's use a more complex example — a generic checkout code where we need information about the user, payment, and address — and try to create an order:

with %Payment{} = payment <- fetch_payment_information(params), {:ok, user} <- Session.get(conn, :user), address when !is_nil(address) <- fetch_address(user, params), {:ok, order} <- create_order(user, payment, address) do conn |> put_flash(:info, "Order completed!") |> render("checkout.html") else {:error, :payment_failed} -> handle_error(conn, "Payment Error") %Store.OrderError{message: message} -> handle_error(conn, "Order Error") error -> handle_error(conn, "Unprocessable order") end

Not only does each function have a different return type, but errors may also have different shapes.

Also, because the existing functions don't follow a pattern, a developer adding a new step to this feature will not know what the new function should return. A struct? ok/error tuple? A non-nil/nil value? What about errors?

The lack of predictability here is bad for whoever is reading this code, as well as people who will modify it in the future.

Design Better Pipelines in Elixir

As previously stated, to achieve predictability, we need three things in our code: structure, size, and simplicity. Knowing that, we can create a few rules to help us write predictable code:

  • Each function on a pipeline is expected to transform a given state into an updated state, and it should only do one transformation. This will help us keep functions small and focused.
  • Always receive two parameters. The first parameter is the state we want to transform, and the second contains any optional or extra data we might need to perform such a transformation. The second parameter is optional.
  • Always return an ok/error tuple. If everything goes well, an updated value of a state is returned.

For now, we won't require a structure or type for the error description.

Let's revisit our checkout code, now applying these rules:

options = %{conn: conn} with {:ok, payment} <- fetch_payment_information(params, options), {:ok, user} <- fetch_user(conn), {:ok, address} <- fetch_address(%{user: user, params: params}, options), {:ok, order} <- create_order(%{user: user, address: address, payment: payment}, options) do conn |> put_flash(:info, "Order completed!") |> redirect(to: Routes.order_path(conn, order)) else {:error, error_description} -> conn |> put_flash(:error, parse_error(error_description)) |> render("checkout.html") end

It definitely looks better, and if someone in the future has to add a new step, they will know how this new function should look. Because the errors always have the same shape, we just need to find the appropriate message for a given error description, and everything else will look the same.

However, although we've solved the design of one particular feature, none of these changes or design principles are enforced. A team working on a different part of your product might never have contact with this code and may follow another design or pattern — or none at all.

If we can find a way to enforce these changes, then the code will always look the same.

An Emerging Pattern

Now we can see a pattern emerging when we write our functions: chain-of-state transformations — a pipeline. It's almost like an Enum.reduce/3 function.

However, instead of applying the same function to a collection of values, we apply a collection of functions to transform a state.

We'll explore this pattern in greater detail in the next part of this series.

Coming Up Next Time: Enforcing Predictable Elixir Code

In this article, we showed how to write code in a pattern that our brains can understand faster and more accurately. And by doing so, we also can write — and keep writing! — code that is maintainable in the long term.

Note that this pattern does not cover module organization, project structure, or API design. For that, I highly recommend the series of articles Towards Maintainable Elixir by Saša Jurić.

In the next and final part of this two-part series, we'll learn how to create a basic framework to enforce design principles in our Elixir code.

Until then, happy coding!

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!

Marcos Ramos

Marcos Ramos

Our guest author Marcos is a software engineer from Brazil who really likes Elixir and Erlang. When not working, you'll probably find him at the nearest climbing gym!

All articles by Marcos Ramos

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps