Files

35 KiB

Documentation Patterns

Patterns extracted from the Elixir standard library source code.

Contents

  1. @moduledoc with Structured Sections
  2. @doc with Sections and Examples
  3. @doc since: Version Annotation
  4. @doc guard: true Metadata
  5. @doc false — Hiding from Documentation
  6. @moduledoc false — Hiding Modules
  7. Mermaid Diagrams in Documentation
  8. Admonition Blocks in Documentation
  9. @doc deprecated: Soft Deprecation
  10. Callback Documentation Convention
  11. Documentation with Link References (c: and t: prefixes)

1. @moduledoc with Structured Sections

Source: lib/elixir/lib/gen_server.ex#L6+, lib/logger/lib/logger.ex#L6+

What it does: @moduledoc uses a clear hierarchical structure with ## sections covering: overview, examples, configuration, and detailed behavioral explanations. GenServer covers "Example", "Client / Server APIs", "How to supervise", "Name registration", "Timeouts", "Debugging". Logger covers "Levels", "Message", "Metadata", "Configuration" (with subsections for Boot/Compile/Runtime).

Why: Long moduledocs are effectively reference manuals. Structured sections let users jump to what they need. The pattern establishes a convention: start with "what is this" → "quick example" → "detailed topics."

Anti-pattern: Writing a single unstructured wall of text, or documenting only the happy path without covering error handling, configuration, and advanced usage.

Code example:

defmodule GenServer do
  @moduledoc """
  A behaviour module for implementing the server of a client-server relation.

  A GenServer is a process like any other Elixir process...

  ## Example

  The GenServer behaviour abstracts the common client-server interaction...

      defmodule Stack do
        use GenServer
        # ...
      end

  ## How to supervise

  A GenServer is most commonly started under a supervision tree...

  ## Name registration

  ...

  ## Timeouts

  ...

  ## Debugging with the :sys module

  ...
  """
end

When to Use

Triggers:

  • Your module is the primary entry point for a concept (GenServer, Logger, Supervisor)
  • The module has more than 3-4 public functions with non-obvious relationships
  • Users need to understand configuration, lifecycle, or architecture before calling individual functions

Example — before:

defmodule MyApp.Cache do
  @moduledoc "A cache module."

  def get(key), do: ...
  def put(key, value, opts), do: ...
  def invalidate(key), do: ...
end

Example — after:

defmodule MyApp.Cache do
  @moduledoc """
  A write-through cache backed by ETS with configurable TTL.

  ## Usage

      MyApp.Cache.put("user:1", user, ttl: :timer.minutes(5))
      MyApp.Cache.get("user:1")  #=> {:ok, %User{}}

  ## Configuration

  Configure in your application config:

      config :my_app, MyApp.Cache,
        max_size: 10_000,
        default_ttl: :timer.hours(1)

  ## Eviction

  When `max_size` is reached, entries are evicted LRU-first...
  """
end

When NOT to Use

Don't use this when:

  • The module has 1-2 functions and the purpose is obvious from the module name
  • It's an internal/private module (@moduledoc false is better)
  • The module is a thin wrapper where function-level docs suffice

Over-application example:

defmodule MyApp.StringUtils do
  @moduledoc """
  # String Utilities

  ## Overview

  This module provides string utility functions for the application.

  ## Architecture

  Functions in this module operate on binaries and return binaries...

  ## Configuration

  No configuration required.

  ## Examples

  See individual function documentation.
  """

  def capitalize_words(str), do: ...
end

Better alternative:

defmodule MyApp.StringUtils do
  @moduledoc "String transformation helpers for display formatting."

  @doc "Capitalizes the first letter of each word."
  def capitalize_words(str), do: ...
end

Why: A utility module with a few pure functions doesn't need architecture docs, configuration sections, or a table of contents. Match the documentation depth to the module's complexity.


2. @doc with Sections and Examples

Source: lib/elixir/lib/kernel.ex#L315 (abs/1), lib/logger/lib/logger.ex#L536

What it does: @doc for individual functions includes: a brief one-liner, allowed-in-guards note (via @doc guard: true), explanation of edge cases, and ## Examples with doctests.

