Files
Rodin 40f024b477 fix: update drifted source citations to match current upstream
Verified all 17 file:line citations against elixir-lang/elixir HEAD.
Fixed 10 citations where line numbers had shifted due to upstream changes:

- patterns/genserver.md: agent.ex:246 → 279 (start_link spec)
- patterns/process-design.md: task.ex:282 → 327 (child_spec)
- smells/anti-patterns.md: registry_test.exs:28 → 29, gen_server_test.exs:166 → 164,
  test_helper.exs:98 → 99
- smells/common-mistakes.md: registry_test.exs:28 → 29, callbacks.ex:423 → 433,
  task_test.exs:297,305,315,330 → 300,308,316,327,
  supervisor_test.exs:278 → 289, callbacks.ex:277 → 520
2026-05-06 16:33:21 -07:00

47 KiB

GenServer Patterns — From the Elixir Source

Analysis of lib/elixir/lib/gen_server.ex, lib/elixir/lib/agent.ex, and related modules.

Contents

  1. Pattern 1: Client/Server API Separation
  2. Pattern 2: @impl true Annotations on All Callbacks
  3. Pattern 3: Guard-Protected start_link
  4. Pattern 4: handle_continue for Post-Init Work
  5. Pattern 5: Timeout-Based Idle Shutdown
  6. Pattern 6: Periodic Work via Process.send_after
  7. Pattern 7: Call vs Cast Decision (Synchronous vs Asynchronous)
  8. Pattern 8: Default Callback Implementations with Clear Error Messages
  9. Pattern 9: child_spec/1 Generation and Customization via use Options
  10. Pattern 10: Agent as Minimal State Wrapper (GenServer Under the Hood)
  11. Pattern 11: Name Registration via :via Tuple
  12. Pattern 12: GenServer as Anti-Pattern — Don't Use Processes for Code Organization

Pattern 1: Client/Server API Separation

Source: lib/elixir/lib/gen_server.ex#L101 (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

When to Use

Triggers: You're writing a GenServer that other modules will interact with. You have more than one message type. The module will be maintained by people who didn't write it.

Example — before:

# Other modules call GenServer directly with raw messages
GenServer.call(Cache, {:get, key})
GenServer.cast(Cache, {:put, key, value})
GenServer.call(Cache, {:delete, key})

Example — after:

# Clean client API hides the message protocol
defmodule Cache do
  use GenServer

  # Client
  def get(key), do: GenServer.call(__MODULE__, {:get, key})
  def put(key, value), do: GenServer.cast(__MODULE__, {:put, key, value})
  def delete(key), do: GenServer.call(__MODULE__, {:delete, key})

  # Server
  @impl true
  def handle_call({:get, key}, _from, state), do: {:reply, Map.get(state, key), state}
  @impl true
  def handle_cast({:put, key, value}, state), do: {:noreply, Map.put(state, key, value)}
  @impl true
  def handle_call({:delete, key}, _from, state), do: {:reply, :ok, Map.delete(state, key)}
end

When NOT to Use

Don't use this when: You're writing a one-off GenServer used only internally by its own supervision tree (e.g., a private worker started by a DynamicSupervisor where no external module ever sends it messages directly).

Over-application example:

# Overkill: wrapping a single internal message with a client function nobody else calls
defmodule MyApp.InternalWorker do
  use GenServer

  # "Client API" that only init/1 of THIS module calls
  def do_internal_thing(pid), do: GenServer.cast(pid, :do_thing)

  @impl true
  def init(_) do
    do_internal_thing(self())  # calling your own wrapper?
    {:ok, %{}}
  end

  @impl true
  def handle_cast(:do_thing, state), do: {:noreply, state}
end

Better alternative: If the only caller is the module itself (self-messages), use send/2 or handle_continue directly — no wrapper needed.

Why: The client/server separation exists to decouple external callers from message formats. If there are no external callers, the abstraction adds noise without value.


Pattern 2: @impl true Annotations on All Callbacks

Source: lib/elixir/lib/gen_server.ex#L41 (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

When to Use

