Extracted patterns, conventions, and code smells directly from the Elixir and Phoenix source code with file path and line number citations. Covers: GenServer, error handling, data transforms, process design, testing, documentation, typespecs, macros, behaviours, module organization, Phoenix-specific patterns, framework deviations, and anti-patterns.
16 KiB
GenServer Patterns — From the Elixir Source
Analysis of lib/elixir/lib/gen_server.ex, lib/elixir/lib/agent.ex, and related modules.
Pattern 1: Client/Server API Separation
Source: lib/elixir/lib/gen_server.ex:101-149 (documentation example)
What it does: Every GenServer module defines two distinct API layers — a client API (thin public functions that wrap GenServer.call/cast) and a server API (callback implementations). The client functions live in the same module but are clearly separated with comments.
Why: Encapsulation. Callers don't need to know the message protocol. The client API provides a typed, documented interface while the server callbacks handle the actual logic. This allows changing the internal message format without breaking callers.
Anti-pattern: Calling GenServer.call(MyServer, :some_msg) directly from other modules. This leaks the message protocol and couples callers to implementation details.
Code example from source:
defmodule Stack do
use GenServer
# Client
def start_link(default) when is_binary(default) do
GenServer.start_link(__MODULE__, default)
end
def push(pid, element) do
GenServer.cast(pid, {:push, element})
end
def pop(pid) do
GenServer.call(pid, :pop)
end
# Server (callbacks)
@impl true
def init(elements) do
initial_state = String.split(elements, ",", trim: true)
{:ok, initial_state}
end
@impl true
def handle_call(:pop, _from, state) do
[to_caller | new_state] = state
{:reply, to_caller, new_state}
end
@impl true
def handle_cast({:push, element}, state) do
new_state = [element | state]
{:noreply, new_state}
end
end
Pattern 2: @impl true Annotations on All Callbacks
Source: lib/elixir/lib/gen_server.ex:41-60 (Stack example)
What it does: Every callback function is annotated with @impl true. This tells the compiler to verify that the function is a valid callback for the declared behaviour.
Why: Catches typos and mismatches at compile time. If you accidentally name a callback handle_calls instead of handle_call, the compiler will warn you. It also serves as documentation for readers — you immediately know which functions are callbacks vs. helper functions.
Anti-pattern: Omitting @impl true on callbacks, especially when mixing callbacks with private helpers in the same module.
Code example from source:
@impl true
def init(counter) do
{:ok, counter}
end
@impl true
def handle_call(:get, _from, counter) do
{:reply, counter, counter}
end
Pattern 3: Guard-Protected start_link
Source: lib/elixir/lib/gen_server.ex:101 and lib/elixir/lib/agent.ex:28
What it does: The start_link function uses guards to validate its arguments at the API boundary, failing fast with a clear error before any process spawning occurs.
Why: Catches invalid arguments immediately at the caller site, not deep inside init/1 where the error would be harder to trace. This is the fail-fast principle applied at process boundaries.
Anti-pattern: Accepting any term in start_link and then crashing inside init/1 with a confusing FunctionClauseError or MatchError.
Code example from source:
# From gen_server.ex documentation
def start_link(default) when is_binary(default) do
GenServer.start_link(__MODULE__, default)
end
# From agent.ex:246
@spec start_link((-> term), GenServer.options()) :: on_start
def start_link(fun, options \\ []) when is_function(fun, 0) do
GenServer.start_link(Agent.Server, fun, options)
end
Pattern 4: handle_continue for Post-Init Work
Source: lib/elixir/lib/gen_server.ex:520-528 (callback spec), lib/elixir/lib/gen_server.ex:714-720 (handle_continue callback definition)
What it does: init/1 can return {:ok, state, {:continue, continue_arg}}, which causes handle_continue/2 to be invoked immediately after init completes. This allows splitting initialization into a synchronous part (that unblocks the supervisor) and an asynchronous continuation.
Why: init/1 blocks the supervisor — if it does expensive work (DB connections, HTTP calls, cache warming), it delays the entire supervision tree startup. handle_continue lets you return quickly from init/1 while still performing setup before handling any client messages. Unlike send(self(), :continue), it's guaranteed to execute before any other messages in the mailbox.
Anti-pattern: Doing slow initialization directly in init/1, blocking the supervisor. Or using send(self(), :init_continue) which doesn't guarantee ordering — a client message could arrive first.
Code example from source (callback spec):
@callback handle_continue(continue_arg, state :: term) ::
{:noreply, new_state}
| {:noreply, new_state, timeout | :hibernate | {:continue, continue_arg}}
| {:stop, reason :: term, new_state}
when new_state: term, continue_arg: term
Usage pattern:
def init(args) do
# Quick setup only — return immediately to unblock supervisor
{:ok, %{config: args, data: nil}, {:continue, :load_data}}
end
@impl true
def handle_continue(:load_data, state) do
# Expensive work happens here, guaranteed before any call/cast
data = expensive_database_query()
{:noreply, %{state | data: data}}
end
Pattern 5: Timeout-Based Idle Shutdown
Source: lib/elixir/lib/gen_server.ex:335-380
What it does: Callbacks can return a timeout value (milliseconds) as the last element of the return tuple. If no message arrives within that time, handle_info(:timeout, state) is called. This enables idle process cleanup.
Why: Prevents resource leaks from idle processes. A process that's not being used can shut itself down gracefully, freeing memory and reducing the supervision tree. Useful for connection pools, caches, and session processes.
Anti-pattern: Using Process.send_after for idle detection — it doesn't reset on activity. The built-in timeout resets every time a message arrives, which is the correct semantic for "idle detection."
Code example from source:
defmodule Counter do
use GenServer
@timeout to_timeout(second: 5)
@impl true
def init(count) do
{:ok, count, @timeout}
end
@impl true
def handle_call(:increment, _from, count) do
new_count = count + 1
{:reply, new_count, new_count, @timeout}
end
@impl true
def handle_info(:timeout, count) do
{:stop, :normal, count}
end
end
Pattern 6: Periodic Work via Process.send_after
Source: lib/elixir/lib/gen_server.ex:298-332 (Periodically example in docs)
What it does: A GenServer schedules periodic work by sending itself a message via Process.send_after in init/1, then rescheduling in handle_info. This creates a self-sustaining periodic loop.
Why: Unlike timeouts (which reset on any message), send_after gives precise control over scheduling intervals. The pattern is self-healing — if the process crashes and restarts, init reschedules automatically. It's simpler than external timer libraries.
Anti-pattern: Using :timer.send_interval — if the GenServer is restarted by its supervisor, the timer from the old process keeps firing into the void (the timer is tied to the process that created it, but send_interval isn't linked to the destination). send_after in handle_info naturally dies with the process.
Code example from source:
defmodule MyApp.Periodically do
use GenServer
def start_link(_) do
GenServer.start_link(__MODULE__, %{})
end
@impl true
def init(state) do
schedule_work()
{:ok, state}
end
@impl true
def handle_info(:work, state) do
# Do the desired work here
schedule_work()
{:noreply, state}
end
defp schedule_work do
Process.send_after(self(), :work, 2 * 60 * 60 * 1000)
end
end
Pattern 7: Call vs Cast Decision (Synchronous vs Asynchronous)
Source: lib/elixir/lib/gen_server.ex:83-90 (docs), lib/elixir/lib/agent.ex:368-378 (Agent.update uses call, Agent.cast uses cast)
What it does: The Elixir team uses call (synchronous) for operations where the client needs confirmation or a return value, and cast (fire-and-forget) only when the client genuinely doesn't care about the outcome.
Why: cast provides no backpressure — if a producer sends casts faster than the GenServer can process them, the mailbox grows unbounded until OOM. call naturally provides backpressure because the caller blocks. The Agent module makes this explicit: Agent.update/3 uses GenServer.call (not cast!) because updates need confirmation of ordering.
Anti-pattern: Using cast for operations that should have backpressure, or using cast when you actually need to know if the operation succeeded. The Agent source proves this — even "fire and forget" Agent.update is a call:
Code example from source (agent.ex):
# Agent.update uses call — NOT cast — for backpressure
@spec update(agent, (state -> state), timeout) :: :ok
def update(agent, fun, timeout \\ 5000) when is_function(fun, 1) do
GenServer.call(agent, {:update, fun}, timeout)
end
# Agent.cast is the explicit fire-and-forget variant
@spec cast(agent, (state -> state)) :: :ok
def cast(agent, fun) when is_function(fun, 1) do
GenServer.cast(agent, {:cast, fun})
end
Pattern 8: Default Callback Implementations with Clear Error Messages
Source: lib/elixir/lib/gen_server.ex:902-993 (__using__ macro)
What it does: use GenServer injects default implementations of handle_call, handle_cast, handle_info, terminate, and code_change. The defaults for handle_call and handle_cast raise with a descriptive error message including the process name. handle_info logs an error. All are defoverridable.
Why: This is defensive programming at the framework level. Instead of getting a cryptic :function_clause error from the Erlang runtime, developers get "attempted to call GenServer #{inspect(proc)} but no handle_call/3 clause was provided". The defaults also mean you only implement the callbacks you actually need.
Anti-pattern: Not implementing handle_info when your process might receive unexpected messages (monitors, nodedown, etc). The default logs a warning but doesn't crash — this is intentional.
Code example from source:
@doc false
def handle_call(msg, _from, state) do
proc =
case Process.info(self(), :registered_name) do
{_, []} -> self()
{_, name} -> name
end
case :erlang.phash2(1, 1) do
0 ->
raise "attempted to call GenServer #{inspect(proc)} but no handle_call/3 clause was provided"
1 ->
{:stop, {:bad_call, msg}, state}
end
end
@doc false
def handle_info(msg, state) do
proc =
case Process.info(self(), :registered_name) do
{_, []} -> self()
{_, name} -> name
end
:logger.error(...)
{:noreply, state}
end
Pattern 9: child_spec/1 Generation and Customization via use Options
Source: lib/elixir/lib/gen_server.ex:900-921, lib/elixir/lib/agent.ex:206-218, lib/elixir/lib/task.ex:282-292
What it does: Each use GenServer/Agent/Task generates a child_spec/1 function with sensible defaults that can be customized via options passed to use. The child spec is a map with :id, :start, :restart, :shutdown, and :type.
Why: Encapsulates supervision configuration within the module itself. Supervisors just need {MyModule, arg} — they don't need to know restart strategies or shutdown timeouts. Different modules have different defaults: GenServer/Agent default to :permanent restart, Task defaults to :temporary.
Anti-pattern: Defining child specs in the supervisor module rather than in the child module. This scatters configuration and makes modules non-portable between supervision trees.
Code example from source:
# GenServer child_spec — restart: :permanent (default)
def child_spec(init_arg) do
default = %{
id: __MODULE__,
start: {__MODULE__, :start_link, [init_arg]}
}
Supervisor.child_spec(default, unquote(Macro.escape(opts)))
end
# Task child_spec — restart: :temporary (tasks don't restart)
def child_spec(arg) do
%{
id: Task,
start: {Task, :start_link, [arg]},
restart: :temporary
}
end
Pattern 10: Agent as Minimal State Wrapper (GenServer Under the Hood)
Source: lib/elixir/lib/agent.ex:1-60 (module docs), lib/elixir/lib/agent.ex:246-250 (implementation)
What it does: Agent is implemented entirely in terms of GenServer.start_link(Agent.Server, fun, options). It's a thin abstraction that provides get/update/get_and_update/cast over GenServer's call/cast.
Why: When your process is purely about state (no complex message handling, no multi-step workflows), Agent removes boilerplate. It's an intentional design choice by the Elixir team: provide the simplest possible stateful process, built on the same foundation.
Anti-pattern: Using Agent for anything that needs custom message handling, multi-step coordination, or where you'd benefit from handle_info (timers, monitors). If you need more than get/update, use GenServer directly.
Code example from source:
# Agent is literally just GenServer with a purpose-built server module
@spec start_link((-> term), GenServer.options()) :: on_start
def start_link(fun, options \\ []) when is_function(fun, 0) do
GenServer.start_link(Agent.Server, fun, options)
end
# Client/server boundary in Agent: expensive work placement matters
# Compute in the agent/server (blocks the agent for all other callers):
def get_something(agent) do
Agent.get(agent, fn state -> do_something_expensive(state) end)
end
# Compute in the agent/client (copies state but doesn't block):
def get_something(agent) do
Agent.get(agent, & &1) |> do_something_expensive()
end
Pattern 11: Name Registration via :via Tuple
Source: lib/elixir/lib/gen_server.ex:1087-1107 (do_start implementation), lib/elixir/lib/gen_server.ex:230-250 (documentation)
What it does: GenServer supports four naming schemes: nil (anonymous), atom (local), {:global, term} (cluster-wide), and {:via, module, term} (pluggable registry). The implementation delegates to :gen.start with the appropriate format.
Why: The :via pattern enables dynamic process naming without atom leaks. Since atoms are never garbage-collected, dynamic names (like per-user or per-session processes) must use {:via, Registry, {registry, key}} to avoid exhausting the atom table.
Anti-pattern: Using String.to_atom("user_#{id}") for dynamic process names. This creates atoms that are never GC'd and will eventually crash the VM.
Code example from source:
# From gen_server.ex do_start/4
defp do_start(link, module, init_arg, options) do
case Keyword.pop(options, :name) do
{nil, opts} ->
:gen.start(:gen_server, link, module, init_arg, opts)
{atom, opts} when is_atom(atom) ->
:gen.start(:gen_server, link, {:local, atom}, module, init_arg, opts)
{{:global, _term} = tuple, opts} ->
:gen.start(:gen_server, link, tuple, module, init_arg, opts)
{{:via, via_module, _term} = tuple, opts} when is_atom(via_module) ->
:gen.start(:gen_server, link, tuple, module, init_arg, opts)
end
end
Pattern 12: GenServer as Anti-Pattern — Don't Use Processes for Code Organization
Source: lib/elixir/lib/gen_server.ex:381-415 ("When (not) to use a GenServer" section)
What it does: The Elixir team explicitly documents that GenServer should NOT be used for code organization. A process must model a runtime property: mutable state, concurrency boundary, or failure isolation.
Why: A GenServer serializes all access through a single process. Using it for stateless computation (like a calculator) creates artificial bottlenecks. Processes have overhead (memory, scheduling, message copying). Functions are free.
Anti-pattern (from the source itself):
# DON'T DO THIS — from gen_server.ex docs
def add(a, b) do
GenServer.call(__MODULE__, {:add, a, b})
end
def handle_call({:add, a, b}, _from, state) do
{:reply, a + b, state}
end
# DO THIS instead:
def add(a, b) do
a + b
end