Elixir Code Review Checklist: Functional Patterns and OTP Principles

Published on
Written byChristoffer Artmann
Elixir Code Review Checklist: Functional Patterns and OTP Principles

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 with for happy path)

Pattern Matching & Error Handling

  • All function clauses handle expected patterns
  • {:ok, result} and {:error, reason} tuples for operations that might fail
  • with expressions 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 :error atoms (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, via tuples)
  • 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
  • send and receive used appropriately (prefer OTP abstractions)
  • No race conditions (processes own their state)
  • Parallel operations use Task.async_stream for 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: @doc with 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.