https://keathley.io/blog/good-and-bad-elixir.html
Keathley
Talks
Github
Twitter
RSS
Good and Bad Elixir
June 2, 2021
I've seen a lot of elixir at this point, both good and bad. Through
all of that code, I've seen similar patterns that tend to lead to
worse code. So I thought I would document some of them as well as
better alternatives to these patterns.
Map.get/2 and Keyword.get/2 vs. Access
Map.get/2 and Keyword.get/2 lock you into using a specific data
structure. This means that if you want to change the type of
structure, you now need to update all of the call sites. Instead of
these functions, you should prefer using Access:
# Don't do
opts = %{foo: :bar}
Map.get(opts, :foo)
# Do
opts[:foo]
Don't pipe results into the following function
Side-effecting functions tend to return "results" like {:ok, term()}
| {:error, term()}. If your dealing with side-effecting functions,
don't pipe the results into the next function. It's always better to
deal with the results directly using either case or with.
# Don't do...
def main do
data
|> call_service
|> parse_response
|> handle_result
end
defp call_service(data) do
# ...
end
defp parse_response({:ok, result}, do: Jason.decode(result)
defp parse_response(error, do: error)
defp handle_result({:ok, decoded}), do: decoded
defp handle_result({:error, error}), do: raise error
# Do...
def main do
with {:ok, response} <- call_service(data),
{:ok, decoded} <- parse_response(response) do
decoded
end
end
Using pipes forces our functions to handle the previous function's
results, spreading error handling throughout our various function
calls. The core problem here is subtle, but it's essential to
internalize. Each of these functions has to know too much information
about how it is being called. Good software design is mainly about
building reusable bits that can be arbitrarily composed. In the
pipeline example, the functions know how they're used, how they're
called, and what order they're composed.
Another problem with the pipeline approach is that it tends to assume
that errors can be handled generically. This assumption is often
incorrect.
When dealing with side-effects, the only function with enough
information to decide what to do with an error is the calling
function. In many systems, the error cases are just as important - if
not more important - than the "happy path" case. The error cases are
where you're going to have to perform fallbacks or graceful
degradation.
If your in a situation where errors are a vital part of your
functions control flow, then it's best to keep all of the error
handling in the calling function using case statements.
# Do...
def main(id) do
case :fuse.check(:service) do
:ok ->
case call_service(id) do
{:ok, result} ->
:ok = Cache.put(id, result)
{:ok, result}
{:error, error} ->
:fuse.melt(:service)
{:error, error}
end
:blown ->
cached = Cache.get(id)
if cached do
{:ok, result}
else
{:error, error}
end
end
end
This increases the size of the calling function, but the benefit is
that you can read this entire function and understand each control
flow.
Don't pipe into case statements
I used to be on the fence about piping into case statements, but I've
seen this pattern abused too many times. Seriously y'all, put down
the pipe operator and show a little restraint. If you find yourself
piping into case, it's almost always better to assign intermediate
steps to a variable instead.
# Don't do...
build_post(attrs)
|> store_post()
|> case do
{:ok, post} ->
# ...
{:error, _} ->
# ...
end
# Do...
changeset = build_post(attrs)
case store_post(changeset) do
{:ok, post} ->
# ...
{:error, _} ->
# ...
end
Don't hide higher-order functions
Higher-order functions are great, so try not to hide them away. If
you're working with collections, you should prefer to write functions
that operate on a single entity rather than the collection itself.
Then you can use higher-order functions directly in your pipeline.
# Don't do...
def main do
collection
|> parse_items
|> add_items
end
def parse_items(list) do
Enum.map(list, &String.to_integer/1)
end
def add_items(list) do
Enum.reduce(list, 0, & &1 + &2)
end
# Do...
def main do
collection
|> Enum.map(&parse_item/1)
|> Enum.reduce(0, &add_item/2)
end
defp parse_item(item), do: String.to_integer(item)
defp add_item(num, acc), do: num + acc
With this change, our parse_item and add_item functions become
reusable in the broader set of contexts. These functions can now be
used on a single item or can be lifted into the context of Stream,
Enum, Task, or any number of other uses. Hiding this logic away from
the caller is a worse design because it couples the function to its
call site and makes it less reusable. Ideally, our APIs are reusable
in a wide range of contexts.
Another benefit of this change is that better solutions may reveal
themselves. In this case, we may decide that we don't need the named
functions and can use anonymous functions instead. We realize that we
don't need the reduce and can use sum.
def main do
collection
|> Enum.map(&String.to_integer/1)
|> Enum.sum()
end
This final step may not always be the right choice. It depends on how
much work your functions are doing. But, as a general rule, you
should strive to eliminate functions that only have a single call
site. Even though there are no dedicated names for these functions,
the final version is no less "readable" than when we started. An
Elixir programmer can still look at this series of steps and
understand that the goal is to convert a collection of strings into
integers and then sum those integers. And, they can realize this
without needing to read any other functions along the way.
Avoid else in with blocks
else can be helpful if you need to perform an operation that is
generic across all error values being returned. You should not use
else to handle all potential errors (or even a large number of
errors).
# Don't do...
with {:ok, response} <- call_service(data),
{:ok, decoded} <- Jason.decode(response),
{:ok, result} <- store_in_db(decoded) do
:ok
else
{:error, %Jason.Error{}=error} ->
# Do something with json error
{:error, %ServiceError{}=error} ->
# Do something with service error
{:error, %DBError{}} ->
# Do something with db error
end
For the same reason, under no circumstances should you annotate your
function calls with a name just so you can differentiate between
them.
with {:service, {:ok, resp}} <- {:service, call_service(data)},
{:decode, {:ok, decoded}} <- {:decode, Jason.decode(resp)},
{:db, {:ok, result}} <- {:db, store_in_db(decoded)} do
:ok
else
{:service, {:error, error}} ->
# Do something with service error
{:decode, {:error, error}} ->
# Do something with json error
{:db, {:error, error}} ->
# Do something with db error
end
If you find yourself doing this, it means that the error conditions
matter. Which means that you don't want with at all. You want case.
with is best used when you can fall through at any point without
worrying about the specific error or contrary pattern. A good way to
create a more unified way to deal with errors is to build a common
error type like so:
defmodule MyApp.Error do
defexception [:code, :msg, :meta]
def new(code, msg, meta) when is_binary(msg) do
%__MODULE__{code: code, msg: msg, meta: Map.new(meta)}
end
def not_found(msg, meta \\ %{}) do
new(:not_found, msg, meta)
end
def internal(msg, meta \\ %{}) do
new(:internal, msg, meta)
end
end
def main do
with {:ok, response} <- call_service(data),
{:ok, decoded} <- decode(response),
{:ok, result} <- store_in_db(decoded) do
:ok
end
end
# We wrap the result of Jason.decode in our own custom error type
defp decode(resp) do
with {:error, e} <- Jason.decode(resp) do
{:error, Error.internal("could not decode: #{inspect resp}")}
end
end
This error struct provides a unified way to surface all of the errors
in your application. The struct can render errors in a phoenix
controller or be returned from an RPC handler. Because the struct
your using is an exception, the caller can also choose to raise the
error, and you'll get well-formatted error messages.
case main() do
{:ok, _} -> :ok
{:error, e} -> raise e
end
State what you want, not what you don't
You should be intentional about your function's requirements. Don't
bother checking that a value is not nil if what you expect it to be
is a string:
# Don't do...
def call_service(%{req: req}) when not is_nil(req) do
# ...
end
# Do...
def call_service(%{req: req}) when is_binary(req) do
# ...
end
The same is true for case statements and if statements. Be more
explicit about what it is you expect. You'd prefer to raise or crash
if you receive arguments that would violate your expectations.
Only return error tuples when the caller can do something about it.
You should only force your user to deal with errors that they can do
something about. If your API can error, and there's nothing the
caller can do about it, then raise an exception or throw. Don't
bother making your callers deal with result tuples when there's
nothing they can do.
# Don't do...
def get(table \\ __MODULE__, id) do
# If the table doesn't exist ets will throw an error. Catch that and return
# an error tuple
try do
:ets.lookup(table, id)
catch
_, _ ->
{:error, "Table is not available"}
end
end
# Do...
def get(table \\ __MODULE__, id) do
# If the table doesn't exist, there's nothing the caller can do
# about it, so just throw.
:ets.lookup(table, id)
end
Raise exceptions if you receive invalid data.
You should not be afraid of just raising exceptions if a return value
or piece of data has violated your expectations. If you're calling a
downstream service that should always return JSON, use Jason.decode!
and avoid writing additional error handling logic.
# Don't do...
def main do
{:ok, resp} = call_service(id)
case Jason.decode(resp) do
{:ok, decoded} ->
decoded
{:error, e} ->
# Now what?...
end
end
# Do...
def main do
{:ok, resp} = call_service(id)
decoded = Jason.decode!(resp)
end
This allows us to crash the process (which is good) and removes the
useless error handling logic from the function.
Use for when checking collections in tests
This is a quick one, but it makes your test failures much more
helpful.
# Don't do...
assert Enum.all?(posts, fn post -> %Post{} == post end)
# Do...
for post <- posts, do: assert %Post{} == post
@ChrisKeathley