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.
This commit is contained in:
Aaron Weiker
2026-04-29 22:50:12 -07:00
commit 4ea9a884aa
16 changed files with 4857 additions and 0 deletions
+196
View File
@@ -0,0 +1,196 @@
# 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)
```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.
---
## 2. `@optional_callbacks` for Extensibility
**Source:** `lib/phoenix/channel.ex:442-448`
```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.
---
## 3. `@behaviour` Declaration in `__using__`
**Source:** `lib/phoenix/channel.ex:450-453`
```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:836`
```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`.
---
## 4. Default Implementations via `defoverridable`
**Source:** `lib/elixir/lib/gen_server.ex:849`
```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.
---
## 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`
```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.
---
## 6. Callback Documentation Pattern
**Source:** `lib/phoenix/channel.ex:350-363` (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.
---
## 7. Phoenix.Endpoint: Behaviour as Interface Contract
**Source:** `lib/phoenix/endpoint.ex:408`
```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.