Triggers: Any module that implements a behaviour (GenServer, Supervisor, Plug, Phoenix.LiveView, custom behaviours). You have a mix of callback functions and helper functions in the same module.

Example — before:

defmodule MyWorker do
  use GenServer

  def init(state), do: {:ok, state}
  def handle_call(:status, _from, state), do: {:reply, state, state}
  def format_status(state), do: "Running: #{inspect(state)}"  # Is this a callback?
end

Example — after:

defmodule MyWorker do
  use GenServer

  @impl true
  def init(state), do: {:ok, state}

  @impl true
  def handle_call(:status, _from, state), do: {:reply, state, state}

  # Clearly NOT a callback — just a private helper
  defp format_status(state), do: "Running: #{inspect(state)}"
end

When NOT to Use

Don't use this when: You're implementing functions that happen to share a name with a callback but are NOT implementing that behaviour's contract (extremely rare in practice — this pattern should be used almost universally).

Over-application example:

# This is fine but misleading — the module doesn't use GenServer
defmodule MyLib.Utils do
  @impl true  # ERROR: no behaviour declared!
  def init(config), do: Map.merge(@defaults, config)
end

Better alternative: Only use @impl true when the module declares use GenServer (or @behaviour SomeBehaviour). If your function just happens to be named init, that's fine — don't annotate it.

Why: @impl true only makes sense in the context of a declared behaviour. Using it without one causes a compiler warning, not a benefit.

Minimal Callback Annotation

Source: lib/elixir/lib/module.ex#L72 (@impl documentation)

What it does: Once @impl true is on a callback, two things become redundant:

  1. Repeating @impl true on subsequent clauses — the annotation applies to the function as a whole, not individual clauses. All clauses inherit it from the first.
  2. Adding @spec — the behaviour's @callback already defines the type contract. Dialyzer uses the callback spec to check implementations, so a redundant @spec creates a second source of truth that can drift.

Why: The behaviour owns the contract. Adding @spec init(term()) :: {:ok, map()} on a GenServer callback just restates @callback init(init_arg :: term) :: {:ok, state} | ... with less information. Repeating @impl true on every clause is noise that misleads readers into thinking each clause is a separate function. The minimal annotation communicates "this is a callback, the behaviour defines the contract."

Anti-pattern:

# BAD — redundant @spec and @impl on every clause
@spec handle_call(term(), GenServer.from(), map()) :: {:reply, term(), map()}
@impl true
def handle_call({:get, key}, _from, state) do
  {:reply, Map.get(state, key), state}
end

@impl true
def handle_call({:keys}, _from, state) do
  {:reply, Map.keys(state), state}
end

@impl true
def handle_call({:size}, _from, state) do
  {:reply, map_size(state), state}
end

Example — after:

@impl true
def handle_call({:get, key}, _from, state) do
  {:reply, Map.get(state, key), state}
end

def handle_call({:keys}, _from, state) do
  {:reply, Map.keys(state), state}
end

def handle_call({:size}, _from, state) do
  {:reply, map_size(state), state}
end

When NOT to apply this:

  • Non-consecutive clauses: If clauses of the same callback are separated by other functions, the compiler may not associate them. Keep multi-clause callbacks grouped together.
  • Intentionally narrower types: When the implementation accepts a narrower type than the callback declares, a more specific @spec documents that constraint:
# Justified — documents that this init/1 only accepts keyword lists,
# not arbitrary term() as the callback permits
@spec init(keyword()) :: {:ok, Config.t()}
@impl true
def init(opts) when is_list(opts) do
  {:ok, Config.new!(opts)}
end

If the @spec would be identical to (or wider than) the @callback, omit it. If it's meaningfully narrower, keep it.


Source: lib/elixir/lib/gen_server.ex#L101 and lib/elixir/lib/agent.ex#L28

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:279
@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

When to Use

Triggers: Your start_link accepts arguments that have a specific expected type or shape. The GenServer is part of a supervision tree where a bad argument would cascade into confusing crash reports.

Example — before:

def start_link(opts) do
  GenServer.start_link(__MODULE__, opts)
end

