Elixir
Design modules, model data, and handle errors with Elixir's functional idioms.
The Iron Law
NO PROCESS WITHOUT A RUNTIME REASON
Before creating a GenServer, Agent, or any process, answer YES to at least one:
- Do I need mutable state persisting across calls?
- Do I need concurrent execution?
- Do I need fault isolation?
All three are NO? Use plain functions. Modules organize code; processes manage runtime.
The Three Decoupled Dimensions
OOP couples behavior, state, and mutability together. Elixir decouples them:
| OOP Dimension | Elixir Equivalent |
|---|---|
| Behavior | Modules (functions) |
| State | Data (structs, maps) |
| Mutability | Processes (GenServer) |
Pick only what you need. "I only need data and functions" = no process needed.
"Let It Crash" = "Let It Heal"
The misconception: Write careless code. The truth: Supervisors START processes.
- Handle expected errors explicitly (
{:ok, _}/{:error, _}) - Let unexpected errors crash → supervisor restarts
Control Flow
Pattern matching first:
- Match on function heads instead of
if/elseorcasein bodies %{}matches ANY map—usemap_size(map) == 0guard for empty maps- Avoid nested
case—refactor to singlecase,with, or separate functions
Error handling:
- Use
{:ok, result}/{:error, reason}for operations that can fail - Avoid raising exceptions for control flow
- Use
withfor chaining{:ok, _}/{:error, _}operations
Be explicit about expected cases:
- Avoid
_ -> nilcatch-alls—they silently swallow unexpected cases - Avoid
value && value.fieldnil-punning—obscures actual return types - When a case has
{:ok, nil} -> nilalongside{:ok, value} -> value.field, usewithinstead:
# Verbose
case get_run(id) do
{:ok, nil} -> nil
{:ok, run} -> run.recommendations
end
# Prefer
with {:ok, %{recommendations: recs}} <- get_run(id), do: recs
Polymorphism
| For Polymorphism Over... | Use | Contract |
|---|---|---|
| Modules | Behaviors | Upfront callbacks |
| Data | Protocols | Upfront implementations |
| Processes | Message passing | Implicit (send/receive) |
Behaviors = default for module polymorphism (very cheap at runtime) Protocols = only when composing data types, especially built-ins Message passing = only when stateful by design (IO, file handles)
Use the simplest abstraction: pattern matching → anonymous functions → behaviors → protocols → message passing. Each step adds complexity.
When justified: Library extensibility, multiple implementations, test swapping. When to stay coupled: Internal module, single implementation, pattern matching handles all cases.
Data Modeling Replaces Class Hierarchies
OOP: Complex class hierarchy + visitor pattern. Elixir: Model as data + pattern matching + recursion.
{:sequence, {:literal, "rain"}, {:repeat, {:alternation, "dogs", "cats"}}}
def interpret({:literal, text}, input), do: ...
def interpret({:sequence, left, right}, input), do: ...
def interpret({:repeat, pattern}, input), do: ...
Defaults and Options
Use /3 variants (Keyword.get/3, Map.get/3) instead of case statements branching on nil:
# WRONG
case Keyword.get(opts, :chunker) do
nil -> chunker()
config -> parse_chunker_config(config)
end
# RIGHT
Keyword.get(opts, :chunker, :default) |> parse_chunker_config()
Don't create helper functions to merge config defaults. Inline the fallback:
# WRONG
defp merge_defaults(opts), do: Keyword.merge([repo: Application.get_env(:app, :repo)], opts)
# RIGHT
def some_function(opts) do
repo = opts[:repo] || Application.get_env(:app, :repo)
end
Idioms
- Process dictionary is typically unidiomatic—pass state explicitly
- Reserve
is_thingnames for guards only - Use structs over maps when shape is known:
defstruct [:name, :age] - Prepend to lists
[new | list]notlist ++ [new] - Use
dbg/1for debugging—prints formatted value with context - Use built-in
JSONmodule (Elixir 1.18+) instead of Jason
Verification
Inside coding agents, always prefix mix commands with unbuffer to get ANSI colors and prevent stdout block-buffering in non-TTY environments (e.g. unbuffer mix test). Install: brew install expect (macOS) or apt install expect (Linux). If unbuffer is unavailable, report the missing prerequisite instead of silently dropping it.
After changing Elixir code, verify the completed change before reporting it as done. Run commands from the relevant Mix project using its pinned Elixir/OTP versions. Follow the repository's contribution instructions and existing check aliases. Prefer an alias when it covers the checks below, and run any uncovered checks separately:
- Format changed files with
unbuffer mix format path/to/file.ex path/to/test.exs, following the project's formatter configuration. - Compile with
unbuffer mix compile --warnings-as-errorsto catch compilation errors and warnings. - Run relevant tests with
unbuffer mix test test/path/to/affected_test.exs. Run the broader suite when the change affects shared behavior or the repository requires it. - Run
unbuffer mix credowhen Credo is configured, using the repository's flags and configuration.
Fix failures introduced by the change and rerun the affected checks. Report the commands actually run and their results, including any checks that were skipped or blocked and why. An unrun or blocked check hasn't passed.
Testing
Prefer pattern matching over imperative assertions. Never use assert length + Enum.at/List.last/hd. Pattern match checks length and content in one shot:
# Bad
assert length(students) == 2
assert Enum.at(students, 0).name == "Alice"
assert Enum.at(students, 1).name == "Bob"
# Good
assert [%{name: "Alice"}, %{name: "Bob"}] = students
Same goes for type-only predicates: assert is_map(user) / assert is_list(posts) pass for almost any non-error return. Pattern match the shape and content together: assert %User{email: "a@b.com"} = user. is_nil/1 is fine when nil-ness is the whole point.
Test behavior, not implementation. Test use cases / public API. Refactoring shouldn't break tests.
Test your code, not the framework. If deleting your code doesn't fail the test, it's tautological.
Keep tests async. async: false means you've coupled to global state. Fix the coupling:
| Problem | Solution |
|---|---|
Application.put_env |
Pass config as function argument |
| Feature flags | Inject via process dictionary or context |
| ETS tables | Create per-test tables with unique names |
| External APIs | Use Mox with explicit allowances |
| File system operations | Use @tag :tmp_dir (see below) |
Use tmp_dir for file tests. ExUnit creates unique temp directories per test, async-safe:
@tag :tmp_dir
test "writes file", %{tmp_dir: tmp_dir} do
path = Path.join(tmp_dir, "test.txt")
File.write!(path, "content")
assert File.read!(path) == "content"
end
Directory is auto-cleaned before each run. Works with @moduletag :tmp_dir for all tests in module.
Common Rationalizations
| Excuse | Reality |
|---|---|
| "I need a process to organize this code" | Modules organize code. Processes are for runtime. |
| "GenServer is the Elixir way" | Plain functions are also the Elixir way. |
| "I'll need state eventually" | YAGNI. Add process when you need it. |
| "It's just a simple wrapper process" | Simple wrappers become bottlenecks. |
| "This is how I'd structure it in OOP" | Rethink from data flow. |
Red Flags - STOP and Reconsider
- Creating process without answering the three questions
- Using GenServer for stateless operations
- Wrapping a library in a process "for safety"
- One process per entity without runtime justification
- Reaching for protocols when pattern matching works
Any of these? Re-read The Iron Law.