Files
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

23 KiB

Behaviour Patterns

How behaviours are designed, implemented, and used in Elixir core and Phoenix.

Contents

  1. Behaviour Definition with @callback
  2. @optional_callbacks for Extensibility
  3. @behaviour Declaration in __using__
  4. Default Implementations via defoverridable
  5. Phoenix Channel: Behaviour + Process + Protocol
  6. Callback Documentation Pattern
  7. 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 MyBehaviour to "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 @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:

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_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:

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:

  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
# 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:

  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:

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