# Crashes deep in init with: ** (KeyError) key :port not found
@impl true
def init(opts) do
  {:ok, socket} = :gen_tcp.listen(opts[:port], [])
  {:ok, %{socket: socket}}
end

Example — after:

def start_link(opts) when is_list(opts) do
  unless Keyword.has_key?(opts, :port), do: raise(ArgumentError, ":port is required")
  GenServer.start_link(__MODULE__, opts, Keyword.take(opts, [:name]))
end

When NOT to Use

Don't use this when: The argument is already validated by the supervision tree (e.g., hardcoded in children list), or when the argument type is so generic that a guard adds no value.

Over-application example:

# Pointless guard — any term is valid state
def start_link(initial_state) when is_map(initial_state) or is_list(initial_state) or is_integer(initial_state) do
  GenServer.start_link(__MODULE__, initial_state)
end

Better alternative: If any term is valid initial state, just accept it without a guard. Guards should encode meaningful constraints, not enumerate all possible types.

Why: Guards that don't actually constrain anything give false confidence. The goal is to catch real mistakes early, not to prove you know about guards.


Pattern 4: handle_continue for Post-Init Work

Source: lib/elixir/lib/gen_server.ex#L520 (callback spec), lib/elixir/lib/gen_server.ex#L714 (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

When to Use

Triggers: Your init/1 needs to do work that takes more than a few milliseconds (network calls, file I/O, large computations). Your process is part of a supervision tree where blocking init delays other children. You need guaranteed execution order before any client messages arrive.

Example — before:

@impl true
def init(config) do
  # This blocks the supervisor for 2+ seconds
  {:ok, conn} = Database.connect(config.db_url)
  schema = Database.load_schema(conn)
  cache = warm_cache(conn, schema)
  {:ok, %{conn: conn, schema: schema, cache: cache}}
end

Example — after:

@impl true
def init(config) do
  {:ok, %{config: config, conn: nil, ready: false}, {:continue, :connect}}
end

@impl true
def handle_continue(:connect, state) do
  {:ok, conn} = Database.connect(state.config.db_url)
  schema = Database.load_schema(conn)
  cache = warm_cache(conn, schema)
  {:noreply, %{state | conn: conn, schema: schema, cache: cache, ready: true}}
end

When NOT to Use

Don't use this when: Your init is already fast (< 1ms), or when the process MUST be fully initialized before it can handle any messages (and callers expect this).

Over-application example:

@impl true
def init(count) do
  # This is instant — no reason for handle_continue
  {:ok, count, {:continue, :setup}}
end

@impl true
def handle_continue(:setup, count) do
  # "setup" is just... assigning a variable
  {:noreply, count + 1}
end

Better alternative: Just do the work in init/1 directly: {:ok, count + 1}. Reserve handle_continue for genuinely expensive operations.

Why: handle_continue adds complexity (another callback, intermediate state where ready: false). If init is already fast, you're adding indirection for no benefit and making the code harder to follow.


Pattern 5: Timeout-Based Idle Shutdown

Source: lib/elixir/lib/gen_server.ex#L335

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

When to Use

Triggers: You have dynamically-spawned processes (per-user sessions, per-connection workers, cache entries) that should clean up when idle. The process holds resources (memory, connections, file handles) that shouldn't persist indefinitely. You want automatic backpressure — fewer active processes when load drops.

Example — before:

# Session processes live forever, even if the user left hours ago
defmodule UserSession do
  use GenServer

  @impl true
  def init(user_id) do
    {:ok, load_user_data(user_id)}
  end
  # ... never shuts down, memory grows forever
end

Example — after:

defmodule UserSession do
  use GenServer
  @idle_timeout :timer.minutes(30)

  @impl true
  def init(user_id) do
    {:ok, load_user_data(user_id), @idle_timeout}
  end

  @impl true
  def handle_call(:get_profile, _from, state) do
    {:reply, state.profile, state, @idle_timeout}
  end

  @impl true
  def handle_info(:timeout, state) do
    {:stop, :normal, state}
  end
end

When NOT to Use

Don't use this when: The process is a singleton (only one instance, always needed). The process does periodic work that resets the timeout unintentionally. You need the timeout to fire at exact intervals regardless of activity (use Process.send_after instead).

