Files
elixir-patterns/patterns/behaviours.md
T
aweiker 10218813d3 docs: backfill TOC + decision trees, fix review findings
- Add ## Contents and ## Decision Tree to all 10 existing pattern files
- Fix embed_as/1 semantics inversion in types.md (:self → :dump)
- Fix fabricated __meta__.changes reference in changesets.md
- Fix default primary key type (:integer → :id) in schemas.md
- Combine @impl subsections into single "Minimal Callback Annotation"
2026-05-01 22:13:35 -07:00

705 lines
23 KiB
Markdown

# Behaviour Patterns
How behaviours are designed, implemented, and used in Elixir core and Phoenix.
## Contents
1. [Behaviour Definition with `@callback`](#1-behaviour-definition-with-callback)
2. [`@optional_callbacks` for Extensibility](#2-optional_callbacks-for-extensibility)
3. [`@behaviour` Declaration in `__using__`](#3-behaviour-declaration-in-__using__)
4. [Default Implementations via `defoverridable`](#4-default-implementations-via-defoverridable)
5. [Phoenix Channel: Behaviour + Process + Protocol](#5-phoenix-channel-behaviour--process--protocol)
6. [Callback Documentation Pattern](#6-callback-documentation-pattern)
7. [Phoenix.Endpoint: Behaviour as Interface Contract](#7-phoenixendpoint-behaviour-as-interface-contract)
---
## 1. Behaviour Definition with `@callback`
**Source:** [lib/elixir/lib/gen_server.ex#L577](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/elixir/lib/gen_server.ex#L577) (all callback definitions)
```elixir
@callback init(init_arg :: term) ::
{:ok, state}
| {:ok, state, timeout | :hibernate | {:continue, continue_arg}}
| :ignore
| {:stop, reason :: any}
@callback handle_call(request :: term, from, state :: term) ::
{:reply, reply, new_state}
| {:noreply, new_state}
| {:stop, reason, reply, new_state}
| {:stop, reason, new_state}
when reply: term, new_state: term, reason: term
```
**Why:** Callbacks with full type unions document every valid return. Named parameters (`init_arg`, `request`, `state`) serve as documentation. The `when` clause defines type variables used across the union.
**Anti-pattern:** Defining callbacks with `@callback handle_call(term, term, term) :: term` — provides zero guidance to implementors.
### When to Use
**Triggers:**
- You're defining a contract that multiple modules will implement differently
- You want compile-time guarantees that implementors provide required functions
- The return type has multiple valid shapes that implementors must choose between
**Example — before:**
```elixir
# No formal contract — just "convention" in a README
defmodule MyApp.PaymentGateway do
def charge(amount, card), do: raise "implement me"
end
```
**Example — after:**
```elixir
defmodule MyApp.PaymentGateway do
@callback charge(amount :: pos_integer(), card :: card_token()) ::
{:ok, transaction_id :: String.t()}
| {:declined, reason :: String.t()}
| {:error, :network_timeout | :invalid_card}
when card_token: String.t()
end
```
### When NOT to Use
**Don't use this when:**
- There's only one implementation and no plans for more (just use a module)
- The "contract" is so simple it's a single function with one return type — a protocol or simple module works better
- You need runtime dispatch based on data type (use protocols instead)
**Over-application example:**
```elixir
defmodule MyApp.Config do
@callback get(key :: atom()) :: term()
@callback put(key :: atom(), value :: term()) :: :ok
end
# Only ever one implementation:
defmodule MyApp.Config.Env do
@behaviour MyApp.Config
def get(key), do: Application.get_env(:my_app, key)
def put(key, value), do: Application.put_env(:my_app, key, value)
end
```
**Better alternative:**
```elixir
# Just a module — no behaviour ceremony needed for a singleton
defmodule MyApp.Config do
def get(key), do: Application.get_env(:my_app, key)
def put(key, value), do: Application.put_env(:my_app, key, value)
end
```
**Why:** Behaviours add value through polymorphism — multiple implementations behind one contract. A behaviour with exactly one implementation is indirection without benefit. Add the behaviour when the second implementation arrives.
---
## 2. `@optional_callbacks` for Extensibility
**Source:** [lib/phoenix/channel.ex#L442](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/phoenix/channel.ex#L442)
```elixir
@optional_callbacks handle_in: 3,
handle_out: 3,
handle_info: 2,
handle_call: 3,
handle_cast: 2,
code_change: 3,
terminate: 2
```
**Why:** Only `join/3` is required for a Channel. Everything else has sensible defaults. This keeps the minimum implementation surface small — a Channel that just joins and broadcasts needs only one function.
**Anti-pattern:** Making all callbacks required when most have reasonable defaults — forces implementors to write boilerplate they don't need.
### When to Use
**Triggers:**
- Your behaviour has callbacks where most implementors will use a default (e.g., `terminate/2`, `code_change/3`)
- You want a minimal "get started" experience — implement one function, everything else works
- Some callbacks are only needed for advanced use cases
**Example — before:**
```elixir
defmodule MyApp.Worker do
@callback init(args :: term()) :: {:ok, state :: term()}
@callback handle_task(task :: term(), state :: term()) :: {:ok, state :: term()}
@callback on_error(error :: term(), state :: term()) :: {:ok, state :: term()}
@callback on_shutdown(reason :: term(), state :: term()) :: :ok
end
# Implementors MUST define all 4, even if on_error and on_shutdown are no-ops
```
**Example — after:**
```elixir
defmodule MyApp.Worker do
@callback init(args :: term()) :: {:ok, state :: term()}
@callback handle_task(task :: term(), state :: term()) :: {:ok, state :: term()}
@callback on_error(error :: term(), state :: term()) :: {:ok, state :: term()}
@callback on_shutdown(reason :: term(), state :: term()) :: :ok
@optional_callbacks on_error: 2, on_shutdown: 2
end
```
### When NOT to Use
**Don't use this when:**
- The callback is essential to correctness (if skipping it would break the system, it's required)
- Every implementor WILL need to customize the behavior (making it optional hides a real requirement)
- You have only 1-2 callbacks total — if they're all optional, why is it a behaviour?
**Over-application example:**
```elixir
defmodule MyApp.Serializer do
@callback encode(term()) :: {:ok, binary()} | {:error, term()}
@callback decode(binary()) :: {:ok, term()} | {:error, term()}
@optional_callbacks encode: 1, decode: 1 # Both optional?!
end
```
**Better alternative:**
```elixir
defmodule MyApp.Serializer do
@callback encode(term()) :: {:ok, binary()} | {:error, term()}
@callback decode(binary()) :: {:ok, term()} | {:error, term()}
# Both are required — a serializer that can't encode or decode isn't a serializer
end
```
**Why:** If ALL callbacks are optional, the behaviour provides no compile-time guarantees. At least one callback should be required to justify the behaviour's existence. Optional callbacks are for extensions, not the core contract.
---
## 3. `@behaviour` Declaration in `__using__`
**Source:** [lib/phoenix/channel.ex#L450](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/phoenix/channel.ex#L450)
```elixir
defmacro __using__(opts \\ []) do
quote do
opts = unquote(opts)
@behaviour unquote(__MODULE__)
@on_definition unquote(__MODULE__)
@before_compile unquote(__MODULE__)
...
end
end
```
**Source:** [lib/elixir/lib/gen_server.ex#L836](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/elixir/lib/gen_server.ex#L836)
```elixir
quote location: :keep, bind_quoted: [opts: opts] do
@behaviour GenServer
...
end
```
**Why:** Setting `@behaviour` inside `use` means users get compile-time warnings about missing callbacks automatically. They don't need to know about the behaviour mechanism — `use Phoenix.Channel` handles it.
**Anti-pattern:** Requiring users to manually add both `use MyModule` AND `@behaviour MyModule`.
### When to Use
**Triggers:**
- Your behaviour requires boilerplate that every implementor would write identically
- You want `use MyBehaviour` to "just work" with compile-time callback verification
- The behaviour has associated module attributes, process setup, or struct definitions
**Example — before:**
```elixir
# Users must remember both steps
defmodule MyWorker do
@behaviour MyApp.Worker
# Easy to forget @behaviour and lose compile-time checks
def init(_), do: {:ok, %{}}
end
```
**Example — after:**
```elixir
defmodule MyApp.Worker do
defmacro __using__(_opts) do
quote do
@behaviour MyApp.Worker
# Compile-time checks are automatic
end
end
end
defmodule MyWorker do
use MyApp.Worker
def init(_), do: {:ok, %{}}
end
```
### When NOT to Use
**Don't use this when:**
- The behaviour has no associated boilerplate — a bare `@behaviour` declaration is sufficient
- You're creating a `use` macro that only sets `@behaviour` and nothing else (unnecessary indirection)
- The module being "used" doesn't define callbacks (it's a utility, not a behaviour)
**Over-application example:**
```elixir
defmodule MyApp.Formatter do
@callback format(term()) :: String.t()
defmacro __using__(_opts) do
quote do
@behaviour MyApp.Formatter
# That's it — nothing else generated
end
end
end
```
**Better alternative:**
```elixir
defmodule MyApp.Formatter do
@callback format(term()) :: String.t()
end
# Users just add @behaviour directly — simpler, more explicit
defmodule HtmlFormatter do
@behaviour MyApp.Formatter
def format(data), do: ...
end
```
**Why:** `use` implies "this macro generates code for you." If it only sets `@behaviour`, the indirection hides what's happening without saving any work. Use `use` when there's actual code generation; use bare `@behaviour` when there isn't.
---
## 4. Default Implementations via `defoverridable`
**Source:** [lib/elixir/lib/gen_server.ex#L849](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/elixir/lib/gen_server.ex#L849)
```elixir
def child_spec(init_arg) do
default = %{
id: __MODULE__,
start: {__MODULE__, :start_link, [init_arg]}
}
Supervisor.child_spec(default, unquote(Macro.escape(opts)))
end
defoverridable child_spec: 1
```
**Why:** `defoverridable` provides a working default that users CAN customize but don't HAVE to. The generated function works for the 90% case. The 10% can override it.
**Anti-pattern:** Not using `defoverridable` — users who need custom behavior must bypass the `use` macro entirely.
### When to Use
**Triggers:**
- The 90% case has an obvious default implementation (e.g., `child_spec/1`, `terminate/2`)
- You want users to opt-in to customization rather than requiring it
- The default is non-trivial enough that users shouldn't have to copy-paste it
**Example — before:**
```elixir
defmacro __using__(_opts) do
quote do
@behaviour MyApp.Plugin
# No default — every plugin must implement format_output/1
# even though 90% just want to call inspect()
end
end
```
**Example — after:**
```elixir
defmacro __using__(_opts) do
quote do
@behaviour MyApp.Plugin
def format_output(data), do: inspect(data, pretty: true)
defoverridable format_output: 1
end
end
```
### When NOT to Use
**Don't use this when:**
- The default implementation would be wrong for most cases (forces users to override = same as required)
- The function is the core purpose of the behaviour (e.g., `handle_call` in GenServer has no default because the POINT is to define it)
- You're providing a default that silently swallows errors or does nothing
**Over-application example:**
```elixir
defmacro __using__(_opts) do
quote do
@behaviour MyApp.EventHandler
def handle_event(_event, state), do: {:ok, state}
defoverridable handle_event: 2
end
end
# Now users can "implement" the behaviour without handling ANY events
# Bugs hide because unhandled events silently succeed
```
**Better alternative:**
```elixir
defmacro __using__(_opts) do
quote do
@behaviour MyApp.EventHandler
# handle_event/2 is required — no default
# If you don't handle events, you don't need this behaviour
end
end
```
**Why:** A default implementation that does nothing for the CORE callback creates a pit of failure — modules compile cleanly while silently dropping events. Defaults should be for auxiliary concerns (logging, shutdown, serialization), not the primary contract.
---
## 5. Phoenix Channel: Behaviour + Process + Protocol
**Source:** [lib/phoenix/channel.ex#L364](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/phoenix/channel.ex#L364) (full callback set)
The Channel behaviour combines:
1. **Required callback:** `join/3` (authorization gate)
2. **Optional callbacks:** `handle_in/3`, `handle_info/2`, etc. (event handlers)
3. **Process semantics:** Each channel is a GenServer (line 476-479)
4. **Configuration via module attributes:** `@phoenix_log_join`, `@phoenix_hibernate_after`
```elixir
# From __using__ — configures the process
@phoenix_hibernate_after Keyword.get(opts, :hibernate_after, 15_000)
@phoenix_shutdown Keyword.get(opts, :shutdown, 5000)
def child_spec(init_arg) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, [init_arg]},
shutdown: @phoenix_shutdown,
restart: :temporary
}
end
def start_link(triplet) do
GenServer.start_link(Phoenix.Channel.Server, triplet,
hibernate_after: @phoenix_hibernate_after
)
end
```
**Why:** The Channel behaviour demonstrates layering — it's a behaviour (compile-time contract), a process (runtime entity), and configurable (via options to `use`). Each concern is handled by the appropriate mechanism.
**Anti-pattern:** Trying to encode runtime configuration in the behaviour contract itself, or conflating compile-time and runtime concerns.
### When to Use
**Triggers:**
- Your behaviour involves a running process (GenServer, Agent, Task-like)
- The module needs both compile-time contracts AND runtime configuration
- Different options affect process lifecycle (timeouts, shutdown strategies, restart policies)
**Example — before:**
```elixir
defmodule MyApp.Worker do
@callback handle_job(job :: term()) :: :ok | {:error, term()}
# No process semantics, no configuration — just a callback
end
```
**Example — after:**
```elixir
defmodule MyApp.Worker do
@callback init(args :: term()) :: {:ok, state :: term()}
@callback handle_job(job :: term(), state :: term()) :: {:ok, state :: term()} | {:error, term(), state :: term()}
defmacro __using__(opts) do
quote do
@behaviour MyApp.Worker
@worker_timeout Keyword.get(unquote(opts), :timeout, 30_000)
@worker_max_retries Keyword.get(unquote(opts), :max_retries, 3)
def child_spec(init_arg) do
%{id: __MODULE__, start: {__MODULE__, :start_link, [init_arg]}, restart: :transient}
end
defoverridable child_spec: 1
end
end
end
```
### When NOT to Use
**Don't use this when:**
- The behaviour is purely functional (no process, no state) — keep it simple
- You're conflating too many concerns (behaviour + process + config + routing) in one module
- The "configuration" is better handled at runtime via application config rather than compile-time module attributes
**Over-application example:**
```elixir
defmodule MyApp.Formatter do
@callback format(term()) :: String.t()
defmacro __using__(opts) do
quote do
@behaviour MyApp.Formatter
@formatter_timeout Keyword.get(unquote(opts), :timeout, 5000)
def child_spec(_) do
%{id: __MODULE__, start: {__MODULE__, :start_link, []}}
end
def start_link do
GenServer.start_link(__MODULE__, [])
end
end
end
end
# A formatter doesn't need to be a process!
```
**Better alternative:**
```elixir
defmodule MyApp.Formatter do
@callback format(term()) :: String.t()
end
# Pure behaviour — implementors are just modules with a format/1 function
# No process needed for a synchronous data transformation
```
**Why:** Not everything needs to be a process. Adding GenServer semantics to a behaviour that does synchronous data transformation is over-engineering. Reserve process+behaviour combinations for things that genuinely need state, concurrency, or lifecycle management.
---
## 6. Callback Documentation Pattern
**Source:** [lib/phoenix/channel.ex#L350](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/phoenix/channel.ex#L350) (join callback doc)
```elixir
@doc """
Handle channel joins by `topic`.
...
## Example
def join("room:lobby", payload, socket) do
if authorized?(payload) do
{:ok, socket}
else
{:error, %{reason: "unauthorized"}}
end
end
"""
@callback join(topic :: binary, payload :: payload, socket :: Socket.t()) ::
{:ok, Socket.t()}
| {:ok, reply :: payload, Socket.t()}
| {:error, reason :: map}
```
**Why:** Every callback gets:
1. A `@doc` explaining when it's called and what it should do
2. A concrete example
3. The full type spec with all valid returns
This trio (doc + example + spec) gives implementors everything they need.
**Anti-pattern:** Defining callbacks without documentation — implementors have to read source code to understand when callbacks fire.
### When to Use
**Triggers:**
- You're defining a behaviour that users must implement
- Each callback has non-obvious semantics (when it fires, what params mean, what returns cause)
- The behaviour is public and will be implemented by people who didn't write it
**Example — before:**
```elixir
@callback on_connect(params :: map(), state :: term()) :: {:ok, term()} | {:error, term()}
```
**Example — after:**
```elixir
@doc """
Called when a client establishes a new connection.
`params` contains the query parameters from the connection URL.
`state` is initialized to the value returned by the transport's init.
Return `{:ok, state}` to accept the connection with updated state.
Return `{:error, reason}` to reject — `reason` is sent to the client
as the close frame payload.
## Example
def on_connect(%{"token" => token}, state) do
case verify_token(token) do
{:ok, user_id} -> {:ok, Map.put(state, :user_id, user_id)}
:error -> {:error, :unauthorized}
end
end
"""
@callback on_connect(params :: map(), state :: term()) ::
{:ok, state :: term()}
| {:error, reason :: term()}
```
### When NOT to Use
**Don't use this when:**
- The callback is internal and only your own code implements it
- The callback name and spec are completely self-explanatory (`@callback format(String.t()) :: String.t()`)
- You're writing a one-off behaviour for test mocking — extensive docs are wasted effort
**Over-application example:**
```elixir
@doc """
Converts the value to a string.
## Parameters
- `value` — the value to convert (term)
## Returns
- `String.t()` — the converted string
## Examples
def to_string(123), do: "123"
def to_string(:hello), do: "hello"
## Notes
This callback is required.
"""
@callback to_string(value :: term()) :: String.t()
```
**Better alternative:**
```elixir
@doc "Converts `value` to its string representation for display."
@callback to_string(value :: term()) :: String.t()
```
**Why:** Documentation depth should match callback complexity. A single-purpose callback with one obvious return type needs one sentence, not a full reference page. Save detailed docs for callbacks with multiple return shapes and non-obvious triggering conditions.
---
## 7. Phoenix.Endpoint: Behaviour as Interface Contract
**Source:** [lib/phoenix/endpoint.ex#L408](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/phoenix/endpoint.ex#L408)
```elixir
defmacro __using__(opts) do
quote do
@behaviour Phoenix.Endpoint
unquote(config(opts))
unquote(pubsub())
unquote(plug())
unquote(server())
end
end
```
**Why:** The Endpoint uses `@behaviour` to define what an endpoint MUST provide (like `config/2`), then `__using__` generates the common implementation. The behaviour is the interface; the macro provides the default implementation.
**Anti-pattern:** Using only a behaviour without a `use` macro when significant boilerplate is required — forces every implementor to write the same code.
### When to Use
**Triggers:**
- Your behaviour defines a contract AND requires significant generated code
- The "interface" is simple but the implementation wiring is complex (plugs, routing, supervision)
- Users of the behaviour shouldn't need to understand the plumbing — just implement callbacks
**Example — before:**
```elixir
# User has to wire everything manually
defmodule MyEndpoint do
@behaviour Phoenix.Endpoint
use Plug.Builder
# ... 50 lines of boilerplate
# Easy to get wrong
end
```
**Example — after:**
```elixir
defmodule MyEndpoint do
use Phoenix.Endpoint, otp_app: :my_app
# All wiring generated — just configure and add plugs
plug MyAppWeb.Router
end
```
### When NOT to Use
**Don't use this when:**
- The generated code is minimal (just `@behaviour` — see pattern #3)
- The magic is hard to debug when things go wrong (transparency > convenience)
- Users need to understand what's generated to use the module correctly
**Over-application example:**
```elixir
defmodule MyApp.Validator do
defmacro __using__(_opts) do
quote do
@behaviour MyApp.Validator
import MyApp.Validator.DSL
Module.register_attribute(__MODULE__, :validations, accumulate: true)
@before_compile MyApp.Validator
# 40 lines of generated code for "validation framework"
# Users need a PhD in macros to debug validation errors
end
end
end
```
**Better alternative:**
```elixir
defmodule MyApp.Validator do
@callback validate(term()) :: :ok | {:error, [String.t()]}
end
# Simple behaviour — implementors write plain Elixir
defmodule UserValidator do
@behaviour MyApp.Validator
def validate(%{name: name}) when byte_size(name) > 0, do: :ok
def validate(_), do: {:error, ["name is required"]}
end
```
**Why:** The more code a `use` macro generates, the harder it is to debug. If users regularly need to read the generated code to understand failures, the abstraction is leaking. Reserve heavy `use` macros for well-established patterns (GenServer, Endpoint, Channel) where the community has internalized the mental model.
## Decision Tree
- If you need a contract that multiple modules will implement differently → define a behaviour with `@callback` (Pattern 1)
- If most implementors will use a default for some callbacks → mark those `@optional_callbacks` (Pattern 2)
- If your behaviour requires boilerplate setup (module attributes, compile hooks) → inject `@behaviour` inside `__using__` (Pattern 3)
- If 90% of implementors want the same default for a callback → provide a `defoverridable` implementation (Pattern 4)
- If the behaviour involves a running process with lifecycle configuration → combine behaviour + process + module attributes (Pattern 5)
- If callback semantics are non-obvious (multiple return shapes, triggering conditions) → write comprehensive `@doc` with examples on each `@callback` (Pattern 6)
- If the behaviour requires significant generated boilerplate (plugs, routing, supervision wiring) → use the `use` macro as the full interface contract (Pattern 7)
- If there is only one implementation and no plans for more → skip the behaviour, use a plain module
<!-- PATTERN_COMPLETE -->