This is going to be a quick one.
Recently I had the opportunity to take part in the refactoring of some Elixir code.
It looked something like this. Picture more interesting (and complex) logic in place of the classic, old and honestly a bit boring beverages example:
defmodule Beverages do
def prepare(beverage_type) do
with :ok <- roll_up_sleeves(),
{:ok, quantity} <- random_quantity(),
{:ok, preparation} <- prepare_it(beverage_type, quantity),
{:ok, _beverage} <- serve_it(preparation) do
{:ok, "happy days!"}
else
error -> {:error, error}
end
end
defp random_quantity, do: {:ok, Enum.random(30..40)}
defp roll_up_sleeves do
"rolling up sleeves to start working" |> IO.puts()
end
defp prepare_it(:coffee, quantity) do
"getting #{quantity}gr of beautiful Peruvian coffee beans" |> IO.puts()
{:ok, "prepped with the finest coffee ☕️"}
end
defp prepare_it(:tea, quantity) do
"getting #{quantity}gr of mixed Indian Assam leaves, brisk Ceylon and bright Kenyan teas"
|> IO.puts()
{:ok, "prepped with the finest teas 🍵"}
end
defp serve_it(preparation) do
served = "ready to drink beverage " <> preparation
served |> IO.puts()
{:ok, served}
end
end
We wanted to move away from pattern-matching on atoms (first function argument) as a way of forking to different implementations in the same module. Single responsibility principle anyone?
Our goal was to split the logic in various different modules, given we would now need to add another beverage type: the lemonade. The logic inside each was also more complex than in this toy example.
This split would make it easier to reason about each one of the beverage types and the way they differ from each other, while hopefully still keeping the comon logic in a central place that privileges dryness. Furthermore, having each of them as separate modules made it simpler to test them (each one in isolation) and also to extend them (both now given the new business requirements, and in the future in case yet another beverage comes along).
Besides all this, we were also not following a specific way of properly documenting the fact that each of the specific beverage implementations needed to respect a well-defined interface. Smells like teen spirit Elixir behaviours.
After doing some quick research over the Elixir hexdocs and official pages, we eventually hit gold when we came across the recommended way of doing “dynamic dispatching” in Elixir.
So we changed it to this:
defmodule NewBeverages do
@type quantity :: integer()
@type preparation :: binary()
@callback prepare_it(quantity) :: {:ok, preparation}
def prepare(beverage_type) do
beverage_impl = beverage_for(beverage_type)
with :ok <- roll_up_sleeves(),
{:ok, quantity} <- random_quantity(),
{:ok, preparation} <- beverage_impl.prepare_it(quantity),
{:ok, _beverage} <- serve_it(preparation) do
{:ok, "happy days!"}
else
error -> {:error, error}
end
end
defp beverage_for(:coffee), do: NewBeverages.Coffee
defp beverage_for(:tea), do: NewBeverages.Tea
defp beverage_for(:lemonade), do: NewBeverages.Lemonade
defp random_quantity, do: {:ok, Enum.random(30..40)}
defp roll_up_sleeves do
"rolling up sleeves to start working" |> IO.puts()
end
defp serve_it(preparation) do
served = "ready to drink beverage " <> preparation
served |> IO.puts()
{:ok, served}
end
end
defmodule NewBeverages.Coffee do
@behaviour NewBeverages
def prepare_it(quantity) do
"getting #{quantity}gr of beautiful Peruvian coffee beans" |> IO.puts()
{:ok, "prepped with the finest coffee ☕️"}
end
end
defmodule NewBeverages.Tea do
@behaviour NewBeverages
def prepare_it(quantity) do
"getting #{quantity}gr of mixed Indian Assam leaves, brisk Ceylon and bright Kenyan teas"
|> IO.puts()
{:ok, "prepped with the finest teas 🍵"}
end
end
defmodule NewBeverages.Lemonade do
@behaviour NewBeverages
def prepare_it(quantity) do
"getting #{quantity}ml of hearty lemon juice" |> IO.puts()
{:ok, "prepped with the finest lemon 🍋"}
end
end
We making lemonade, boy.
> NewBeverages.prepare(:lemonade)
rolling up sleeves to start working
getting 32ml of hearty lemon juice
ready to drink beverage prepped with the finest lemon 🍋
{:ok, "happy days!"}
You can easily extend lemonade to do crazy things other beverages don’t need to do as part of preparing your beverage (prepare_it/1
) without polluting the “orchestrator module”. Buy one of those nice looking metal squeezers? Looking at you Rodrigo.
You can take this further, and do something like the Template method pattern:
def prepare_beverage(beverage_type) do
beverage_impl = beverage_for(beverage_type)
with :ok <- roll_up_sleeves(),
{:ok, quantity} <- random_quantity(),
{:ok, preparation} <- beverage_impl.prepare_it(quantity),
{:ok, beverage_with_side} <- beverage_impl.add_side(preparation),
{:ok, _beverage} <- serve_it(beverage_with_side) do
{:ok, "happy days!"}
else
error -> {:error, error}
end
end
In this case, the template defines two very general actions: randomising the quantity used to prepare your beverage (random_quantity/0
) and serving the drink (serve_it/1
). Actually preparing it (prepare_it/1
): beans for coffee, leaves for tea, etc., and getting a special side for each of these (add_side/1
): chocolate brownie for coffee, butter cookie for tea, etc. is different for each, so each specific implementation should do its own thing.
Functional purists may be crying right now, so I’ll stop. But you get the gist of it. This new design seems to, for now, have put us in a much better place. Leveraging language features such as Protocols and Behaviours has truly helped us maintain and improve our code for the long run.
Code available here.
Note: Thanks to Dino Costa for proposing the usage of a “factory” function (e.g., beverage_for/1
). Also, thanks to him, Daniel Caixinha, and Pedro Madeira for validating this design.
Merry Xmas! 🎄🎅