Why: Each function doc is self-contained. A developer reading docs shouldn't need to look elsewhere for basic usage. Guard-eligible functions are annotated structurally so tools can surface this.

Anti-pattern: Docs that say "see module documentation" without providing any local context. Also: examples without iex> that can't be tested automatically.

Code example:

@doc """
Returns all the available levels.
"""
@doc since: "1.16.0"
@spec levels() :: [level(), ...]
def levels(), do: @levels

When to Use

Triggers:

  • The function has non-obvious behavior, edge cases, or preconditions
  • Users need to understand what happens with boundary inputs (nil, empty list, negative numbers)
  • The function is part of a public API that others will call without reading the source

Example — before:

def chunk(list, size), do: ...

Example — after:

@doc """
Splits `list` into chunks of the given `size`.

The last chunk may have fewer than `size` elements if the list
length is not evenly divisible.

## Examples

    iex> chunk([1, 2, 3, 4, 5], 2)
    [[1, 2], [3, 4], [5]]

    iex> chunk([], 3)
    []

"""
def chunk(list, size), do: ...

When NOT to Use

Don't use this when:

  • The function is private (use # comments for private function notes)
  • The function name + typespec are completely self-explanatory (e.g., @spec pid() :: pid())
  • You're implementing a behaviour callback — @impl true sets @doc false automatically. Override only when the implementation has semantics the behaviour can't speak to (see Pattern 10 — implementation-side docs)

Over-application example:

@doc """
Returns the name.

## Examples

    iex> get_name(%User{name: "Alice"})
    "Alice"

"""
@spec get_name(User.t()) :: String.t()
def get_name(%User{name: name}), do: name

Better alternative:

@spec get_name(User.t()) :: String.t()
def get_name(%User{name: name}), do: name

Why: When the function name, spec, and implementation are all trivially obvious, a @doc that restates them adds maintenance burden without helping anyone. Save detailed docs for functions where the behavior isn't immediately obvious.


3. @doc since: Version Annotation

Source: lib/logger/lib/logger.ex#L539, 576, 813, 824, 831, lib/elixir/lib/kernel.ex#L5163+

What it does: Attaches since: "X.Y.Z" metadata to @doc indicating the Elixir version that introduced the function.

Why: Users working with multiple Elixir versions need to know API availability. ExDoc renders this prominently. The pattern is universal in the standard library for any function added after 1.0.

Anti-pattern: Adding new public functions without @since annotation, leaving version compatibility ambiguous.

Code example:

@doc since: "1.15.0"
@spec default_formatter(formatter_opts) :: {module, :logger.formatter_config()}
def default_formatter(overrides \\ []) when is_list(overrides) do
  # ...
end

@doc since: "1.14.0"
def binary_slice(binary, start, size)
    when is_binary(binary) and is_integer(start) and is_integer(size) and size >= 0 do
  # ...
end

When to Use

Triggers:

  • You're adding a new public function to an existing library (anything post-1.0)
  • Your library publishes versioned documentation (via ExDoc/HexDocs)
  • Users may be on older versions and need to know when a function became available

Example — before:

@doc "Compacts the list by removing nil values."
def compact(list), do: Enum.reject(list, &is_nil/1)

Example — after:

@doc since: "1.4.0"
@doc "Compacts the list by removing nil values."
def compact(list), do: Enum.reject(list, &is_nil/1)

When NOT to Use

Don't use this when:

  • You're writing application code (not a published library)
  • The function has existed since the library's first release
  • The library doesn't publish versioned docs or follow semver

Over-application example:

# In a Phoenix controller — no one checks "which version of my app added this"
defmodule MyAppWeb.UserController do
  @doc since: "0.1.0"
  def index(conn, _params), do: ...

  @doc since: "0.2.0"
  def show(conn, %{"id" => id}), do: ...
end

Better alternative:

defmodule MyAppWeb.UserController do
  def index(conn, _params), do: ...
  def show(conn, %{"id" => id}), do: ...
end

Why: since: annotations help library consumers check compatibility. Application code is deployed atomically — there's no concept of "which version of the app introduced this endpoint." Use git blame instead.


4. @doc guard: true Metadata

Source: lib/elixir/lib/kernel.ex#L329, 408, 428, 452, etc.

What it does: Functions/macros usable in guard clauses are annotated with @doc guard: true metadata, separate from the doc string itself.

Why: This is machine-readable metadata. ExDoc and IEx can filter/display guard-eligible functions differently. It's orthogonal to the prose documentation.

Anti-pattern: Mentioning "allowed in guards" only in the doc string text, which tools can't parse programmatically.

Code example:

@doc """
Returns the absolute value of `number`.

...

## Examples

    iex> abs(-1)
    1
    iex> abs(1)
    1

"""
@doc guard: true
@spec abs(number) :: number
def abs(number) when is_number(number), do: ...

When to Use

Triggers:

  • The function/macro is valid in guard clauses
  • You want tools (ExDoc, IEx) to programmatically identify guard-eligible functions
  • The function is a Kernel macro that users might try to use in guards

Example — before:

@doc """
Returns true if `term` is a non-empty binary.

Allowed in guard tests.
"""
defmacro is_non_empty_binary(term) do
  ...
end

Example — after:

@doc """
Returns true if `term` is a non-empty binary.
"""
@doc guard: true
defmacro is_non_empty_binary(term) do
  ...
end

When NOT to Use

Don't use this when:

  • The function is NOT valid in guard clauses (adding it would be misleading)
  • You're writing application code where no one filters functions by guard eligibility
  • The function calls other functions or has side effects (it can't be a guard)

Over-application example:

@doc guard: true
def valid_email?(email) do
  String.contains?(email, "@")  # NOT guard-safe — calls String.contains?
end

Better alternative:

@doc "Checks if the string looks like an email address."
def valid_email?(email) do
  String.contains?(email, "@")
end

Why: @doc guard: true is a semantic contract that the function works in guard position. Adding it to non-guard functions breaks tooling expectations and confuses users who try to use it in when clauses.


5. @doc false — Hiding from Documentation

Source: lib/elixir/lib/inspect.ex#L410, 417; implicit via @impl true

What it does: @doc false explicitly hides a function from documentation generators. Also, using @impl true automatically sets @doc false (unless @doc is explicitly provided).

Why: Not all public functions are part of the user-facing API. Protocol implementations, internal helpers that must be public for technical reasons, and overridable defaults should be hidden from docs.

Anti-pattern: Leaving documentation on functions that are public only for technical reasons (e.g., protocol dispatch functions), confusing users about what to call.

Code example:

# Explicit hiding:
@doc false
def __protocol__(:module), do: __MODULE__

# Implicit via @impl:
@impl true  # automatically sets @doc false
def init(counter) do
  {:ok, counter}
end

When to Use

Triggers:

  • A function must be public for technical reasons (protocol dispatch, macro expansion) but isn't part of the user API
  • You're implementing callbacks with @impl true and don't want generated docs
  • The function is an internal hook that users should never call directly

Example — before:

# Users see this in docs and try to call it
def __struct__(fields), do: ...

Example — after:

@doc false
def __struct__(fields), do: ...

When NOT to Use

Don't use this when:

  • The function IS part of the public API (even if you think it's "obvious")
  • You want to discourage use but still document it (use @doc deprecated: instead)
  • You're hiding functions because you're too lazy to document them
  • The @impl function has implementation-specific semantics that callers need to know (see Pattern 10 — implementation-side docs)

Over-application example:

defmodule MyApp.Repo do
  @doc false
  def get(schema, id), do: ...

  @doc false
  def all(query), do: ...
end

Better alternative:

defmodule MyApp.Repo do
  @doc "Fetches a single record by primary key. Returns nil if not found."
  def get(schema, id), do: ...

  @doc "Fetches all records matching the query."
  def all(query), do: ...
end

Why: @doc false means "this function is not part of the public API." If users are expected to call it, it needs documentation. Hiding public API behind @doc false is a maintenance hazard — users will call undocumented functions and break on upgrades.

Note: @impl true auto-sets @doc false, but some implementations earn their own @doc. See Pattern 10 — implementation-side docs for the rule.


6. @moduledoc false — Hiding Modules

Source: Common pattern in internal modules (not shown in top-level files but documented in Module)

What it does: @moduledoc false makes an entire module invisible to documentation tools.

Why: Internal implementation modules (e.g., MyApp.Repo.Migrations.Internal) exist for code organization but shouldn't appear in user-facing docs.

Anti-pattern: Leaving @moduledoc undeclared on internal modules — ExDoc will show them with an empty documentation page, confusing users.

Code example:

defmodule MyApp.Internal.Helper do
  @moduledoc false
  # This module exists but won't appear in docs
  def helper_function(x), do: x * 2
end

When to Use

Triggers:

  • The module exists purely for internal code organization
  • It's a protocol implementation module that users never reference directly
  • It's a migration, task, or generated module that shouldn't appear in docs

Example — before:

defmodule MyApp.Repo.Supervisor do
  # Internal supervisor — users never interact with this directly
  use Supervisor
  def start_link(opts), do: ...
  def init(opts), do: ...
end

Example — after:

defmodule MyApp.Repo.Supervisor do
  @moduledoc false
  use Supervisor
  def start_link(opts), do: ...
  def init(opts), do: ...
end

When NOT to Use

Don't use this when:

  • The module has any function that users are expected to call
  • You're hiding it because documentation feels like too much work
  • The module is a behaviour that others will implement

Over-application example:

defmodule MyApp.Telemetry do
  @moduledoc false  # "It's just telemetry setup, nobody cares"
  
  def setup do
    # Attaches telemetry handlers that affect app behavior
    :telemetry.attach_many(...)
  end
end

Better alternative:

defmodule MyApp.Telemetry do
  @moduledoc """
  Telemetry event handlers for request metrics and error tracking.

  Called from `Application.start/2`. Attaches handlers for:
  - `[:my_app, :request, :stop]` — request duration histograms
  - `[:my_app, :error]` — error counters
  """
  def setup, do: ...
end

Why: If a module affects application behavior and other developers need to understand it during debugging or extension, it needs documentation. @moduledoc false should be reserved for truly mechanical/generated code.


7. Mermaid Diagrams in Documentation

Source: lib/elixir/lib/gen_server.ex#L14

What it does: Embeds Mermaid diagram syntax directly in @moduledoc to illustrate architectural patterns (client-server message flow).

Why: Visual diagrams communicate architecture faster than prose. ExDoc renders Mermaid natively, so documentation can include live-rendered flowcharts.

Anti-pattern: Describing complex interaction patterns only in prose when a diagram would be clearer.

Code example:

@moduledoc """
A behaviour module for implementing the server of a client-server relation.

```mermaid
graph BT
    C(Client #3) ~~~ B(Client #2) ~~~ A(Client #1)
    A & B & C -->|request| GenServer
    GenServer -.->|reply| A & B & C

Example

... """



### When to Use

**Triggers:**
- You're documenting a multi-component architecture (client-server, pipeline, state machine)
- The relationship between components is spatial or sequential and hard to express in prose
- ExDoc is your documentation renderer (it supports Mermaid natively)

**Example — before:**
```elixir
@moduledoc """
The pipeline processes events through three stages:
first the parser, then the transformer, then the loader.
The parser sends to transformer, transformer sends to loader.
Errors at any stage go to the error handler.
"""

Example — after:

@moduledoc """
Event processing pipeline with three stages.

```mermaid
graph LR
    Parser -->|events| Transformer
    Transformer -->|records| Loader
    Parser & Transformer & Loader -.->|errors| ErrorHandler

Stages

... """


### When NOT to Use

**Don't use this when:**
- The module's architecture is simple enough that a one-sentence description suffices
- Your docs aren't rendered by a tool that supports Mermaid (raw markdown viewers won't render it)
- The diagram would be trivial (one box, one arrow) — a sentence is clearer

**Over-application example:**
```elixir
@moduledoc """
Wraps an integer counter.

```mermaid
graph LR
    User -->|increment| Counter
    Counter -->|value| User

"""


**Better alternative:**
```elixir
@moduledoc """
A simple integer counter. Call `increment/1` to add, `value/1` to read.
"""

Why: Diagrams add cognitive overhead. If the reader can understand the architecture faster from a sentence than from parsing a diagram, the diagram is noise. Reserve diagrams for genuinely complex interactions.


8. Admonition Blocks in Documentation

Source: lib/elixir/lib/gen_server.ex#L88, lib/elixir/lib/supervisor.ex#L34

What it does: Uses markdown admonition syntax (> #### Title {: .info}) to highlight important callouts — especially for use ModuleName behavior documentation.

Why: Admonitions draw attention to critical information that might otherwise be buried in prose. The .info, .warning, .neutral classes provide visual differentiation.

Anti-pattern: Burying critical behavioral information (like what use does) in regular paragraphs that users skim over.

Code example:

@moduledoc """
...

> #### `use GenServer` {: .info}
>
> When you `use GenServer`, the `GenServer` module will
> set `@behaviour GenServer` and define a `child_spec/1`
> function, so your module can be used as a child
> in a supervision tree.

...
"""

When to Use

Triggers:

  • There's critical information that users MUST notice (breaking changes, security implications, required setup)
  • You need to document what use MyModule injects (the OTP convention)
  • A common mistake or foot-gun deserves visual prominence

Example — before:

@moduledoc """
...
Note: calling process/1 with untrusted input can execute arbitrary code.
Make sure to validate inputs first.
...
"""

Example — after:

@moduledoc """
...

> #### Security Warning {: .warning}
>
> `process/1` evaluates its input. Never pass untrusted user input
> directly — always validate and sanitize first.

...
"""

When NOT to Use

Don't use this when:

  • The information isn't actually critical (don't cry wolf)
  • You're using admonitions for every paragraph (they lose impact)
  • Your rendering target doesn't support admonition syntax (it'll render as ugly blockquotes)

Over-application example:

@moduledoc """
> #### Overview {: .info}
>
> This module provides helper functions.

> #### Usage {: .info}
>
> Call the functions with the appropriate arguments.

> #### Note {: .neutral}
>
> All functions return their results.
"""

Better alternative:

@moduledoc """
Helper functions for string formatting.

## Usage

    MyHelpers.format_name("alice") #=> "Alice"
"""

Why: Admonitions are visual interrupts — they break reading flow to demand attention. When everything is an admonition, nothing is. Reserve them for information where missing it would cause real problems.


9. @doc deprecated: Soft Deprecation

Source: lib/elixir/lib/module.ex#L163

What it does: Uses @doc deprecated: "Use X instead" for soft deprecation (documentation-only warning) vs. @deprecated "reason" for hard deprecation (compiler warning).

Why: Two levels of deprecation serve different needs. Soft deprecation signals "we recommend the alternative" without breaking builds. Hard deprecation actively warns at compile time.

Anti-pattern: Using @deprecated for things that aren't truly deprecated yet (just discouraged), creating noise in build outputs.

Code example:

# Soft deprecation — docs only, no compiler warning:
@doc deprecated: "Use Kernel.length/1 instead"
def size(keyword) do
  length(keyword)
end

# Hard deprecation — compiler warning emitted:
@deprecated "Use Kernel.length/1 instead"
def size(keyword) do
  length(keyword)
end

When to Use

Triggers:

  • You want to signal "prefer the alternative" without emitting compiler warnings
  • The function still works fine but a better API exists
  • You're planning a future hard deprecation and want to give users a migration window

Example — before:

@doc "Fetches a user by ID. Deprecated: use Accounts.get_user/1."
def fetch_user(id), do: ...

Example — after:

@doc deprecated: "Use Accounts.get_user/1 instead"
@doc "Fetches a user by ID."
def fetch_user(id), do: ...

When NOT to Use

Don't use this when:

  • You actually want compiler warnings (use @deprecated "reason" instead)
  • The function is being removed in the next release (hard deprecation is appropriate)
  • The function is still the recommended approach (don't pre-deprecate)

Over-application example:

# Deprecating before the replacement exists
@doc deprecated: "Will be replaced by new_process/2 in v3.0"
def process(input) do
  # new_process/2 doesn't exist yet!
  ...
end

Better alternative:

# Wait until the replacement is available, then deprecate
def process(input), do: ...

# In v3.0, after new_process/2 ships:
@doc deprecated: "Use new_process/2 instead"
def process(input), do: new_process(input, [])

Why: Never deprecate a function before the recommended alternative exists and is documented. Users seeing "use X instead" need X to actually exist — otherwise you're creating confusion without offering a path forward.


10. Callback Documentation Convention

Source: lib/elixir/lib/gen_server.ex#L584 (handle_call docs)

What it does: Each @callback is preceded by a comprehensive @doc that explains: what triggers the callback, what the parameters mean, every possible return value and its effect, when the callback is optional, and cross-references to related callbacks.

Why: Callback documentation is the primary teaching tool for behaviour implementers. Since users must write these functions, the docs must be complete enough to implement from.

Anti-pattern: Documenting callbacks with only a one-liner, forcing users to read source code or external guides.

Code example:

@doc """
Invoked to handle synchronous `call/3` messages. `call/3` will block until a
reply is received (unless the call times out or nodes are disconnected).

`request` is the request message sent by a `call/3`, `from` is a 2-tuple
containing the caller's PID and a term that uniquely identifies the call, and
`state` is the current state of the `GenServer`.

Returning `{:reply, reply, new_state}` sends the response `reply` to the
caller and continues the loop with new state `new_state`.

Returning `{:noreply, new_state}` does not send a response to the caller and
continues the loop with new state `new_state`. The response must be sent with
`reply/2`.

...

This callback is optional. If one is not implemented, the server will fail
if a call is performed against it.
"""
@callback handle_call(request :: term, from, state :: term) ::
            {:reply, reply, new_state}
            | ...
          when reply: term, new_state: term, reason: term

When to Use

Triggers:

  • You're defining a behaviour that other modules will implement
  • Implementers need to know: when the callback fires, what all return values mean, and which callbacks are optional
  • The callback has multiple valid return shapes with different effects

Example — before:

@callback handle_event(event :: term(), state :: term()) :: {:ok, term()} | {:error, term()}

Example — after:

@doc """
Invoked when an event is received from the event bus.

`event` is the decoded event payload. `state` is the handler's
current state, initialized by `c:init/1`.

Returning `{:ok, new_state}` acknowledges the event and continues.
Returning `{:skip, new_state}` skips without acknowledgment (redelivery possible).
Returning `{:error, reason}` triggers the error handler configured in `start_link/1`.

This callback is required.
"""
@callback handle_event(event :: term(), state :: term()) ::
            {:ok, new_state :: term()}
            | {:skip, new_state :: term()}
            | {:error, reason :: term()}

When NOT to Use

Don't use this when:

  • The callback has a single obvious return type and the name says it all
  • You're documenting internal callbacks that aren't part of a public behaviour
  • The behaviour is private to your application and only you implement it

Over-application example:

@doc """
Called to format the value.

## Parameters

- `value` - The value to format (term)

## Returns

- `String.t()` - The formatted string

## Examples

    iex> format(:hello)
    "hello"
"""
@callback format(value :: term()) :: String.t()

Better alternative:

@doc "Formats `value` into a human-readable string for display."
@callback format(value :: term()) :: String.t()

Why: A callback with one parameter and one return type doesn't need a full reference manual. Match documentation depth to complexity — a one-liner with good naming is better than padded sections that add no information.

When to override @doc false on @impl functions

@impl true sets @doc false by default — the assumption is that the behaviour's @callback doc covers it. This is correct when the implementation is unremarkable. Override it with an explicit @doc when the implementation has semantics that would not be true of every conforming implementation.

The test: "Would this statement be true of any implementation of this callback?" If yes → the doc belongs on the @callback in the behaviour, not here. If no → the implementation earns its own @doc.

Override when:

  • The implementation makes a guarantee the behaviour doesn't promise (e.g., "always returns immediately", "never buffers", "fires at most once")
  • There's a race condition, ordering constraint, or subtle failure mode specific to this implementation
  • The implementation's behavior under edge cases differs from what the behaviour's generic contract implies

Don't override when:

  • The doc would just restate the @callback doc in different words
  • The doc describes what the function does rather than what's surprising about this implementation
  • The function name + behaviour doc are sufficient for an implementor or caller to understand it

Example — unnecessary override (just restates the contract):

defmodule JsonSerializer do
  @behaviour Serializer

  @doc """
  Encodes the given term to a binary.
  """
  @impl true
  def encode(term), do: Jason.encode!(term)
end

Example — justified override (implementation-specific guarantee):

defmodule Immediate do
  @behaviour Aggregation

  @doc """
  Always returns `{:ready, [signal]}` — immediate mode fires on first signal
  without buffering or waiting for a timer.
  """
  @impl true
  def check(%Signal{} = signal), do: {:ready, [signal]}
end

Example — justified override (race condition warning):

defmodule AlpacaAdapter do
  @behaviour BrokerAdapter

  @doc """
  Cancels an open order by broker ID. Returns `:ok` on success.

  The order may still receive a final fill between the cancel request
  and confirmation — callers must handle the `partially_filled` → `cancelled` race.
  """
  @impl true
  def cancel(credential, broker_order_id), do: ...
end

Why: The behaviour's @callback doc is the generic contract — "what any implementation must do." An implementation's @doc is the specific contract — "what callers of this implementation can rely on." When those differ meaningfully, silence (@doc false) hides information that would prevent bugs.


Source: lib/elixir/lib/gen_server.ex throughout the @moduledoc

What it does: Uses ExDoc link syntax like c:init/1 (callback reference), t:server/0 (type reference), and backtick function references like `start_link/2` to create navigable cross-references.

Why: Documentation is a hypertext. Cross-linking lets users navigate between related concepts. The c: and t: prefixes disambiguate between functions, callbacks, and types with the same name.

Anti-pattern: Mentioning related functions/types by name without linking them, requiring users to manually search.

Code example:

@moduledoc """
...
`c:init/1` transforms our initial argument to the initial state for the
GenServer. `c:handle_call/3` fires when the server receives a synchronous
`pop` message...

Each call to `GenServer.call/3` results in a message
that must be handled by the `c:handle_call/3` callback in the GenServer.
...
"""

When to Use

Triggers:

  • Your moduledoc or function doc references other functions, callbacks, or types in the same or other modules
  • Users would benefit from clicking through to related documentation
  • You have callbacks and functions with the same name that need disambiguation

Example — before:

@doc """
Starts the server. Calls init/1 internally.
See the server type for accepted values.
"""

Example — after:

@doc """
Starts the server. Calls `c:init/1` internally.
See `t:server/0` for accepted name values.
"""

When NOT to Use

Don't use this when:

  • You're writing documentation that won't be rendered by ExDoc (plain README, comments)
  • The reference is to a well-known Erlang/Elixir function that readers will recognize without a link
  • Over-linking makes the prose unreadable (every other word is a link)

Over-application example:

@doc """
Returns `t:boolean/0` indicating if the `t:pid/0` from `Kernel.self/0`
matches the `t:pid/0` stored in `t:state/0` field `:owner`.
"""

Better alternative:

@doc """
Returns `true` if the calling process is the owner of this resource.
"""

Why: Link references should aid navigation, not turn documentation into hypertext soup. Link types and callbacks that users might need to look up; don't link primitive types or universally known functions.

Decision Tree

  • If the module is a primary entry point with 4+ public functions → use structured @moduledoc with sections (Pattern 1)
  • If a function has non-obvious behavior or edge cases → add @doc with sections and ## Examples doctests (Pattern 2)
  • If adding a new public function to a versioned library → annotate with @doc since: "X.Y.Z" (Pattern 3)
  • If the function/macro is valid in guard clauses → add @doc guard: true metadata (Pattern 4)
  • If a function must be public for technical reasons but is not user-facing → use @doc false (Pattern 5)
  • If an entire module is purely internal implementation → use @moduledoc false (Pattern 6)
  • If documenting multi-component architecture (client-server, pipelines) → embed a Mermaid diagram (Pattern 7)
  • If critical information must stand out (security, breaking changes, use behavior) → use an admonition block (Pattern 8)
  • If a function still works but a better alternative exists → use @doc deprecated: for soft deprecation (Pattern 9)
  • If defining a behaviour callback with multiple return shapes → write comprehensive callback docs with trigger, params, returns, and example (Pattern 10)