docs: add when/when-not to typespecs + documentation + behaviours
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user