Nested case statements make code unreadable. Forgetting to handle pattern matching cases causes runtime crashes. GenServers without supervision restart create single points of failure. Blocking operations in GenServer callbacks freeze message processing. These issues compile successfully but cause errors, bottlenecks, or crashes under load.
This checklist helps catch what matters: non-functional patterns, missing supervision, improper OTP usage, and code that fights Elixir's concurrency model instead of leveraging processes and message passing.
Quick Reference Checklist
Use this checklist as a quick reference during code review.
Functional Programming Patterns
- Functions are pure (no side effects, same input produces same output)
- Data structures immutable (no mutation after creation)
- Pattern matching preferred over conditionals:
case,with, function clauses - Pipe operator chains data transformations:
data |> transform() |> process() - Guards used for simple conditions:
when is_binary(x) - Recursion used instead of loops
- Higher-order functions leverage Enum and Stream modules
- No nested case statements (use
withfor happy path)
Pattern Matching & Error Handling
- All function clauses handle expected patterns
{:ok, result}and{:error, reason}tuples for operations that might failwithexpressions handle multiple operations with error propagation- Pattern matching in function heads instead of conditionals in body
- Destructuring extracts values:
%{name: name, age: age} = user - Guard clauses validate input:
def process(x) when x > 0 - No bare
:erroratoms (provide reason:{:error, :not_found}) - Raise exceptions only for truly exceptional cases
OTP Behaviors & Processes
- GenServers implement all required callbacks:
init/1,handle_call/3,handle_cast/2 - Supervisors define child specs with restart strategies
- Process registry used for named processes (Registry,
viatuples) - No blocking operations in GenServer callbacks
- State updates return new state (immutability)
- Processes don't hold large amounts of data (use ETS for caching)
- Application supervision tree properly structured
- GenServer timeout handling implemented when needed
Concurrency & Message Passing
- Tasks used for concurrent operations:
Task.async,Task.await - Processes communicate through messages, not shared state
sendandreceiveused appropriately (prefer OTP abstractions)- No race conditions (processes own their state)
- Parallel operations use
Task.async_streamfor collections - Heavy computation offloaded to separate processes
- Process mailboxes don't grow unbounded
- Timeouts specified for operations that might hang
Data Structures & Performance
- Maps used for key-value data:
%{key: value} - Lists used for sequential access, not random access
- Keyword lists for options:
[async: true, timeout: 5000] - Structs for domain models:
defstruct [:name, :age] - ETS tables for large datasets and caching
- String operations use binary syntax:
<<"hello">> - Avoid
++for appending lists (use recursion or reverse-build) - Stream for lazy evaluation of large collections
Testing & Documentation
- Doctests in module documentation:
@docwith examples - ExUnit tests for all public functions
- Test descriptions clear:
test "returns error when user not found" - Mox or similar for mocking external dependencies
- Property-based testing for complex logic (StreamData)
- Module documentation explains purpose:
@moduledoc - Function documentation includes examples and return types:
@doc - Type specs document function signatures:
@spec
Functional Programming in Elixir
Elixir embraces functional programming where functions are pure and data is
immutable. Pure functions always return the same output for the same input
without side effects. This makes code predictable and testable. When reviewing
functions, we verify they don't mutate data or rely on external state. The
pattern def calculate(x, y), do: x + y is pure because it only transforms
inputs to output.
Immutability means data structures never change after creation. Operations that appear to modify data actually return new structures. This eliminates entire classes of bugs related to shared mutable state. When we see code trying to update maps or lists, we verify it's using transformation functions that return new values rather than attempting mutation.
Pattern matching provides elegant control flow without nested conditionals. The
case expression matches values against patterns, executing the first matching
clause. Function heads can pattern match on arguments, allowing different
implementations based on input shape. When we see long if-else chains or nested
case statements, pattern matching in function clauses usually simplifies the
logic.
The pipe operator makes data transformations readable by chaining function
calls. The expression data |> validate() |> transform() |> save() clearly
shows the data flow from left to right. This beats nested function calls like
save(transform(validate(data))) which read inside-out. When we see deeply
nested function calls, pipes improve clarity.
Guards add simple conditions to pattern matching without full case expressions.
The pattern def process(x) when is_integer(x) and x > 0 validates input at the
function head. Guards can only use limited safe operations that don't raise
exceptions. When functions need input validation, guards provide clean syntax
for simple checks.
Higher-order functions from the Enum and Stream modules replace explicit
recursion for common operations. The expression Enum.map(list, &transform/1)
applies a function to each element. Stream provides lazy evaluation for large or
infinite collections. When we see manual recursion implementing map, filter, or
reduce, Enum functions express intent more clearly.
Pattern Matching and Error Handling
The {:ok, result} and {:error, reason} convention communicates success or
failure through return values. Functions that might fail return these tuples,
letting callers decide how to handle errors. Pattern matching extracts the
result: {:ok, user} = get_user(id) succeeds only if the function returns ok.
When we see functions returning multiple types without wrapping in tuples, the
ok-error convention makes error handling explicit.
The with expression chains operations that return ok-error tuples, stopping at
the first error. The pattern focuses on the happy path while handling errors at
the end. This beats nested case expressions checking each result. When we see
deeply nested case statements checking for errors, with expressions flatten the
logic and improve readability.
Pattern matching in function heads eliminates conditionals in function bodies. Multiple function clauses handle different cases, with the first matching clause executing. The pattern defines separate clauses for different input shapes, making each implementation simple. When functions have large case statements switching on input type, multiple function clauses separate the logic more clearly.
Destructuring extracts values from complex data structures in pattern matches.
The expression %{name: name, age: age} = user extracts specific fields from a
map. This works in function heads, case clauses, and assignments. When we see
code accessing nested fields repeatedly, destructuring extracts values upfront.
Guard clauses validate input at function boundaries rather than deep in implementation. Guards can check types, compare values, and call specific safe functions. The compiler optimizes guard checks and they document preconditions. When functions manually validate inputs with if expressions, guards make requirements explicit in the signature.
Exceptions should be rare in Elixir, reserved for truly exceptional situations. Normal error conditions return error tuples. Raising exceptions for expected errors like validation failures or not-found results fights the Elixir style. When we see raise in normal error paths, returning error tuples allows callers to handle errors appropriately.
OTP Behaviors and Process Management
GenServers implement the server pattern for managing state in processes. The
required callbacks init/1, handle_call/3, and handle_cast/2 define how the
server initializes and responds to messages. GenServer handles message passing,
state management, and error handling. When we see processes managing state with
manual receive loops, GenServer provides the pattern.
Supervisors restart failed processes according to restart strategies. The supervisor monitors child processes, restarting them when they crash. This creates fault-tolerant systems where individual process failures don't bring down the application. When GenServers aren't supervised, crashes become single points of failure rather than recoverable events.
Process registration allows addressing processes by name instead of PID. The Registry module provides flexible process naming and lookup. Named processes improve code clarity compared to passing PIDs through function arguments. When we see PIDs stored in application config or passed deeply through call chains, registration simplifies process communication.
Blocking operations in GenServer callbacks freeze the entire server, preventing other messages from processing. Long-running work should happen in separate processes using Task or by spawning workers. The GenServer only coordinates and manages state. When callbacks do heavy computation or I/O, that work should move to background processes.
State updates must return new state because Elixir data is immutable. GenServer callbacks receive current state and return updated state. The framework replaces the old state with the new value. When we see attempts to mutate state data structures, the code must return transformed state instead.
Large data in process state creates memory pressure and slows garbage collection. Processes should hold minimal state, moving large datasets to ETS tables. ETS provides in-memory storage accessible from any process without copying data. When GenServers accumulate megabytes of data, ETS handles large datasets more efficiently.
Concurrency Through Processes
Tasks provide simple concurrency for operations that return a result. The
pattern task = Task.async(fn -> work() end); result = Task.await(task) spawns
a process for work and collects the result. This beats manual process spawning
and message handling for simple concurrent operations. When code spawns
processes to run functions and sends results back, Task abstracts this pattern.
Processes communicate through messages rather than sharing memory. Each process has its own state, accessed only through message passing. This eliminates race conditions and makes concurrency safe. When we see attempts to share data between processes using external state, message passing provides the Elixir approach.
The send and receive primitives enable custom message passing, but OTP
behaviors like GenServer usually provide better structure. Raw send and receive
work for simple cases, but GenServer adds error handling, state management, and
standard patterns. When custom receive loops grow complex, GenServer behaviors
organize the logic.
Parallel operations on collections use Task.async_stream to process elements
concurrently. This streams results as they complete, handling thousands of
concurrent operations efficiently. The pattern limits maximum concurrent tasks
to prevent overwhelming the system. When sequential Enum operations process
large collections with I/O per element, async_stream adds parallelism.
Heavy computation should run in separate processes to avoid blocking. Spawning a process for CPU-intensive work keeps the rest of the system responsive. The scheduler distributes processes across CPU cores. When long calculations block GenServer callbacks or web requests, moving computation to processes enables concurrency.
Process mailboxes can grow unbounded if messages arrive faster than processing. This causes memory growth and eventually crashes. Rate limiting message sending or using backpressure mechanisms prevents mailbox overflow. When processes receive messages from external sources without limits, monitoring mailbox size catches problems early.
Data Structures and Performance
Maps store key-value pairs with fast access by key. The syntax
%{name: "Alice", age: 30} creates maps with atom or string keys. Maps work
well for structured data with known fields. When representing domain objects or
configuration, maps provide convenient syntax and good performance.
Lists provide sequential access optimized for head operations. Adding to the
front with [item | list] is constant time. Random access requires traversing
the list. Lists work well for stacks, queues, and sequential processing but
poorly for random access. When code accesses list elements by index repeatedly,
other data structures perform better.
Keyword lists [key: value, key2: value2] represent options and small
collections of pairs. They allow duplicate keys and maintain order, unlike maps.
Function options commonly use keyword lists. When functions take many optional
parameters, keyword lists provide clean syntax.
Structs define schemas for domain data, providing compile-time checks for field
names. The defstruct declaration creates a map-like structure with defined
fields and optional defaults. Structs document what fields exist and enable
pattern matching on type. When maps represent domain models, structs add type
safety and documentation.
ETS tables provide in-memory storage for large datasets shared between processes. Unlike process state, ETS doesn't copy data when accessed from multiple processes. This makes ETS efficient for caching, counters, and large reference data. When GenServers accumulate large amounts of data, ETS provides better performance and memory characteristics.
String operations should use binary syntax for efficiency. The pattern
<<"hello">> creates binaries, which are more efficient than charlists for
text. Most string operations work on binaries. When we see charlists (single
quotes) for user-facing text, binaries (double quotes) perform better.
Appending lists with ++ is expensive because it copies the left list. Building
lists by prepending and reversing, or using recursion to build backwards, avoids
this cost. When we see repeated ++ operations in loops, alternative approaches
improve performance.
Stream provides lazy evaluation for large or infinite collections. Unlike Enum which eagerly processes elements, Stream creates a pipeline that processes elements only when needed. This reduces memory usage for large datasets. When Enum operations chain on large collections, Stream postpones work until the final consumer needs results.
Bringing It Together
Effective Elixir code review recognizes functional patterns versus imperative habits from other languages. Code that tries to mutate state, uses nested conditionals instead of pattern matching, or spawns processes without supervision misses Elixir's strengths. Well-written Elixir leverages immutability, pattern matching, OTP behaviors, and the process model.
Not every issue requires immediate fixing. A missing type spec is documentation issue. An unsupervised GenServer is a reliability problem. Missing error handling in production code causes failures. The key is identifying patterns that affect reliability, maintainability, and whether the code works with Elixir's model or against it.
Elixir's concurrency model through lightweight processes enables building massively concurrent fault-tolerant systems. Code review helps teams learn these patterns and understand when the compiler's guidance leads to better designs. The BEAM VM's process isolation and supervision trees make errors recoverable, but only when we structure code to leverage these features.

