Better Elixir Pipeline

Programming problems can be complex. The best way to tackle any complexity is to break it down into smaller problems to be solved individually. You may do this for many iterations until you end up with many little classes or methods that follow Single Responsibility Principle. Then we compose them to solve our initial problem; and later we may compose them in a different way to solve another problem. It's really the essence of programming.

The Elixir |> (pipe) operator helps me think in this way when I program.

stream
|> CSV.decode(headers: true)
|> Enum.reverse
|> Enum.take(opts[:take])
|> Enum.map(&(import_update(profile, &1)))

It is very obviously what this snippet of code does. The pipe operator lets you write code that follows the logic in your head. It helps the readability so much. Write code this way is both fun and rewarding!

However, the pipe operator has its limit.

I've been working on a Phoenix app lately, and one of the features is to allow a customer to send feedback via SMS to a store's dashboard. Specifically, the following steps are required to be performed sequentially.

  1. assert_valid_phone_number_format(phone_number)
  2. find_or_create_customer
  3. add_customer_to_store(store)
  4. create_feedback(message)

Each of these methods may fail, so they return either {:ok, value} or {:error, reason} tuple. If any one of the steps fails, the remaining steps should not be performed and should immediately halt and return an error.

We can no longer chain them with pipe operator as the methods are not expecting the tuple we see above as its input. Of course, we can use nested case statements to make it work again, but it is not only ugly, but also cumbersome to maintain.

Another naive approach is to have each method matching the tuple as its input. For example, instead of the following method,

def find_or_create_customer(phone_number) do
  # ...
end

we would have:

def find_or_create_customer({:ok, phone_number}) do
  # ...
end

def find_or_create_customer({:error, reason}) do
  # ...
end

But we've just made this method not friendly reusable in other places. Ideally, I would still want to be able to call find_or_create_customer(phone_number).

Let's see what we can do.

Error monad

We can use the error monad from this library https://github.com/rmies/monad

I don't want to get into another monad blog post. There are plenty of good ones out there. You don't need to understand monads, and friends (functor, applicative functor, etc.), as well as the theory behind to get the benefits from using monad in our case (but if you want to, do some googling or grab a Haskell book and start reading :p).

To simplify it, we can think that monad is encapsulating the code that deals with {:ok, value} and {:error, reason} tuples, so that we can compose these methods to get the effect we want. That is: execution terminates immediately when any of the steps fails and returns the error result from the last executed step.

All methods return one of the tuples that the error monad expects, so we can use the special error monad pipe operator provided by the library.

Error.p do
  assert_valid_phone_number_format(phone_number)
  |> find_or_create_customer
  |> add_customer_to_entity(store)
  |> create_feedback(message)
end

case result do
  {:ok, value} -> # ...
  {:error, reason} -> # ...
end

The with keyword introduced in Elixir 1.2

Take a look at the awesome introduction of with by José Valim here: https://gist.github.com/josevalim/8130b19eb62706e1ab37

So our new code becomes:

with {:ok, phone_number} <- assert_valid_phone_number_format(phone_number),
     {:ok, customer} <- find_or_create_customer_with_phone_number(customer),
     {:ok, customer} <- add_customer_to_store(customer, store),
     {:ok, feedback} <- create_feedback(message)
     do: {:ok, feedback}

The downside of this solution is that we are not using the pipe operator anymore (if you prefer it like I do), but the upside is that we don't need a third-party library to achieve the same effect.

Loading comments...