feat: initial elixir patterns guide from source study

12 pattern/smell files covering GenServer, process design, data transforms,
error handling, testing, typespecs, documentation, behaviours, macros,
modules, anti-patterns, and common mistakes.

All patterns cite specific Elixir source files and line numbers.
This commit is contained in:
Aaron Weiker
2026-04-29 22:49:44 -07:00
commit 835ce18f09
16 changed files with 5000 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.
+408
View File
@@ -0,0 +1,408 @@
# Common Mistakes in Elixir (What the Core Team Avoids)
Patterns that suggest "if you're doing X, you're doing it wrong" — extracted from studying the Elixir standard library and ExUnit source.
---
## 1. Using `Process.sleep` Instead of Message-Based Synchronization
**The mistake:**
```elixir
test "process sends response" do
pid = spawn(fn -> send(test_pid, :done) end)
Process.sleep(50) # Hope 50ms is enough...
assert_received :done
end
```
**Why it's wrong:** The test is a race condition. It might pass locally but fail on CI. The sleep is arbitrary.
**The fix:**
```elixir
test "process sends response" do
pid = spawn(fn -> send(test_pid, :done) end)
assert_receive :done, 1000 # Explicit timeout, proper wait
end
```
**Source:** Elixir's test suite has 39 `assert_receive`/`refute_receive` calls in `task_test.exs` alone, vs 0 `Process.sleep(N)` for synchronization.
---
## 2. Not Using `start_supervised` in Tests
**The mistake:**
```elixir
setup do
{:ok, pid} = MyGenServer.start_link(name: :my_server)
on_exit(fn -> GenServer.stop(pid) end)
%{pid: pid}
end
```
**Why it's wrong:** If the process crashes during the test, `on_exit` will try to stop an already-dead process. If `start_link` links to the test process, a crash kills the test before cleanup.
**The fix:**
```elixir
setup do
pid = start_supervised!(MyGenServer)
%{pid: pid}
end
```
**Source:** `lib/ex_unit/lib/ex_unit/callbacks.ex:277-340``start_supervised` is designed specifically for this: guaranteed shutdown in reverse order, no leaked processes, no race conditions.
---
## 3. Asserting Exact Equality on Concurrent/Shared Output
**The mistake:**
```elixir
test "logs error message" do
assert capture_io(:stderr, fn -> Logger.error("oops") end) == "[error] oops\n"
end
```
**Why it's wrong:** With `async: true`, other tests may write to `:stderr` simultaneously. The captured output may include their messages.
**The fix:**
```elixir
test "logs error message" do
assert capture_io(:stderr, fn -> Logger.error("oops") end) =~ "oops"
end
```
**Source:** `lib/ex_unit/lib/ex_unit/capture_io.ex` docs explicitly warn: "use `=~` instead of `==` for assertions on `:stderr` if your tests are async"
---
## 4. Registering Global Names in Async Tests
**The mistake:**
```elixir
defmodule MyTest do
use ExUnit.Case, async: true
test "starts a server" do
{:ok, _} = GenServer.start_link(MyServer, [], name: :my_server)
# Another test instance might try to register the same name!
end
end
```
**Why it's wrong:** Registered names are global. Two concurrent test runs will collide on `:my_server`.
**The fix:**
```elixir
test "starts a server", %{test: test_name} do
{:ok, _} = GenServer.start_link(MyServer, [], name: test_name)
# test_name is unique per test
end
```
**Source:** `lib/elixir/test/elixir/registry_test.exs:28``name = :"#{config.test}_#{partitions}_#{inspect(keys)}"` — always derives unique names from test context.
---
## 5. Testing Private Functions Directly
**The mistake:**
```elixir
# Exposing private implementation for testing
@doc false
def __internal_transform__(data), do: ...
# In test:
test "internal transform works" do
assert MyModule.__internal_transform__(%{}) == %{transformed: true}
end
```
**Why it's wrong:** Tests become coupled to implementation. You can't refactor without breaking tests. The public API is the contract.
**The fix:** Test through the public interface. If a private function is complex enough to need its own tests, it should probably be its own module.
**Source:** The Elixir test suite tests public APIs exclusively. `gen_server_test.exs` tests `GenServer.call/cast/stop` — never the internal `handle_*` callbacks directly.
---
## 6. Not Cleaning Up After Global State Changes
**The mistake:**
```elixir
test "changes log level" do
Logger.configure(level: :error)
# ... test stuff ...
# Oops, forgot to restore! All subsequent tests have wrong log level.
end
```
**Why it's wrong:** Contaminates the test environment for all subsequent tests. Causes mysterious failures in unrelated tests.
**The fix:**
```elixir
test "changes log level" do
Logger.configure(level: :error)
# ... test stuff ...
after
Logger.configure(level: :debug)
end
# Or better, use on_exit in setup:
setup do
on_exit(fn -> Logger.configure(level: :debug) end)
end
```
**Source:** `lib/logger/test/logger_test.exs:12-17` — Every Logger config change has a corresponding `on_exit` restoration. `lib/logger/test/test_helper.exs:57-62``capture_log` uses `after` to always restore level.
---
## 7. Nested `describe` Blocks
**The mistake:**
```elixir
describe "users" do
describe "admin users" do # This won't compile!
test "can delete" do
end
end
end
```
**Why it's wrong:** ExUnit explicitly prevents nested describe blocks. The framework raises: "cannot call \"describe\" inside another \"describe\"."
**The fix:** Use flat describe blocks with descriptive names, or prefix test names:
```elixir
describe "admin users - deletion" do
test "can delete other users" do
end
end
```
**Source:** `lib/ex_unit/lib/ex_unit/callbacks.ex:423-425``no_describe!` check prevents nesting. The docs explain the design: "See the documentation for ExUnit.Case.describe/2 on named setups and how to handle hierarchies"
---
## 8. Using `assert` Where `assert_receive` Belongs
**The mistake:**
```elixir
test "message is sent" do
send(self(), {:result, 42})
assert {:result, 42} in Process.info(self(), :messages) |> elem(1)
end
```
**Why it's wrong:** Reinvents the wheel poorly. Doesn't wait for async messages. No pattern matching. Bad failure messages.
**The fix:**
```elixir
test "message is sent" do
send(self(), {:result, 42})
assert_received {:result, 42}
end
```
**Source:** ExUnit provides specialized assertion macros for messages precisely because generic `assert` is inadequate for mailbox testing.
---
## 9. Forgetting `Process.flag(:trap_exit, true)` When Testing Linked Processes
**The mistake:**
```elixir
test "handles process crash" do
task = Task.async(fn -> raise "boom" end)
# Test process crashes because it's linked to the task!
assert catch_exit(Task.await(task))
end
```
**Why it's wrong:** Without trapping exits, the linked process crash kills the test process before `catch_exit` can catch anything.
**The fix:**
```elixir
test "handles process crash" do
Process.flag(:trap_exit, true)
task = Task.async(fn -> raise "boom" end)
assert {{%RuntimeError{}, _}, _} = catch_exit(Task.await(task))
end
```
**Source:** `lib/elixir/test/elixir/task_test.exs:297,305,315,330` — Every test that expects a linked process to crash sets `:trap_exit` first.
---
## 10. Writing Flaky Tests with Timing Assumptions
**The mistake:**
```elixir
test "debounce fires after 100ms" do
start_debounce(:my_action, 100)
Process.sleep(110) # "should be enough"
assert_received :my_action
end
```
**Why it's wrong:** System load, GC pauses, or CI resource contention can make 110ms insufficient. The test will flake.
**The fix:** Use generous timeouts with `assert_receive`:
```elixir
test "debounce fires after delay" do
start_debounce(:my_action, 100)
assert_receive :my_action, 1000 # generous timeout, still fast on success
end
```
Or better, make the system under test notify completion:
```elixir
test "debounce fires callback" do
start_debounce(fn -> send(self(), :fired) end, 100)
assert_receive :fired, 1000
end
```
**Source:** Elixir's own `ExUnit.configure` allows setting `assert_receive_timeout` globally (default 100ms, CI uses 300ms via env var `ELIXIR_ASSERT_TIMEOUT`).
---
## 11. Not Using `=~` for Regex/Partial Matching
**The mistake:**
```elixir
test "error message" do
{:error, msg} = do_thing()
assert msg == "failed to connect to localhost:5432 (connection refused)"
end
```
**Why it's wrong:** The message might include timestamps, PIDs, or other dynamic content. Any format change breaks the test.
**The fix:**
```elixir
test "error message" do
{:error, msg} = do_thing()
assert msg =~ "connection refused"
# Or with regex:
assert msg =~ ~r/failed to connect to .+:\d+/
end
```
**Source:** `lib/elixir/test/elixir/gen_server_test.exs:70-82` uses `~r"expected :name option to be one of the following:"` in `assert_raise` — testing the stable part, ignoring the dynamic rest.
---
## 12. Relying on Process.alive? Without Synchronization
**The mistake:**
```elixir
test "process stops" do
GenServer.stop(pid)
refute Process.alive?(pid) # Race condition!
end
```
**Why it's wrong:** `GenServer.stop` is synchronous for the server's exit, but the Process.alive? check and :DOWN signal delivery have a timing gap.
**The fix:**
```elixir
test "process stops" do
ref = Process.monitor(pid)
GenServer.stop(pid)
assert_receive {:DOWN, ^ref, :process, ^pid, :normal}
end
```
**Source:** `lib/elixir/test/elixir/supervisor_test.exs:278-285``assert_kill` helper always uses monitor + assert_receive, never `Process.alive?` polling.
---
## 13. Using `== ""` for Empty Capture Assertions in Async Tests
**The mistake:**
```elixir
# In an async test
test "no output on success" do
assert capture_io(:stderr, fn -> do_quiet_thing() end) == ""
end
```
**Why it's wrong:** Another async test might write to stderr during your capture window, making your capture non-empty.
**The fix:** Either make the test synchronous, or don't assert emptiness on shared devices:
```elixir
# Use :stdio (group leader) which is per-process and safe
test "no output on success" do
assert capture_io(fn -> do_quiet_thing() end) == ""
end
```
**Source:** `lib/ex_unit/lib/ex_unit/capture_io.ex` docs: "avoid empty captures on `:stderr` with async tests"
---
## 14. Overriding ExUnit Reserved Tags
**The mistake:**
```elixir
@tag test: "my_custom_value" # Overwrites ExUnit's :test tag!
```
**Why it's wrong:** ExUnit reserves certain context keys (`:case`, `:file`, `:line`, `:test`, `:async`, `:registered`, `:describe`). Overriding them breaks ExUnit internals.
**The fix:** Use your own tag names that don't conflict:
```elixir
@tag test_type: "integration"
```
**Source:** `lib/ex_unit/lib/ex_unit/callbacks.ex:460``@reserved [:case, :file, :line, :test, :async, :registered, :describe]` — ExUnit raises if you try to set these to different values in setup.
---
## 15. Complex Conditional Logic in Tests
**The mistake:**
```elixir
test "handles all cases" do
for input <- [:a, :b, :c] do
result = process(input)
if input == :a do
assert result == 1
else
if input == :b do
assert result == 2
else
assert result == 3
end
end
end
end
```
**Why it's wrong:** When this test fails, you don't know which case failed. The logic is harder to read than separate tests. Conditionals in tests suggest you're testing multiple behaviors in one test.
**The fix:** Separate tests for separate behaviors, or use parameterize:
```elixir
# Option 1: Separate tests
test "processes :a" do
assert process(:a) == 1
end
test "processes :b" do
assert process(:b) == 2
end
# Option 2: Parameterize (Elixir 1.18+)
use ExUnit.Case, parameterize: [
%{input: :a, expected: 1},
%{input: :b, expected: 2},
%{input: :c, expected: 3}
]
test "processes input", %{input: input, expected: expected} do
assert process(input) == expected
end
```
**Source:** ExUnit case.ex docs: "If you use parameterized tests and then find yourself adding conditionals in your tests to deal with different parameters, then parameterized tests may be the wrong solution."