Over-application example:

defmodule MyApp.DatabasePool do
  use GenServer
  @timeout :timer.minutes(5)

  @impl true
  def init(_) do
    {:ok, connect_to_db(), @timeout}
  end

  @impl true
  def handle_info(:timeout, state) do
    # Bad: shutting down the DB pool because it was "idle"
    # In reality, no queries for 5 min is normal at night
    {:stop, :normal, state}
  end
end

Better alternative: Singleton infrastructure processes (DB pools, PubSub, registries) should be :permanent and never self-terminate. Idle shutdown is for ephemeral, per-entity processes.

Why: The timeout resets on ANY message, including internal ones (monitors, system messages). For singleton processes, self-termination causes supervisor restarts and connection churn. Idle shutdown is a resource-management pattern for dynamic process pools, not for core infrastructure.


Pattern 6: Periodic Work via Process.send_after

Source: lib/elixir/lib/gen_server.ex#L298 (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

When to Use

Triggers: You need work to happen at regular intervals (health checks, cache expiry sweeps, metrics collection, heartbeats). The interval should be consistent regardless of other message traffic. The work is tied to the lifecycle of a specific process.

Example — before:

# Using :timer.send_interval — breaks on restart
defmodule MetricsCollector do
  use GenServer

  @impl true
  def init(state) do
    :timer.send_interval(30_000, :collect)
    {:ok, state}
  end

  # After supervisor restart, old timer still fires + new timer starts = double collection
end

Example — after:

defmodule MetricsCollector do
  use GenServer
  @interval :timer.seconds(30)

  @impl true
  def init(state) do
    schedule_collection()
    {:ok, state}
  end

  @impl true
  def handle_info(:collect, state) do
    metrics = gather_metrics()
    push_to_backend(metrics)
    schedule_collection()
    {:noreply, %{state | last_collected: DateTime.utc_now()}}
  end

  defp schedule_collection, do: Process.send_after(self(), :collect, @interval)
end

When NOT to Use

Don't use this when: The "periodic" work is actually idle detection (use GenServer timeout instead). You need sub-millisecond precision (use :erlang.start_timer with absolute references). The periodic task is independent of any process state (use a simple Task or external scheduler).

Over-application example:

defmodule MyApp.DailyReport do
  use GenServer

  @impl true
  def init(_) do
    schedule_report()
    {:ok, %{}}
  end

  @impl true
  def handle_info(:generate_report, state) do
    # Runs once per day, holds no state, doesn't need to be a GenServer
    Reports.generate_daily()
    schedule_report()
    {:noreply, state}
  end

  defp schedule_report, do: Process.send_after(self(), :generate_report, :timer.hours(24))
end

Better alternative: Use a dedicated job scheduler (Oban, Quantum) for cron-like work. Or a simple Task supervised by Task.Supervisor. A GenServer that holds no meaningful state and just fires a function periodically is over-engineered.

