Extracted patterns, conventions, and code smells directly from the Elixir and Phoenix source code with file path and line number citations. Covers: GenServer, error handling, data transforms, process design, testing, documentation, typespecs, macros, behaviours, module organization, Phoenix-specific patterns, framework deviations, and anti-patterns.
5.8 KiB
Anti-Patterns
Things the Elixir and Phoenix source deliberately avoids — and why you should too.
1. GenServer for Pure Functions
Source: lib/elixir/lib/gen_server.ex:533-575 ("When (not) to use a GenServer")
The GenServer docs explicitly say:
If you don't need a process, then you don't need a process.
What they avoid: Creating a GenServer to wrap stateless computation.
Why: A process adds message passing overhead, serialization (one request at a time), and supervision complexity — all unnecessary for pure functions.
Do this instead: A plain module with functions:
# Good: pure function, no process needed
defmodule MyApp.Calculator do
def add(a, b), do: a + b
end
# Bad: unnecessary process
defmodule MyApp.Calculator do
use GenServer
def add(a, b), do: GenServer.call(__MODULE__, {:add, a, b})
def handle_call({:add, a, b}, _from, state), do: {:reply, a + b, state}
end
2. Dynamic Atoms for Process Names
Source: lib/elixir/lib/registry.ex:28-60 (Registry as alternative to atoms)
The Registry module exists specifically because dynamic atom creation is dangerous:
atoms are never garbage collected
What they avoid: String.to_atom("worker_#{id}")
What they do instead:
{:via, Registry, {MyApp.Registry, "worker-#{id}"}}
3. Broad import Without :only
Source: lib/elixir/lib/enum.ex:250
import Kernel, except: [max: 2, min: 2]
Even within the standard library, imports are scoped. Enum explicitly excludes the specific Kernel functions it replaces.
What they avoid: import Kernel without qualification when they define conflicting names.
Exception: Phoenix Router (lib/phoenix/router.ex:274-276) imports broadly — but it's a DSL where usability trumps explicitness.
4. Exceptions for Control Flow
Source: lib/elixir/lib/task.ex:455-460 (async documentation on error handling)
an asynchronous task should be thought of as an extension of the caller process rather than a mechanism to isolate it from all errors.
The Task documentation advises returning {:ok, val} | :error for normal flow, NOT using try/rescue:
For example, to either return
{:ok, val} | :errorresults or, in more extreme cases, by usingtry/rescue
What they avoid: Using try/rescue around expected failure cases.
Why: Pattern matching on tagged tuples is more explicit, composable (works with with), and doesn't hide the error path.
5. Trapping Exits in Normal Code
Source: lib/elixir/lib/task.ex:469-477 (explicit warning)
Setting
:trap_exittotrue- trapping exits should be used only in special circumstances as it would make your process immune to not only exits from the task but from any other processes.Moreover, even when trapping exits, calling
awaitwill still exit if the task has terminated without sending its result back.
What they avoid: Process.flag(:trap_exit, true) in normal application code.
Why: Trapping exits breaks the supervision contract. A supervisor expects to be able to kill its children — if they trap exits, shutdown semantics change unpredictably.
6. Expensive Work in init/1
Source: lib/elixir/lib/gen_server.ex:127-145 (handle_continue pattern)
# What NOT to do — blocks the supervisor
def init(url) do
data = HTTP.get!(url) # BAD: blocks here
{:ok, data}
end
# What TO do — return immediately, do work later
def init(url) do
{:ok, :unset, {:continue, {:fetch, url}}}
end
def handle_continue({:fetch, url}, _state) do
{:noreply, HTTP.get!(url)}
end
What they avoid: Network calls, disk I/O, or any slow operation in init/1.
Why: init/1 blocks start_link, which blocks the supervisor. If your init takes 5 seconds, the entire supervision tree startup stalls.
7. Unlinking Task Processes
Source: lib/elixir/lib/task.ex:478-482
Unlinking the task process started with
async/await. If you unlink the processes and the task does not belong to any supervisor, you may leave dangling tasks in case the caller process dies.
What they avoid: Process.unlink/1 on task processes.
Why: The link is a safety mechanism. If the caller dies, the task should die too (since nobody will read the result). Unlinking creates orphan processes.
8. Blocking the Agent with Expensive Computation
Source: lib/elixir/lib/agent.ex:62-82 (client vs server computation)
# BAD: blocks the agent, other callers queue up
def get_something(agent) do
Agent.get(agent, fn state -> do_something_expensive(state) end)
end
# GOOD: copies state to caller, work happens in caller's process
def get_something(agent) do
Agent.get(agent, & &1) |> do_something_expensive()
end
What they avoid: Running expensive operations inside the Agent's process.
Why: The Agent is a single process. While it's computing, ALL other get/update/cast operations queue up. Move computation to the caller unless atomicity is required.
9. Raw spawn Instead of Supervised Processes
Source: lib/elixir/lib/task.ex:24-26 (why Task over spawn)
Compared to plain processes, started with
spawn/1, tasks include monitoring metadata and logging in case of errors.
Source: lib/elixir/lib/task.ex:100-115
We encourage developers to rely on supervised tasks as much as possible. Supervised tasks improve the visibility of how many tasks are running at a given moment and enable a variety of patterns that give you explicit control on how to handle the results, errors, and timeouts.
What they avoid: spawn/1 and spawn_link/1 in application code.
Why: Unsupervised processes are invisible to the system. They don't appear in observer, don't get logged on crash, and can't be gracefully shut down.