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)
This commit is contained in:
Aaron Weiker
2026-04-29 22:59:17 -07:00
parent 4ea9a884aa
commit 2e7a822b6b
6 changed files with 845 additions and 406 deletions
+386 -242
View File
@@ -1,265 +1,409 @@
# Common Mistakes
# Common Mistakes in Elixir (What the Core Team Avoids)
What "bad Elixir" looks like, based on what the source code explicitly warns against or demonstrates the correct way to avoid.
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 `++` 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
```
## 1. Using `Process.sleep` Instead of Message-Based Synchronization
**The mistake:**
```elixir
# O(n²) — copies the entire left list for every element
Enum.reduce(items, [], fn item, acc -> acc ++ [transform(item)] end)
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
# 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
test "process sends response" do
pid = spawn(fn -> send(test_pid, :done) end)
assert_receive :done, 1000 # Explicit timeout, proper wait
end
```
**The fix:**
**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
@impl true
def handle_call(msg, _from, state) do
{:reply, msg, state}
setup do
{:ok, pid} = MyGenServer.start_link(name: :my_server)
on_exit(fn -> GenServer.stop(pid) end)
%{pid: pid}
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
```
**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
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
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
# In an async test
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.
---
## 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` — ExUnit raises if you try to set reserved keys 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."