Files
elixir-patterns/patterns/from-source.md
T
Rodin e6fbfced96 docs: add patterns extracted from elixir-lang/elixir source
Using codebase-analysis skill (patterns mode) on the language source.
Real examples from the repo, not invented. Each pattern has:
- Rule, Example, Why, When NOT to use, Source file.

Topics: module org, protocol design, error handling, testing,
documentation, naming, process design, smells.
2026-04-30 13:26:23 -07:00

8.3 KiB

Elixir Patterns (from Source)

Prescriptive patterns extracted from elixir-lang/elixir source. "If writing new Elixir, follow these rules."


Module Organization

One Module Per Concept

Rule: Each module owns exactly one concept. If you can't name it in 2-3 words, it's too broad.

# Source: lib/elixir/lib/string.ex — String is strings. Period.
defmodule String do
  @moduledoc """
  Strings in Elixir are UTF-8 encoded binaries.
  """
  @type t :: binary

Why: The Elixir source has zero "util" or "helper" modules. Every module has a noun name that IS the thing.

When NOT to use: Kernel is the exception — it's the implicit surface area. You don't get to make your own Kernel.

Source: Every file in lib/elixir/lib/ follows this.

@moduledoc false for Internal Modules

Rule: Internal modules that users shouldn't call get @moduledoc false.

# Source: lib/elixir/lib/code/formatter.ex
defmodule Code.Formatter do
  @moduledoc false

Why: Hides from docs. Signals "this is implementation, not API." The Elixir team uses this for 30+ internal modules.

When NOT to use: If ANYONE outside your team might call it. Public API must have docs.


Protocol Design

Protocols for External Extension

Rule: Define a protocol when users need to extend behavior for their own types.

# Source: lib/elixir/lib/collectable.ex
defprotocol Collectable do
  @doc """
  Returns an initial accumulation value and a "collector" function.
  """
  @spec into(t) :: {initial_acc :: term, collector(term)}
  def into(collectable)
end

Why: Protocols dispatch on the first argument's type. They're the extension point for "I made a new data structure and want it to work with Enum."

When NOT to use: When you control all implementations. Use behaviours instead. Protocols are for open extension; behaviours are for closed contracts.

Only 6 Stdlib Protocols

Rule: Be conservative defining protocols. The Elixir stdlib has only 6 in 15 years.

  • Enumerable — iterate over things
  • Collectable — put things into containers
  • Inspect — debug representation
  • String.Chars — convert to string
  • List.Chars — convert to charlist
  • JSON.Encoder — JSON serialization (added 2024)

Why: Each protocol is a permanent API commitment. Once defined, every type in the ecosystem may implement it.

When NOT to use: Don't define a protocol for something only your app needs. A behaviour or function argument is cheaper.


Error Handling

Tagged Tuples for Expected Failures

Rule: Return {:ok, value} or {:error, reason} for operations that can fail in expected ways.

# Source: lib/elixir/lib/file.ex
@spec read(Path.t()) :: {:ok, binary} | {:error, posix}
def read(path) do
  case :file.read_file(path) do
    {:ok, binary} -> {:ok, binary}
    {:error, reason} -> {:error, reason}
  end
end

Why: Pattern matching makes handling explicit. The caller MUST decide what to do with errors — they can't accidentally ignore them.

When NOT to use: For programmer errors (bugs). Those should raise. File.read! raises; File.read returns tuples.

Bang Functions for "Should Never Fail"

Rule: Provide function! variant that raises on error. Use when failure means a bug in the caller.

# Convention: function returns {:ok, _} | {:error, _}
#             function! raises on error
File.read("path")   # => {:ok, "..."} | {:error, :enoent}
File.read!("path")  # => "..." | raises File.Error

Why: The ! signals to the reader: "I expect this to succeed. If it doesn't, crash." This is intentional — crashing is the correct response to unexpected errors in OTP.

When NOT to use: When failure is expected and the caller should handle it (use tagged tuples).


Testing

CaseTemplate for Shared Setup

Rule: Use ExUnit.CaseTemplate when multiple test files share setup logic.

