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:
@@ -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.
|
||||
Reference in New Issue
Block a user