- 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"
23 KiB
Behaviour Patterns
How behaviours are designed, implemented, and used in Elixir core and Phoenix.
Contents
- Behaviour Definition with
@callback @optional_callbacksfor Extensibility@behaviourDeclaration in__using__- Default Implementations via
defoverridable - Phoenix Channel: Behaviour + Process + Protocol
- Callback Documentation Pattern
- Phoenix.Endpoint: Behaviour as Interface Contract
1. Behaviour Definition with @callback
Source: lib/elixir/lib/gen_server.ex#L577 (all callback definitions)
@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:
# No formal contract — just "convention" in a README
defmodule MyApp.PaymentGateway do
def charge(amount, card), do: raise "implement me"
end
Example — after:
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:
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:
# 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
@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:
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:
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:
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:
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
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
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 MyBehaviourto "just work" with compile-time callback verification - The behaviour has associated module attributes, process setup, or struct definitions
Example — before:
# 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:
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
@behaviourdeclaration is sufficient - You're creating a
usemacro that only sets@behaviourand nothing else (unnecessary indirection) - The module being "used" doesn't define callbacks (it's a utility, not a behaviour)
Over-application example:
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:
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
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:
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:
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_callin 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:
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:
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 (full callback set)
The Channel behaviour combines:
- Required callback:
join/3(authorization gate) - Optional callbacks:
handle_in/3,handle_info/2, etc. (event handlers) - Process semantics: Each channel is a GenServer (line 476-479)
- Configuration via module attributes:
@phoenix_log_join,@phoenix_hibernate_after
# 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:
defmodule MyApp.Worker do
@callback handle_job(job :: term()) :: :ok | {:error, term()}
# No process semantics, no configuration — just a callback
end
Example — after:
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:
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:
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 (join callback doc)
@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:
- A
@docexplaining when it's called and what it should do - A concrete example
- 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:
@callback on_connect(params :: map(), state :: term()) :: {:ok, term()} | {:error, term()}
Example — after:
@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:
@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:
@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
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:
# 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:
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:
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:
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
@behaviourinside__using__(Pattern 3) - If 90% of implementors want the same default for a callback → provide a
defoverridableimplementation (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
@docwith examples on each@callback(Pattern 6) - If the behaviour requires significant generated boilerplate (plugs, routing, supervision wiring) → use the
usemacro 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