Why: Process.send_after drifts over time (interval doesn't account for execution time). It doesn't handle "run at 9am daily" semantics. A stateless periodic GenServer is just a worse cron. Use the right tool for the job.


Pattern 7: Call vs Cast Decision (Synchronous vs Asynchronous)

Source: lib/elixir/lib/gen_server.ex#L83 (docs), lib/elixir/lib/agent.ex#L368 (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

When to Use

Triggers: You're deciding between call and cast for a new API function. The operation modifies state that callers depend on. You need ordering guarantees. The producer could potentially overwhelm the consumer.

Example — before:

# Using cast for writes — no backpressure, no error feedback
def enqueue(queue, item) do
  GenServer.cast(queue, {:enqueue, item})
end
# Producer sends 1M items/sec, GenServer processes 100/sec → OOM

Example — after:

# Using call — producer is naturally throttled
def enqueue(queue, item) do
  GenServer.call(queue, {:enqueue, item})
end
# Producer blocks until GenServer processes each item → natural backpressure

When NOT to Use

Don't use this when: You genuinely don't need confirmation AND the producer rate is naturally bounded (e.g., logging telemetry events from a finite number of processes, or broadcasting notifications where delivery is best-effort).

Over-application example:

# Overkill: using call for fire-and-forget telemetry
def report_metric(collector, metric) do
  # The caller blocks waiting for :ok, but doesn't use the reply for anything
  # If the collector is slow, it slows down the entire hot path
  GenServer.call(collector, {:report, metric})
end

Better alternative: For telemetry/logging where you have bounded producers and losing a message isn't catastrophic, cast (or even :telemetry.execute/3) is appropriate. The key question is: "Does the caller's correctness depend on this operation completing?"

Why: call adds latency to the caller. For hot paths where the reply is always :ok and the caller ignores it, you're paying synchronization cost for nothing. But this is the exception — default to call and only use cast when you've consciously evaluated the tradeoffs.


Pattern 8: Default Callback Implementations with Clear Error Messages

Source: lib/elixir/lib/gen_server.ex#L902 (__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

When to Use

Triggers: You're relying on use GenServer (which you should always do rather than @behaviour GenServer alone). You have a GenServer that uses monitors, timers, or receives system messages you want to handle explicitly rather than getting log noise.

Example — before:

defmodule ConnectionManager do
  use GenServer

  @impl true
  def init(config) do
    ref = Process.monitor(config.target_pid)
    {:ok, %{ref: ref, target: config.target_pid}}
  end

  # Forgot to handle :DOWN — default handle_info logs error but doesn't reconnect
  # Process just silently loses track of the monitored process
end

Example — after:

defmodule ConnectionManager do
  use GenServer

  @impl true
  def init(config) do
    ref = Process.monitor(config.target_pid)
    {:ok, %{ref: ref, target: config.target_pid}}
  end

  @impl true
  def handle_info({:DOWN, ref, :process, pid, reason}, %{ref: ref} = state) do
    # Explicitly handle the monitor message
    {:noreply, reconnect(state)}
  end

  @impl true
  def handle_info(_unexpected, state) do
    # Catch-all: don't let the default log noise obscure real issues
    {:noreply, state}
  end
end

When NOT to Use

Don't use this when: N/A — this pattern is built into use GenServer and is always active. The real question is whether to override the defaults.

Over-application example:

# Unnecessary: explicitly reimplementing the exact same default behavior
@impl true
def handle_info(msg, state) do
  require Logger
  Logger.error("Unexpected message: #{inspect(msg)}")
  {:noreply, state}
end

Better alternative: Only override defaults when you need different behavior (like silencing known-harmless messages, or handling specific info messages). If the default behavior is what you want, leave it alone.

Why: Reimplementing defaults adds code that must be maintained and tested. The framework defaults are well-tested and include process-name introspection. Override when you need different behavior, not to prove you're thorough.


Pattern 9: child_spec/1 Generation and Customization via use Options

Source: lib/elixir/lib/gen_server.ex#L900, lib/elixir/lib/agent.ex#L206, lib/elixir/lib/task.ex#L282

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

When to Use

Triggers: Your module will be placed in a supervision tree. You need non-default restart strategy, shutdown timeout, or id. You want the module to be self-describing (portable between supervisors).

Example — before:

# Child spec defined in the supervisor — not portable
defmodule MyApp.Supervisor do
  use Supervisor

  def init(_) do
    children = [
      %{
        id: MyApp.Cache,
        start: {MyApp.Cache, :start_link, [[ttl: 300]]},
        restart: :transient,
        shutdown: 30_000
      }
    ]
    Supervisor.init(children, strategy: :one_for_one)
  end
end

Example — after:

# Child spec encapsulated in the module itself
defmodule MyApp.Cache do
  use GenServer, restart: :transient, shutdown: 30_000

  def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  # ...
end

# Supervisor is clean
defmodule MyApp.Supervisor do
  use Supervisor

  def init(_) do
    children = [{MyApp.Cache, [ttl: 300]}]
    Supervisor.init(children, strategy: :one_for_one)
  end
end

When NOT to Use

Don't use this when: You have multiple instances of the same module with different restart/shutdown requirements in different supervision trees (rare but possible).

Over-application example:

# Overriding child_spec when defaults are fine
defmodule SimpleCounter do
  use GenServer

  # Unnecessary: these ARE the defaults
  def child_spec(arg) do
    %{
      id: __MODULE__,
      start: {__MODULE__, :start_link, [arg]},
      restart: :permanent,
      shutdown: 5000,
      type: :worker
    }
  end
end

Better alternative: Just use GenServer — the generated child_spec/1 already has these exact defaults. Only override or pass options when you need something different.

Why: Manually writing child_spec/1 that matches the auto-generated one is dead code. It adds maintenance burden and risks diverging from the actual defaults if they ever change.


Pattern 10: Agent as Minimal State Wrapper (GenServer Under the Hood)

Source: lib/elixir/lib/agent.ex#L1 (module docs), lib/elixir/lib/agent.ex#L246 (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

When to Use

Triggers: You need shared mutable state. The access pattern is simple get/update. No timers, monitors, or custom message handling needed. You want the minimum viable stateful process.

Example — before:

# Full GenServer for something that's just a counter
defmodule MyApp.Counter do
  use GenServer

  def start_link(initial), do: GenServer.start_link(__MODULE__, initial, name: __MODULE__)
  def value, do: GenServer.call(__MODULE__, :get)
  def increment, do: GenServer.call(__MODULE__, :increment)

  @impl true
  def init(n), do: {:ok, n}
  @impl true
  def handle_call(:get, _from, n), do: {:reply, n, n}
  @impl true
  def handle_call(:increment, _from, n), do: {:reply, n + 1, n + 1}
end

Example — after:

# Agent: same semantics, less code
defmodule MyApp.Counter do
  use Agent

  def start_link(initial), do: Agent.start_link(fn -> initial end, name: __MODULE__)
  def value, do: Agent.get(__MODULE__, & &1)
  def increment, do: Agent.get_and_update(__MODULE__, fn n -> {n + 1, n + 1} end)
end

When NOT to Use

Don't use this when: You need handle_info (timers, monitors, PubSub subscriptions). You need multi-step atomic operations beyond get_and_update. You need handle_continue for init. The state is large and you don't want full-state copies on every access.

Over-application example:

# Agent trying to do things Agents can't
defmodule MyApp.ConnectionPool do
  use Agent

  def start_link(_) do
    Agent.start_link(fn ->
      # Can't use handle_continue — this blocks the supervisor
      conns = Enum.map(1..10, fn _ -> Database.connect!() end)
      %{connections: conns, checked_out: []}
    end, name: __MODULE__)
  end

  # Can't monitor checked-out connections for :DOWN
  # Can't do periodic health checks (no handle_info)
  # Can't implement checkout timeout logic
end

Better alternative: Use a GenServer (or a purpose-built pool library like NimblePool or Poolboy). Agent is for trivial state. The moment you need lifecycle management, monitoring, or timers, you've outgrown Agent.

Why: Agent is intentionally limited. Fighting against those limits means you need GenServer. The Elixir team designed Agent for the "90% case of simple state" — not as a general-purpose process abstraction.


Pattern 11: Name Registration via :via Tuple

Source: lib/elixir/lib/gen_server.ex#L1087 (do_start implementation), lib/elixir/lib/gen_server.ex#L230 (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

When to Use

Triggers: You're spawning processes dynamically (per-user, per-session, per-entity). The number of possible process names is unbounded or user-controlled. You need to look up processes by a dynamic key (user ID, room name, device serial).

Example — before:

# DANGEROUS: atom leak — each new user creates a permanent atom
defmodule UserSession do
  def start_link(user_id) do
    name = String.to_atom("user_session_#{user_id}")
    GenServer.start_link(__MODULE__, user_id, name: name)
  end

  def get_session(user_id) do
    GenServer.call(String.to_atom("user_session_#{user_id}"), :get)
  end
end

Example — after:

# Safe: uses Registry — dynamic keys, no atom leak
defmodule UserSession do
  def start_link(user_id) do
    GenServer.start_link(__MODULE__, user_id, name: via(user_id))
  end

  def get_session(user_id) do
    GenServer.call(via(user_id), :get)
  end

  defp via(user_id), do: {:via, Registry, {MyApp.SessionRegistry, user_id}}
end

When NOT to Use

Don't use this when: You have a fixed, small number of singleton processes (use plain atom names). The process doesn't need to be looked up by name (just pass the pid). You're in a single-node system with < 100 named processes.

Over-application example:

# Overkill: Registry for a singleton
defmodule MyApp.Config do
  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: {:via, Registry, {MyApp.Registry, :config}})
  end
