Post • #elixir
Every Elixir developer goes through the same phases.
First you discover pipes. You pipe everything. Every function call, every transformation, every error case — beautiful chains of code flowing left to right.
Then you discover with. And with becomes the solution to everything. Authorization? with. Error handling? with. Business logic? Obviously with.
Eventually you get jaded. You realize you’ve been contorting your logic to fit the construct. Cramming things into with blocks when a case or cond would be clearer, easier to read, and easier to debug.
It’s a bit like fashion. You want to wear the clothes. You don’t want the clothes wearing you.
The skill isn’t knowing how to use pipes and with. It’s knowing when not to.
When Pipes Go Wrong
The pipe operator (|>) is deceptively simple. It takes the result on the left and passes it as the first argument to the function on the right. That’s it.
1..10
|> Enum.map(&(&1 * 2))
|> Enum.filter(&Integer.is_even/1)
|> Enum.sum()
# => 110
Reads left to right, top to bottom. Each step builds on the last. Compare it to the nested version:
Enum.sum(Enum.filter(Enum.map(1..10, &(&1 * 2)), &Integer.is_even/1))
The nested version forces you to read inside-out. Pipes are genuinely better here.
When Pipes Work
Pipes shine for pure data transformations. Take input, apply functions, get output. No side effects, no error handling, just clean data flow.
But real applications aren’t clean. Most functions need to validate input, check permissions, handle errors, log things, maybe send notifications. And that’s where pipe obsession causes problems.
Error Handling Woes
Error handling is where pipes break down. When you need to check if each step succeeded or failed, you can’t just pass results through a chain anymore.
Here’s the pattern I used to write when I was forcing 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}
end
def validate_required_fields({:error, _} = error) do
error
end
def validate_email({:ok, attrs}) do
{:ok, attrs}
end
def validate_email({:error, _} = error) do
error
end
Every function has to handle both success and error cases. You end up with functions that aren’t about their core logic — they’re about passing errors through a pipe. And you’ve tightly coupled everything to that specific error format.
Good luck refactoring later.
The `with` Statement
with was designed for exactly this. It chains operations and short-circuits on the first error:
def register_and_send_email(attrs) do
with {:ok, user} <- register_user(attrs),
{:ok, _res} <- send_welcome_email(user) do
{:ok, user}
end
end
Each step only handles its success case. with handles the rest.
You can also match on specific errors in the else block:
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)}
end
end
If you’re working inside an Ecto transaction, check out
Repo.transact/2. It wrapsRepo.transaction/2but flips the semantics — returning{:ok, result}commits, and returning{:error, reason}rolls back.This means you can use plain
withchains for multi-step transactional operations. NoEcto.Multiceremony, no exception-based rollbacks.It was popularized by Saša Jurić and is now a standard Ecto built-in.
with is genuinely better than pipes for error handling. But here’s where the cycle repeats.
The New Hammer
Once you discover with, it becomes your new hammer. Complex authorization? with. Business logic? with. GraphQL resolvers? with.
The problem is that with optimizes for the happy path. That sounds great until you need to handle specific error cases. Then you end up with code that’s hard to debug and harder to modify.
Tagging (Don’t)
When multiple functions return the same error shape, pattern matching in else falls apart. The desperate fix is “tagging”:
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
{1, _error} ->
retry_send_email_in_background_job(user)
{_, {:error, reason}} ->
{:error, reason}
end
end
This tanks readability. Any case you don’t handle raises a WithClauseError. And you can’t propagate errors upwards without transformation, which is error-prone.
Resolver Hell
At my day job, we use GraphQL heavily. Authorization is everywhere. Initially we used with for all our resolver checks:
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
Hard to read, harder to debug. Another check means another with clause. Different error messages mean more complex else matching.
We started rewriting these with cond:
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
All the authorization logic in one place. Adding a check is trivial. Different errors get different messages without gymnastics.
cond gets overlooked because it feels basic. Sometimes basic is exactly what you need.
The Right Tool
The lesson 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 once.
- Pipes for pure data transformations
withfor sequential error handling where you care about short-circuitingcondfor complex conditional logiccasefor pattern matching on specific values- Let it crash for everything else
Flatten, Don’t Nest
I use a stupid-simple rule that’s served me well: pay attention to how deeply nested your code is getting. If you’re three levels deep in case branches or with clauses are stacking up, there’s almost certainly a construct that flattens the whole thing.
with chains get long? Maybe the logic wants to be a cond. case inside case? Lift the inner branch into its own function. Deeply nested if/else? That’s what pattern matching in function heads is for.
The goal isn’t fewer lines. It’s flatter structure — code you can read top to bottom without holding state in your head.
Just Let It Crash
Erlang and Elixir have a “let it crash” philosophy. The idea isn’t “don’t handle errors.” It’s that not all errors are the same, and trying to handle all of them the same way is a mistake.
There are two classes of error:
- Expected failures — validation errors, permission denials, not-found. These get
{:error, reason}tuples. They’re part of the normal flow. - Exceptional failures — wrong types, broken invariants, programmer mistakes. These crash. They’re bugs, and you want to know about them.
The distinction matters. Don’t convert crashes into vague error tuples. Stacktraces are features, not failures — especially in background jobs where there’s no human user to show them to.
In Practice
Most of our Oban jobs use this approach:
def perform(%Oban.Job{args: %{org_id: org_id, user_id: user_id, resource_id: resource_id}}) do
user = Users.get_user!(user_id)
%User{org_id: ^org_id} = user
resource = Resource.get_resource!(resource_id)
%Resource{org_id: ^org_id} = resource
if Users.authorized?(user, :update, resource) do
# Do the thing
else
# Cancel the job, log an error
end
end
No with chain. No tagged tuples. The ! functions crash on bad input — and that’s the point. The ! convention in Elixir literally means “I will crash instead of returning an error tuple.” It’s the “let it crash” API decision encoded in a function name.
If something goes wrong, Oban catches the crash, logs the stacktrace, and handles the retry. The supervisor tree gives you that for free.
There’s a common gotcha with
Repo.transaction/1: returning{:error, reason}from inside a transaction does NOT roll back. It’s a successful transaction that returns an error tuple. OnlyRepo.rollback/1or a crash will abort. If you want a transaction to roll back on failure, either crash or callRepo.rollback/1explicitly.
You’re Too Defensive
The cleanest Elixir code I’ve seen follows a simple rule: validate at the boundaries, trust internally. External input, file paths, user params — validate deliberately. That’s the boundary’s job. Internal business logic with wrong types or broken invariants? Let it crash.
You’re not protecting against your users at that point. You’re protecting against yourself, and that’s a losing game.
with chains and error-passing functions are tools for expected failures. When the failure is exceptional — when it shouldn’t happen and you’d want a stacktrace if it did — let the process die. The BEAM knows how to recover.
Conclusion
Every Elixir developer goes through the same cycle: pipe everything, then with everything, then finally realize different problems need different solutions.
Sometimes the right solution isn’t a construct at all. It’s letting go — trusting the BEAM to recover when things go wrong, and saving your with chains for the cases that actually need them.
The skill isn’t mastering any particular construct. It’s knowing when to stop.
▼
▶
Directory
1
- 01.
The Case against Pipes
When Elixir's beloved pipe operator gets misused.