Files
elixir-patterns/smells/anti-patterns.md
T
Aaron Weiker 2e7a822b6b docs: idiomatic Elixir and Phoenix patterns with verified source citations
Extracted patterns from Elixir core and Phoenix source code with specific
file:line citations, then verified all citations against the actual source
in a second pass.

Structure:
- patterns/ — Elixir core patterns (GenServer, errors, data, types, etc.)
- phoenix/ — Phoenix-specific patterns and deviations
- comparison/ — Elixir vs Phoenix side-by-side
- smells/ — Anti-patterns and common mistakes
- changelog/ — Daily Elixir/Phoenix PR digest (auto-updated)
2026-04-29 22:59:17 -07:00

327 lines
11 KiB
Markdown

# Anti-Patterns (Code Smells) in Elixir
Patterns the Elixir core team actively avoids, extracted from studying what they DON'T do in their source code.
---
## 1. Process.sleep for Synchronization (Timing-Based Tests)
**What they avoid:** Using `Process.sleep(N)` to wait for async operations to complete.
**Source evidence:** `lib/elixir/test/elixir/task_test.exs` — 13 uses of `Process.sleep`, ALL are `Process.sleep(:infinity)` for process parking. Zero uses of `Process.sleep(100)` or similar for timing.
**Why it's bad:** Race conditions. A sleep might be too short on a loaded CI server, causing flaky tests. Too long wastes time.
**What they do instead:**
```elixir
# BAD — timing-based synchronization
spawn(fn -> send(parent, :done) end)
Process.sleep(50)
assert_received :done
# GOOD — event-based synchronization
spawn(fn -> send(parent, :done) end)
assert_receive :done # waits up to 100ms with proper timeout
```
```elixir
# BAD — waiting for process to die
Process.exit(pid, :kill)
Process.sleep(10)
refute Process.alive?(pid)
# GOOD — monitor-based confirmation
ref = Process.monitor(pid)
Process.exit(pid, :kill)
assert_receive {:DOWN, ^ref, _, _, _}
```
---
## 2. Mutable Global State in Tests
**What they avoid:** Tests that depend on or modify global state without cleanup.
**Source evidence:** `lib/mix/test/test_helper.exs:98-113` — MixTest.Case restores ALL global state in `on_exit`:
- `Mix.env(:dev)`, `Mix.target(:host)`, `Mix.Task.clear()`, `Mix.Shell.Process.flush()`
- Unloads all applications that were loaded during the test
**Why it's bad:** Tests pass in isolation but fail when run together. Order-dependent failures are the hardest bugs to debug.
**What they do instead:**
```elixir
# Every test using Mix gets automatic cleanup
setup do
on_exit(fn ->
Mix.env(:dev)
Mix.target(:host)
Mix.Task.clear()
Mix.Shell.Process.flush()
Mix.State.clear_cache()
Mix.ProjectStack.clear_stack()
for {app, _, _} <- Application.loaded_applications(), app not in @apps do
Application.stop(app)
Application.unload(app)
end
end)
end
```
---
## 3. try/rescue for Control Flow
**What they avoid:** Using exceptions for expected conditions.
**Source evidence:** `lib/elixir/lib/enum.ex` — 5,242 lines with only 2 `try do` blocks. `lib/elixir/lib/kernel.ex` — 7,102 lines with only 5 `try do` blocks. The ratio is vanishingly small.
**Why it's bad:** Exceptions are for exceptional conditions. Using them for control flow obscures intent, defeats pattern matching, and is slower.
**What they do instead:**
```elixir
# BAD — exception as control flow
def find_user(id) do
try do
fetch_user!(id)
rescue
NotFoundError -> nil
end
end
# GOOD — tagged tuples for expected outcomes
def find_user(id) do
case fetch_user(id) do
{:ok, user} -> user
{:error, :not_found} -> nil
end
end
```
The standard library uses `with` statements and tagged tuples (`{:ok, result}` / `{:error, reason}`) for all fallible operations.
---
## 4. God Modules (Unbounded Module Growth)
**What they avoid:** Stuffing everything into one module.
**Source evidence:** The Elixir standard library splits functionality aggressively:
- `ExUnit.Case` — test case registration
- `ExUnit.Callbacks` — setup/teardown lifecycle
- `ExUnit.Assertions` — assertion macros
- `ExUnit.CaptureIO` — IO capture
- `ExUnit.CaptureLog` — log capture
- `ExUnit.DocTest` — doctest extraction
Each module has a single, clear responsibility.
**Why it's bad:** Large modules are hard to navigate, hard to test in isolation, and create implicit coupling between unrelated features.
**What they do instead:** Single-responsibility modules that compose. `use ExUnit.Case` imports from multiple focused modules.
---
## 5. Stringly-Typed APIs
**What they avoid:** Using strings where atoms, structs, or tagged tuples would be clearer.
**Source evidence:** Throughout the codebase, atoms and structs are preferred:
- `lib/elixir/lib/task.ex``defstruct @enforce_keys` (Task is a struct, not a map)
- `lib/elixir/lib/uri.ex` — URI is a struct
- `lib/elixir/lib/version.ex` — Version is a struct
- `lib/elixir/lib/range.ex` — Range is a struct
- All ExUnit tags use atoms: `:async`, `:capture_log`, `:tmp_dir`
**Why it's bad:** Strings lack compile-time checking, are prone to typos, and don't pattern match cleanly.
**What they do instead:**
```elixir
# BAD — string keys, no structure
%{"type" => "user", "name" => "Alice", "role" => "admin"}
# GOOD — struct with enforced keys
defmodule User do
@enforce_keys [:name, :role]
defstruct [:name, :role, :email]
end
%User{name: "Alice", role: :admin}
```
---
## 6. Bare Maps Where Structs Belong
**What they avoid:** Using plain maps for data with a known, fixed shape.
**Source evidence:** 69 `defstruct` declarations in `lib/elixir/lib/` alone. Every domain concept with a stable shape gets a struct: Task, URI, Version, Range, Regex, MapSet, Stream.
**Why it's bad:**
- No compile-time key checking
- No default values
- No protocol implementations
- No `@enforce_keys` for required fields
- Pattern matching is fragile (extra keys silently ignored)
**What they do instead:**
```elixir
# The Task struct enforces its shape:
defmodule Task do
@enforce_keys [:ref, :pid, :owner, :mfa]
defstruct @enforce_keys
end
# Pattern matching on struct is type-safe:
def await(%Task{ref: ref, owner: owner}) when owner == self() do
# ...
end
```
---
## 7. Deep Nesting
**What they avoid:** Deeply nested `case`/`if`/`cond` blocks.
**Source evidence:** The standard library heavily uses:
- Early returns via pattern matching in function heads
- `with` statements for sequential fallible operations
- Pipeline operator for transformations
- Multi-clause functions instead of nested conditionals
**Why it's bad:** Deep nesting increases cognitive load and makes the "happy path" hard to identify.
**What they do instead:**
```elixir
# BAD — nested conditionals
def process(input) do
case validate(input) do
{:ok, valid} ->
case transform(valid) do
{:ok, result} ->
case save(result) do
{:ok, saved} -> {:ok, saved}
{:error, reason} -> {:error, reason}
end
{:error, reason} -> {:error, reason}
end
{:error, reason} -> {:error, reason}
end
end
# GOOD — with statement flattens the happy path
def process(input) do
with {:ok, valid} <- validate(input),
{:ok, result} <- transform(valid),
{:ok, saved} <- save(result) do
{:ok, saved}
end
end
```
---
## 8. Shared Mutable State Between Tests
**What they avoid:** ETS tables, registered names, or application env used across tests without isolation.
**Source evidence:** `lib/elixir/test/elixir/registry_test.exs:28-31` — Each test gets a uniquely-named Registry:
```elixir
name = :"#{config.test}_#{partitions}_#{inspect(keys)}"
```
`lib/elixir/test/elixir/gen_server_test.exs:166` — Uses `%{test: name}` for unique process registration.
**Why it's bad:** Tests that share state can't run concurrently. They're order-dependent and fragile.
**What they do instead:** Every shared resource gets a unique name derived from `config.test` (the test name atom, guaranteed unique within a module).
---
## 9. Testing Internal State Directly
**What they avoid:** Reaching into process state, private functions, or internal data structures for assertions.
**Source evidence:** `lib/elixir/test/elixir/gen_server_test.exs` — The Stack GenServer is tested entirely through its public call/cast interface. No `:sys.get_state/1` anywhere in the test.
**Why it's bad:** Tests become coupled to implementation. Refactoring internals breaks tests even when behavior is unchanged.
**What they do instead:** Test the behavior (what goes in, what comes out) through the public API.
---
## 10. Overly Complex Test Setup
**What they avoid:** Setup blocks that are longer than the tests themselves.
**Source evidence:** The Elixir test suite keeps setup minimal:
- `lib/elixir/test/elixir/registry_test.exs` — 5-line setup
- `lib/elixir/test/elixir/gen_server_test.exs` — no setup at all, each test is self-contained
- `lib/elixir/test/elixir/task_test.exs` — helper functions instead of complex setup
**Why it's bad:** Complex setup obscures what's actually being tested. When a test fails, you have to understand the setup to understand the failure.
**What they do instead:**
- Self-contained tests that set up exactly what they need
- Small helper functions for common patterns
- `start_supervised!` for process-heavy tests (one line)
---
## 11. Unsupervised Processes
**What they avoid:** Using raw `spawn/1` or `spawn_link/1` in application code.
**Source evidence:** The standard library provides `Task`, `GenServer`, `Agent`, and `DynamicSupervisor` for every process use case. Raw spawns appear only in test helpers.
**Why it's bad:** Unsupervised processes are invisible to the system:
- Don't appear in Observer
- Don't get logged on crash
- Can't be gracefully shut down
- Create orphan processes on restart
**What they do instead:**
```elixir
# BAD — orphan process
spawn(fn -> do_work() end)
# GOOD — supervised, observable, restartable
Task.Supervisor.start_child(MyApp.TaskSupervisor, fn -> do_work() end)
```
---
## 12. Atom Creation from User Input
**What they avoid:** Converting untrusted strings to atoms.
**Source evidence:** `lib/elixir/lib/option_parser.ex:855` — Uses `String.to_existing_atom/1` with the `:switches` allowlist pattern. The only `String.to_atom/1` calls in library code are in compiler/macro contexts where the set is bounded.
**Why it's bad:** Atoms are never garbage collected. User-controlled atom creation is a denial-of-service vector (1,048,576 atom limit by default).
**What they do instead:**
```elixir
# BAD — unbounded atom creation
key = String.to_atom(user_input)
# GOOD — bounded, pre-existing atoms only
key = String.to_existing_atom(user_input)
# BETTER — don't convert at all, use string keys
config = Map.get(settings, user_input)
```
---
## 13. Callback-Heavy Abstractions (When Protocols Suffice)
**What they avoid:** Behaviour callbacks for polymorphism when protocols are more appropriate.
**Source evidence:** The standard library uses protocols for data-type polymorphism (`Enumerable`, `Collectable`, `Inspect`, `String.Chars`, `List.Chars`) and behaviours only for process contracts (`GenServer`, `Supervisor`, `Application`).
**Why it's bad:** Behaviours require the implementing module to know about the behaviour at compile time. Protocols allow retroactive implementation for types you don't control.
**The heuristic:**
- **Protocol:** "Different data types need different treatment" (e.g., printing, iterating)
- **Behaviour:** "Different modules implement the same process contract" (e.g., GenServer callbacks)