Files
elixir-patterns/patterns/behaviours.md
T
Aaron Weiker 4ea9a884aa docs: idiomatic Elixir and Phoenix patterns with source citations
Extracted patterns, conventions, and code smells directly from the
Elixir and Phoenix source code with file path and line number citations.

Covers: GenServer, error handling, data transforms, process design,
testing, documentation, typespecs, macros, behaviours, module organization,
Phoenix-specific patterns, framework deviations, and anti-patterns.
2026-04-29 22:50:12 -07:00

6.0 KiB

Behaviour Patterns

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

1. Behaviour Definition with @callback

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


2. @optional_callbacks for Extensibility

Source: lib/phoenix/channel.ex:442-448

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


3. @behaviour Declaration in __using__

Source: lib/phoenix/channel.ex:450-453

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

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.


4. Default Implementations via defoverridable

Source: lib/elixir/lib/gen_server.ex:849

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.


5. Phoenix Channel: Behaviour + Process + Protocol

Source: lib/phoenix/channel.ex:364-448 (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.


6. Callback Documentation Pattern

Source: lib/phoenix/channel.ex:350-363 (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.


7. Phoenix.Endpoint: Behaviour as Interface Contract

Source: lib/phoenix/endpoint.ex:408

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.