end

Better alternative: name: MyApp.Config — it's a singleton, atom names are fine. You'll never have more than one, so atom table pressure is zero.

Why: :via adds a Registry dependency and lookup overhead. For singletons, a plain atom name is simpler, faster, and perfectly safe. Reserve :via for the use case it was designed for: dynamic, potentially unbounded process populations.


Pattern 12: GenServer as Anti-Pattern — Don't Use Processes for Code Organization

Source: lib/elixir/lib/gen_server.ex#L381 ("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

When to Use

Triggers: You catch yourself asking "should this be a GenServer?" Ask instead: "Does this need mutable state that outlives a single function call? Does this need to serialize access? Does this need independent failure isolation?" If yes to any, use GenServer.

Example — before:

# Stateless computation forced into a process
defmodule MyApp.Calculator do
  use GenServer

  def start_link(_), do: GenServer.start_link(__MODULE__, nil, name: __MODULE__)
  def add(a, b), do: GenServer.call(__MODULE__, {:add, a, b})
  def multiply(a, b), do: GenServer.call(__MODULE__, {:multiply, a, b})

  @impl true
  def init(_), do: {:ok, nil}
  @impl true
  def handle_call({:add, a, b}, _from, _), do: {:reply, a + b, nil}
  @impl true
  def handle_call({:multiply, a, b}, _from, _), do: {:reply, a * b, nil}
