There's a predictable pattern with Elixir developers: first you discover pipes and use them EVERYWHERE. Every single function call, every data transformation, every error case gets piped together into beautiful, flowing chains of code.
Then you discover with
and suddenly that becomes the solution to EVERYTHING. Complex authorization checks? with
. Error handling? with
. Business logic? Obviously with
.
But when you're jaded, you start to realize that this overuse of these constructs leads to harder-to-read code.
You end up contorting your logic to fit the pipe or cramming everything into with
blocks when a simple case
or cond
would be clearer.
The real skill isn't knowing how to use pipes or with
— it's knowing when not to use them.
The Pipe Operator
The pipe operator (|>
) is deceptively simple: it takes the result of the expression on the left and passes it as the first argument to the function on the right.
That's it.
iex> 1..10
...> |> Enum.map(&(&1 * 2))
...> |> Enum.filter(&Integer.is_even/1)
...> |> Enum.sum()
110
This reads nicely left-to-right, top-to-bottom. Each step builds on the previous one. Compare it to the nested version:
iex> Enum.sum(Enum.filter(Enum.map(1..10, &(&1 * 2)), &Integer.is_even/1))
110
The nested version forces you to read inside-out, which is genuinely harder to parse.
But here's where things get tricky: pipes work so well for simple data transformations that you start using them everywhere, even when they're not the right tool.
When Pipes Work (And When They Don't)
Pipes shine when you're doing pure data transformations — taking some input, applying a series of functions, and getting a result.
No side effects, no error handling, just clean data flow.
But in real applications, you rarely have such clean scenarios. At my current job, most of our functions need to:
- Validate input
- Check permissions
- Handle database errors
- Log things
- Maybe send notifications
And this is where the pipe obsession starts causing problems.
The Error Handling Trap
Error handling is where pipes start to break down.
When you need to handle errors, you can't just pass the result through a series of functions anymore. You have to check if each step succeeded or failed.
The Boilerplate Problem
Here's the kind of code I see (and used to write) when trying to force pipes everywhere:
def register_user(attrs) do
attrs
|> validate_required_fields()
|> validate_email()
|> create_user()
|> send_welcome_email()
end
def validate_required_fields({:ok, attrs}), do: {:ok, attrs}
def validate_required_fields({:error, _} = error), do: error
def validate_email({:ok, attrs}), do: {:ok, attrs}
def validate_email({:error, _} = error), do: error
def create_user({:ok, attrs}), do: {:ok, %User{}}
def create_user({:error, _} = error), do: error
def send_welcome_email({:ok, user}), do: {:ok, user}
def send_welcome_email({:error, _} = error), do: error
I've seen this pattern everywhere. Every function has to handle both the success and error cases, which leads to a ton of boilerplate.
You end up with functions that aren't really about their core logic — they're about passing errors through a pipe.
The bigger problem? You're now tightly coupling all these functions to this specific error format. Good luck refactoring later.
The traditional approach is clearer:
case register_user(attrs) do
{:ok, user} ->
case send_welcome_email(user) do
{:ok, _} -> {:ok, user}
{:error, reason} -> {:error, reason}
end
{:error, reason} -> {:error, reason}
end
Sure, it's more verbose, but at least it's explicit about what's happening.
The 'with' Statement
This is where with
comes to the rescue. It's designed exactly for this kind of sequential error handling:
def register_and_send_email(attrs) do
with {:ok, user} <- register_user(attrs),
{:ok, _res} <- send_welcome_email(user) do
{:ok, user}
end
end
Much cleaner! Each step only needs to handle its success case, and with
automatically short-circuits on the first error.
Why This is Better
You can also handle specific error cases:
def register_and_send_email(attrs) do
with {:ok, user} <- register_user(attrs),
{:ok, _res} <- send_email(user) do
{:ok, user}
else
{:error, %Ecto.Changeset{} = changeset} ->
{:error, format_changeset_errors(changeset)}
{:error, %SendGridError{} = error} ->
{:error, format_sendgrid_error(error)}
_otherwise ->
# Don't do this in practice!!!
{:error, "Something went wrong"}
end
end
As you can see, with
is genuinely better than pipes for error handling.
But here's where the cycle repeats itself...
Overusing 'with'
Once you discover with
, it becomes your new hammer. Complex authorization? with
. Business logic? with
. GraphQL resolvers? Obviously with
.
The problem is that with
optimizes for the happy path, which sounds great until you need to handle specific error cases.
Then you end up with code that's hard to debug and even harder to modify.
The Tagging Antipattern
Pattern matching goes a long way, but this depends on the errors raised by the functions you're calling.
If any of them just return {:error, String.t()}
or return the same type of error, then you're going to have a bad time.
It can be tempting to "tag" the functions you're calling in the with
statement, which ends up looking like this:
def register_and_send_email(attrs) do
with {1, {:ok, user}} <- {1, register_user(attrs)},
{2, {:ok, _res}} <- {2, send_email(user)} do
{:ok, user}
else
{_, {:error, %Ecto.Changeset{} = changeset}} ->
{:error, format_changeset_errors(changeset)}
{_, {:error, "Something went wrong"}} ->
{:error, "Something went wrong"}
{1, _error} ->
retry_send_email_in_background_job(user)
end
end
The problems with this approach are:
- You've just tanked the readability of the function.
- Any case you don't handle in the
else
block will raise aMatchError
. - You now can't simply propagate errors upwards without some sort of transformation, which is error prone and can lead to unexpected behaviour.
Real-World 'with' Problems
At my current job, we use GraphQL heavily, and authorization is everywhere.
Initially, we used with
for all our resolver-level checks.
Authorization Hell
Here's the kind of code we ended up with:
def resolve(_parent, args, %{context: %{current_user: user}}) do
with {:ok, %User{} = user} <- Users.get_user(user.id),
true <- Users.authorized?(user, :update, args.resource),
{:ok, resource} <- Resource.get_resource(args.resource),
true <- user.org_id == resource.org_id do
# Do the thing
else
false ->
{:error, :unauthorized}
{:error, reason} ->
{:error, reason}
end
end
This code is hard to read and even harder to debug.
What happens when you need to add another authorization check? Another with
clause.
What if you need different error messages for different failure modes? More complex pattern matching in the else
block.
We also realized this pattern was vulnerable to timing attacks, but that's a different discussion entirely.
The 'cond' Statement
We started rewriting these using cond
instead:
def resolve(_parent, args, %{context: %{current_user: user}}) do
user = Users.get_user!(user.id)
resource = Resource.get_resource!(args.resource)
cond do
nil in [user, resource] ->
{:error, :not_found}
not Users.authorized?(user, :update, resource) ->
{:error, :unauthorized}
not user.org_id == resource.org_id ->
{:error, :unauthorized}
true ->
# Do the thing
end
end
Much clearer! All the authorization logic is visible in one place.
Adding new checks is trivial. Different error conditions can return different messages without complex pattern matching.
The key insight: this code prioritizes explicitness over cleverness. You can see exactly what's being checked and what happens when each check fails.
I'm also loading all the data upfront, which helps with the timing attack issue. Though
Users.authorized?/3
might still introduce timing differences depending on its implementation.
cond
gets overlooked because it feels "basic" compared to with
, but sometimes basic is exactly what you need.
The Right Tool for the Job
The real lesson here isn't about pipes or with
or cond
specifically.
It's about resisting the urge to use the same pattern everywhere just because it worked well in one place.
Pattern Matching Guide
- Pipes for pure data transformations
with
for sequential error handling where you care about short-circuitingcond
for complex conditional logiccase
for pattern matching on specific values- Let it crash for everything else
That last point is important. Sometimes the best error handling is no error handling.
Embrace the Crash
One of the things that sets Erlang and Elixir apart is the "let it crash" philosophy.
Instead of trying to handle every possible error case, you let your code crash and then recover gracefully.
A common saying is that you should "let it crash" when errors are exceptional and not part of the normal flow of your application.
Background Jobs
A lot of our background jobs use this approach:
@impl Oban.Job
def perform(%Oban.Job{args: %{org_id: org_id, user_id: user_id, resource_id: resource_id}}) do
%User{org_id: ^org_id} = Users.get_user!(user_id)
%Resource{org_id: ^org_id} = Resource.get_resource!(resource_id)
if Users.authorized?(user, :update, resource) do
# Do the thing
else
# Cancel the job, log an error, whatever!
end
rescue
_error ->
# Handle the error however we need to...
end
Simple, readable, and if something goes wrong, the job crashes and Oban handles the retry logic. No complex error handling needed.
Same approach works great in database transactions:
def my_transaction(org_id, user_id, resource_id) do
Repo.transaction(fn ->
%User{org_id: ^org_id} = Users.get_user!(user_id)
%Resource{org_id: ^org_id} = Resource.get_resource!(resource_id)
if Users.authorized?(user, :update, resource) do
# Do the thing
else
Repo.rollback(:unauthorized)
end
end)
end
The transaction handles rollback automatically if anything fails.
Conclusion
Every Elixir developer goes through the same phases: pipe everything, then with
everything, then finally realizing that different problems need different solutions.
The real skill isn't mastering any particular construct — it's recognizing when not to use your favorite pattern.
Stop trying to make your code fit the construct. Make the construct fit your code.