Files
elixir-patterns/patterns/genserver.md
T
Aaron Weiker 4ea9a884aa docs: idiomatic Elixir and Phoenix patterns with source citations
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.
2026-04-29 22:50:12 -07:00

416 lines
16 KiB
Markdown

# 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:**
```elixir
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:**
```elixir
@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:**
```elixir
# 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):**
```elixir
@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:**
```elixir
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:**
```elixir
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:**
```elixir
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):**
```elixir
# 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:**
```elixir
@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:**
```elixir
# 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:**
```elixir
# 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:**
```elixir
# 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):**
```elixir
# 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
```