end

Example — after:

# Pure functions — no process needed
defmodule MyApp.Calculator do
  def add(a, b), do: a + b
  def multiply(a, b), do: a * b
end

When NOT to Use

Don't use this when: N/A — this IS the "when not to use GenServer" pattern. The entire point is to remind you that not everything needs to be a process.

Over-application example:

# Taking this advice too far: avoiding GenServer when you actually need one
defmodule MyApp.RateLimiter do
  # "Just use functions!" — but where does the counter live?
  def allow?(user_id) do
    # Can't track request counts without state...
    # ETS? Still needs a process to own the table.
    # This DOES need a GenServer (or ETS with a owner process).
    true
  end
end

Better alternative: If you need mutable state (counters, connections, caches), you need a process. The rule is "don't use processes for code organization" — not "never use processes." A rate limiter with shared counters is a legitimate use of GenServer (or ETS owned by a GenServer).

Why: The pattern cuts both ways. Over-using GenServer creates bottlenecks. Under-using it means reinventing state management poorly. The litmus test: does the state need to survive between function calls? Does access need serialization? If yes, you need a process.

Decision Tree

  • If other modules will interact with your GenServer → define a client API wrapping call/cast (Pattern 1)
  • If implementing any behaviour callback → annotate with @impl true (Pattern 2)
  • If start_link accepts arguments with a specific expected shape → add guards for fail-fast validation (Pattern 3)
  • If init/1 does expensive work (DB, network, cache warming) → split into fast init + handle_continue (Pattern 4)
  • If the process is ephemeral (per-user, per-session) and should clean up when idle → use timeout-based idle shutdown (Pattern 5)
  • If you need work at regular intervals regardless of message traffic → use Process.send_after self-scheduling loop (Pattern 6)
  • If the caller needs confirmation or backpressure → use call; only use cast for genuine fire-and-forget (Pattern 7)
  • If the process needs non-default restart/shutdown behavior → customize via use GenServer options (Pattern 9)
  • If the process is purely about state (no custom messages, no timers) → use Agent instead of GenServer (Pattern 10)
  • If spawning processes dynamically with unbounded names → use {:via, Registry, ...} to avoid atom leaks (Pattern 11)
  • If the operation is stateless pure computation → don't use a GenServer at all, use a plain function (Pattern 12)