docs: add when/when-not to typespecs + documentation + behaviours

This commit is contained in:
Aaron Weiker
2026-04-30 05:23:28 -07:00
parent e81439e686
commit 1a934eb2e3
3 changed files with 1647 additions and 0 deletions
+483
View File
@@ -25,6 +25,66 @@ How behaviours are designed, implemented, and used in Elixir core and Phoenix.
**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
@@ -45,6 +105,65 @@ How behaviours are designed, implemented, and used in Elixir core and Phoenix.
**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__`
@@ -76,6 +195,77 @@ end
**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`
@@ -98,6 +288,72 @@ defoverridable child_spec: 1
**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
@@ -135,6 +391,86 @@ end
**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
@@ -172,6 +508,85 @@ 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
@@ -194,3 +609,71 @@ 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.