2e7a822b6b
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)
327 lines
11 KiB
Markdown
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)
|