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

11 KiB

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:

# 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
# 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:

# 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:

# 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.exdefstruct @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:

# 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:

# 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:

# 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:

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:

# 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:

# 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)