docs: idiomatic Elixir and Phoenix patterns with source citations

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.
This commit is contained in:
Aaron Weiker
2026-04-29 22:50:12 -07:00
commit 4ea9a884aa
16 changed files with 4857 additions and 0 deletions
+181
View File
@@ -0,0 +1,181 @@
# 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:
```elixir
# 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:**
```elixir
{:via, Registry, {MyApp.Registry, "worker-#{id}"}}
```
---
## 3. Broad `import` Without `:only`
**Source:** `lib/elixir/lib/enum.ex:250`
```elixir
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} | :error` results or,
> in more extreme cases, by using `try/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_exit` to `true` - 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 `await` will 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)
```elixir
# 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)
```elixir
# 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.
+265
View File
@@ -0,0 +1,265 @@
# Common Mistakes
What "bad Elixir" looks like, based on what the source code explicitly warns against or demonstrates the correct way to avoid.
## 1. Using `++` in a Loop (O(n²) List Building)
**Source:** `lib/elixir/lib/enum.ex:306-320` (internal macros all use prepend)
```elixir
# What the source does: prepend then reverse
defmacrop next(_, entry, acc) do
quote(do: [unquote(entry) | unquote(acc)])
end
```
**The mistake:**
```elixir
# O(n²) — copies the entire left list for every element
Enum.reduce(items, [], fn item, acc -> acc ++ [transform(item)] end)
```
**The fix:**
```elixir
# O(n) — prepend is O(1), reverse once at the end
items |> Enum.map(&transform/1)
# or
Enum.reduce(items, [], fn item, acc -> [transform(item) | acc] end) |> Enum.reverse()
```
---
## 2. Forgetting `@impl true`
**Source:** `lib/elixir/lib/gen_server.ex:44-56` (every callback uses @impl)
**The mistake:**
```elixir
defmodule MyServer do
use GenServer
# Typo! This will never be called — no warning without @impl
def handle_cll(msg, _from, state) do
{:reply, msg, state}
end
end
```
**The fix:**
```elixir
@impl true
def handle_call(msg, _from, state) do
{:reply, msg, state}
end
```
With `@impl true`, the compiler catches the typo at compile time.
---
## 3. Not Handling All `with` Failure Cases
**Source:** `lib/elixir/lib/kernel/special_forms.ex:1680-1715` (with Beware! section)
**The mistake:**
```elixir
with {:ok, width} <- Map.fetch(opts, "width"),
{:ok, height} <- Map.fetch(opts, "height") do
{:ok, width * height}
else
# Only handles one case — what if Map.fetch returns something else?
:error -> {:error, :missing_field}
end
```
If an `else` block is used and no clause matches, a `WithClauseError` is raised.
**The fix:** Either handle all possible non-match values in `else`, or better yet, normalize return values in helper functions so you don't need `else` at all.
---
## 4. async Without await
**Source:** `lib/elixir/lib/task.ex:38-40`
> If you are using async tasks, you **must await** a reply as they are *always* sent.
**The mistake:**
```elixir
# Leaked reference — message sits in mailbox forever
Task.async(fn -> send_email(user) end)
# Never awaited!
```
**The fix:**
```elixir
# Fire-and-forget: use start_child
Task.Supervisor.start_child(MyApp.TaskSupervisor, fn -> send_email(user) end)
# OR if you need the result:
task = Task.async(fn -> send_email(user) end)
Task.await(task)
```
---
## 5. Anonymous Functions in Distributed Agents
**Source:** `lib/elixir/lib/agent.ex:141-160` ("A word on distributed agents")
> In a distributed setup with multiple nodes, the API that accepts anonymous
> functions only works if the caller (client) and the agent have the same
> version of the caller module.
**The mistake:**
```elixir
# Fails if nodes have different code versions
Agent.get({MyAgent, :remote@node}, fn state -> state.count end)
```
**The fix:**
```elixir
# Use MFA (module, function, args) for distributed calls
Agent.get({MyAgent, :remote@node}, MyModule, :get_count, [])
```
---
## 6. Starting Processes Outside Supervision Trees
**Source:** `lib/elixir/lib/task.ex:100-115`
> We encourage developers to rely on supervised tasks as much as possible.
**The mistake:**
```elixir
# No supervision, no monitoring, no logging
spawn(fn -> do_important_work() end)
# Or:
Task.async(fn -> do_important_work() end) |> Task.await()
# Linked to caller but not supervised
```
**The fix:**
```elixir
# Add to your supervision tree:
{Task.Supervisor, name: MyApp.TaskSupervisor}
# Then use it:
Task.Supervisor.start_child(MyApp.TaskSupervisor, fn -> do_important_work() end)
```
---
## 7. Putting State Logic in Controllers
**Source:** `lib/phoenix/controller.ex:28-45`
Controllers are shown as thin dispatch layers:
```elixir
def show(conn, %{"id" => id}) do
user = Repo.get(User, id)
render(conn, :show, user: user)
end
```
**The mistake:**
```elixir
def create(conn, params) do
# Business logic in the controller
changeset = User.changeset(%User{}, params)
if changeset.valid? do
user = Repo.insert!(changeset)
send_welcome_email(user)
update_analytics(user)
notify_admin(user)
render(conn, :show, user: user)
else
render(conn, :error, errors: changeset.errors)
end
end
```
**The fix:** Move business logic to a context module. The controller just dispatches:
```elixir
def create(conn, params) do
case Accounts.register_user(params) do
{:ok, user} -> render(conn, :show, user: user)
{:error, changeset} -> render(conn, :error, errors: changeset.errors)
end
end
```
---
## 8. Using `:permanent` Restart for One-Shot Tasks
**Source:** `lib/elixir/lib/task.ex:178-186`
> a Task has a default `:restart` of `:temporary`. This means the task will
> not be restarted even if it crashes.
**The mistake:**
```elixir
# Will restart infinitely if the HTTP call keeps failing
use Task, restart: :permanent
def start_link(url) do
Task.start_link(fn -> HTTP.get!(url) end)
end
```
**The fix:** Use `:temporary` (default) for one-shot work. Use `:transient` if you want restart only on abnormal exit:
```elixir
use Task, restart: :transient
```
---
## 9. Pattern Matching the Internals of Opaque Types
**Source:** `lib/elixir/lib/task.ex:298-300`
```elixir
@opaque ref :: reference()
```
**The mistake:**
```elixir
# Accessing internal structure of an opaque type
%Task{ref: ref} = task
send(ref, :custom_message) # This breaks if internals change
```
**The fix:** Use the public API. If a type is `@opaque`, its structure is not guaranteed between versions. Use functions like `Task.await/2` that work with the type properly.
---
## 10. Not Using `on_exit` for Test Cleanup
**Source:** `lib/ex_unit/lib/ex_unit/case.ex:86-94`
**The mistake:**
```elixir
test "writes to file" do
File.write!("/tmp/test_file", "data")
assert File.read!("/tmp/test_file") == "data"
File.rm!("/tmp/test_file") # Never runs if assert above fails!
end
```
**The fix:**
```elixir
setup do
path = "/tmp/test_file_#{System.unique_integer()}"
on_exit(fn -> File.rm(path) end)
{:ok, path: path}
end
test "writes to file", %{path: path} do
File.write!(path, "data")
assert File.read!(path) == "data"
# Cleanup happens automatically, even on failure
end
```