# Source: lib/ex_unit/lib/ex_unit/case_template.ex
defmodule MyApp.DataCase do
  use ExUnit.CaseTemplate

  setup tags do
    :ok = Ecto.Adapters.SQL.Sandbox.checkout(MyApp.Repo)
    unless tags[:async] do
      Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, {:shared, self()})
    end
    :ok
  end
end

Why: Inheritance via use is how Phoenix's ConnCase/DataCase work. This pattern comes directly from ExUnit itself.

When NOT to use: For helpers that don't need lifecycle callbacks. Simple import is cleaner for utility functions.

async: true by Default

Rule: Mark tests async: true unless they touch shared state.

Why: Async tests run in parallel. The Elixir stdlib tests show that most tests CAN be async — only database/file/process tests need serialization.

When NOT to use: Tests that modify global state, shared files, or named processes.


Documentation

Every Public Function Gets @doc + @spec

Rule: All public functions have both @doc and @spec.

# Source: lib/elixir/lib/enum.ex
@doc """
Returns `true` if all elements in `enumerable` are truthy.
"""
@spec all?(t) :: boolean
def all?(enumerable) when is_list(enumerable) do

Why: Specs enable Dialyzer checking. Docs generate ExDoc pages. The Elixir stdlib has zero undocumented public functions.

When NOT to use: @moduledoc false modules skip docs (they're internal).

@type t for Structs

Rule: Every struct defines @type t with field types.

# Source: lib/elixir/lib/kernel.ex (defstruct docs)
defmodule User do
  defstruct name: "John", age: 25
  @type t :: %__MODULE__{name: String.t(), age: non_neg_integer}
end

Why: Enables Dialyzer to catch type mismatches at struct boundaries. Without @type t, struct fields are effectively untyped.


Naming

Modules Are Nouns

Rule: Module names are nouns. Never verbs, never adjectives.

String, Enum, Map, File, Logger, GenServer

Why: A module IS a thing. Functions are what you DO with that thing. String.split/2 reads as "take a String, split it."

When NOT to use: Mix tasks (they're commands: Mix.Tasks.Deps.Get).

Functions Are Verbs

Rule: Function names start with a verb (or are a question with ?).

Enum.map/2, String.split/2, File.read/1, Enum.empty?/1

Why: module.verb(subject) reads as a sentence.

Underscore Prefix for Unused

Rule: Prefix unused variables with _.

def handle_info(_message, state), do: {:noreply, state}

Why: Compiler warning suppression AND documentation that the value is intentionally ignored.


Process Design

GenServer for Stateful Services

Rule: Use GenServer when you need mutable state that outlives a request.

# Source: lib/iex/lib/iex/broker.ex
defmodule IEx.Broker do
  use GenServer
  # ...
end

Why: GenServer gives you: message serialization, supervision tree integration, hot code upgrade, :sys debugging.

When NOT to use: Stateless transformations (just use functions). One-off concurrent work (use Task). Accumulating state within a request (use recursion or reduce).

Agent for Simple State

Rule: Use Agent when you only need get/update on a value — no complex message handling.

# Source: lib/mix/lib/mix/tasks_server.ex
defmodule Mix.TasksServer do
  use Agent
end

Why: Agent is GenServer with the common case optimized. Less boilerplate for "I just need a mutable box."

When NOT to use: When you need handle_info, timeouts, or multiple operations that must be atomic.


Smells

GenEvent (Deprecated Pattern)

# Source: lib/elixir/lib/gen_event.ex
@moduledoc deprecated: "Use one of the alternatives described below"

The Elixir team deprecated their own GenEvent. Alternatives: Registry + GenServer, or Phoenix.PubSub. Lesson: event buses that try to do everything are worse than composed primitives.

Version-Gated TODOs (Deferred Cleanup)

# TODO: Remove me on v2.0
# TODO: Deprecate me on Elixir v1.23

127 of these exist. They're not smells in the "bad code" sense — they're discipline. But if YOUR code has TODOs without version targets, that IS a smell.

@moduledoc false Proliferation

30+ internal modules in the stdlib. If your app has this many, you may be over-splitting. Internal modules should be rare in application code — they're appropriate for libraries and frameworks.