docs: add when/when-not to data-transforms
This commit is contained in:
@@ -1,415 +1 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user