Files
aweiker 10218813d3 docs: backfill TOC + decision trees, fix review findings
- Add ## Contents and ## Decision Tree to all 10 existing pattern files
- Fix embed_as/1 semantics inversion in types.md (:self → :dump)
- Fix fabricated __meta__.changes reference in changesets.md
- Fix default primary key type (:integer → :id) in schemas.md
- Combine @impl subsections into single "Minimal Callback Annotation"
2026-05-01 22:13:35 -07:00

19 KiB

Module Organization Patterns

How modules are structured, named, and organized in Elixir core and Phoenix.

Contents

  1. One Module per Concept, Nested for Sub-Concepts
  2. Public API at the Top, Private Functions at the Bottom
  3. @moduledoc false for Internal Modules
  4. Struct Definition Conventions
  5. Selective Imports in __using__
  6. Alias at Module Scope for Readability
  7. Boolean-Suffixed Fields in Structs

1. One Module per Concept, Nested for Sub-Concepts

Source: lib/elixir/lib/ directory structure

gen_server.ex        — GenServer (the main module)
task.ex              — Task (the main module)
task/supervised.ex   — Task.Supervised (internal implementation)
supervisor.ex        — Supervisor (the main module)

Source: lib/phoenix/ directory structure

channel.ex           — Phoenix.Channel
channel/             — Phoenix.Channel.* submodules
  server.ex          — Phoenix.Channel.Server
router.ex            — Phoenix.Router
router/              — Phoenix.Router.* submodules
  route.ex           — Phoenix.Router.Route
  scope.ex           — Phoenix.Router.Scope
  resource.ex        — Phoenix.Router.Resource
endpoint.ex          — Phoenix.Endpoint
endpoint/            — Phoenix.Endpoint.* submodules
  supervisor.ex      — Phoenix.Endpoint.Supervisor

Why: The parent module is the public API. Submodules handle implementation details. File layout mirrors module hierarchy — Phoenix.Router.Route lives in router/route.ex.

Anti-pattern: Putting all modules in a flat directory, or nesting too deeply (more than 3 levels is usually a sign of over-engineering).

When to Use

Triggers:

  • Your module has grown beyond ~300 lines with distinct sub-responsibilities
  • External code only needs the parent module but implementation is complex
  • You find yourself prefixing private functions with a concept name (e.g., scope_push, scope_pop)

Example — before:

# Everything crammed into one flat module
defmodule MyApp.Router do
  # 800 lines mixing route compilation, scope tracking, and helper generation
  def compile_route(...), do: # ...
  def push_scope(...), do: # ...
  def pop_scope(...), do: # ...
  def generate_helper(...), do: # ...
end

Example — after:

# Parent module is the public API
defmodule MyApp.Router do
  # Public API delegates to focused submodules
  def compile(routes), do: MyApp.Router.Compiler.compile(routes)
end

# Submodules handle implementation
defmodule MyApp.Router.Compiler do
  @moduledoc false
  # ...
end

defmodule MyApp.Router.Scope do
  @moduledoc false
  # ...
end

When NOT to Use

Don't use this when:

  • The module is small and cohesive (< 200 lines)
  • Nesting would exceed 3 levels (A.B.C.D is usually too deep)
  • The "submodule" has its own independent public API (make it a sibling instead)

Over-application example:

# Over-nesting a simple utility
defmodule MyApp.Utils.String.Formatting.Case do
  def upcase(s), do: String.upcase(s)
end

Better alternative:

defmodule MyApp.StringUtils do
  def upcase(s), do: String.upcase(s)
end

Why: Nesting should reflect genuine conceptual hierarchy. If you're creating submodules for 2-3 functions that don't have independent complexity, you're adding navigational overhead without architectural benefit.


2. Public API at the Top, Private Functions at the Bottom

Source: lib/elixir/lib/agent.ex (full module structure)

defmodule Agent do
  @moduledoc "..."

  # Types
  @type on_start :: ...
  @type name :: ...
  @type agent :: ...
  @type state :: ...

  # child_spec (for supervisors)
  def child_spec(arg) do ... end

  # __using__ macro
  defmacro __using__(opts) do ... end

  # Public API
  def start_link(fun, options \\ []) do ... end
  def start(fun, options \\ []) do ... end
  def get(agent, fun, timeout \\ 5000) do ... end
  def get_and_update(agent, fun, timeout \\ 5000) do ... end
  def update(agent, fun, timeout \\ 5000) do ... end
  def cast(agent, fun) do ... end
  def stop(agent, reason \\ :normal, timeout \\ :infinity) do ... end
end

The order:

  1. @moduledoc
  2. Types
  3. child_spec / __using__
  4. start_link / start (lifecycle)
  5. Public API functions (alphabetical or logical grouping)
  6. stop (lifecycle end)

Anti-pattern: Mixing private helpers between public functions, or putting start_link at the bottom where supervisors have to hunt for it.

When to Use

Triggers:

  • You're writing a new module and need to decide function ordering
  • A module has grown organically and functions are scattered randomly
  • You're reviewing code and finding it hard to locate the public API

Example — before:

defmodule UserService do
  defp hash_password(pw), do: # ...

  def create(attrs) do
    # uses hash_password
  end

  def start_link(opts), do: GenServer.start_link(__MODULE__, opts)

  defp validate(attrs), do: # ...

  def get(id), do: # ...
end

Example — after:

defmodule UserService do
  # Lifecycle
  def start_link(opts), do: GenServer.start_link(__MODULE__, opts)

  # Public API
  def create(attrs), do: # ...
  def get(id), do: # ...

  # Private helpers
  defp validate(attrs), do: # ...
  defp hash_password(pw), do: # ...
end

When NOT to Use

Don't use this when:

  • You have a tiny module (< 5 functions) where ordering doesn't matter much
  • The module is a pure data module (just a struct + typespec)
  • "Logical grouping" puts closely related public+private pairs together for readability

Over-application example:

# Forcing start_link to the top in a module that isn't an OTP process
defmodule MyApp.Parser do
  # This module has no lifecycle — don't force OTP ordering
  def start_link(_), do: raise "not a process"  # Just to match the pattern?
  def parse(input), do: # ...
end

Better alternative:

defmodule MyApp.Parser do
  @moduledoc "Parses input format X into structs"

  def parse(input), do: # ...
  def parse!(input), do: # ...

  defp tokenize(input), do: # ...
end

Why: The ordering convention exists to make OTP-aware modules predictable. For non-OTP modules, lead with the primary public function (the one callers reach for first) and let the rest follow logically.


3. @moduledoc false for Internal Modules

Source: lib/phoenix/router/route.ex#L5

# This module defines the Route struct that is used
# throughout Phoenix's router. This struct is private
# as it contains internal routing information.
@moduledoc false

Why: Internal modules that exist for code organization but aren't part of the public API get @moduledoc false. They won't appear in generated documentation.

Anti-pattern: Documenting internal modules and confusing users about what's public API vs implementation detail.

When to Use

Triggers:

  • A module exists purely for internal code organization
  • Users of your library should never call this module directly
  • The module is a helper that could change or disappear between versions

Example — before:

defmodule MyApp.Repo.QueryBuilder do
  @moduledoc """
  Builds Ecto queries for the Repo module.
  """
  # Now appears in docs, users try to call it directly
end

Example — after:

defmodule MyApp.Repo.QueryBuilder do
  @moduledoc false
  # Hidden from docs, clearly internal
end

When NOT to Use

Don't use this when:

  • The module is part of your public API (even if rarely used)
  • Users need to implement callbacks or extend the module
  • The module defines a behaviour or protocol that others implement

Over-application example:

# Hiding a module that users actually need
defmodule MyApp.Errors do
  @moduledoc false  # But users need to pattern-match on these!

  defmodule NotFound do
    defexception [:message]
  end
end

Better alternative:

defmodule MyApp.Errors do
  @moduledoc "Error types raised by MyApp operations."

  defmodule NotFound do
    @moduledoc "Raised when a resource cannot be found."
    defexception [:message]
  end
end

Why: @moduledoc false means "this is not for you." If users catch your exceptions or match on your structs, they need documentation. Hide implementation details, not public contracts.


4. Struct Definition Conventions

Source: lib/elixir/lib/task.ex#L279

@enforce_keys [:mfa, :owner, :pid, :ref]
defstruct @enforce_keys

@type t :: %__MODULE__{
        mfa: mfa(),
        owner: pid(),
        pid: pid() | nil,
        ref: ref()
      }

Source: lib/phoenix/router/route.ex#L30

defstruct [
  :verb,
  :line,
  :kind,
  :path,
  :hosts,
  :plug,
  :plug_opts,
  :helper,
  :private,
  :pipe_through,
  :assigns,
  :metadata,
  :trailing_slash?,
  :warn_on_verify?
]

@type t :: %Route{}

Two patterns:

  1. Task: @enforce_keys + minimal struct — all fields required, enforced at creation
  2. Route: All optional fields listed — flexible construction

Why: Use @enforce_keys when a struct is meaningless without certain fields. Omit it for structs built incrementally.

Anti-pattern: Never using @enforce_keys — allows creating invalid structs that crash later when a required field is nil.

When to Use

Triggers:

  • Your struct has fields that make no sense as nil (creating one without them is a bug)
  • You're modeling a value object where all fields define its identity
  • Incomplete structs would cause confusing runtime errors later

Example — before:

defmodule Order do
  defstruct [:id, :customer_id, :items, :total]
  # Can create %Order{} with everything nil — meaningless
end

Example — after:

defmodule Order do
  @enforce_keys [:customer_id, :items, :total]
  defstruct [:id | @enforce_keys]
  # %Order{} without required fields -> immediate compile/runtime error
end

When NOT to Use

Don't use this when:

  • The struct is built incrementally (e.g., a changeset or builder pattern)
  • Most fields have sensible defaults
  • The struct represents configuration where partial specs are valid

Over-application example:

# Enforcing keys on a struct that's built in stages
defmodule FormState do
  @enforce_keys [:step, :name, :email, :address, :payment]
  defstruct @enforce_keys
  # Can't create a partial form state for step 1!
end

Better alternative:

defmodule FormState do
  defstruct step: 1, name: nil, email: nil, address: nil, payment: nil
  # Built incrementally as user progresses through steps
end

Why: @enforce_keys is for structs that represent complete values. If your struct represents an evolving state or has legitimate intermediate forms, enforcing all keys makes construction impossible at early stages.


5. Selective Imports in __using__

Source: lib/phoenix/channel.ex#L463

import unquote(__MODULE__)
import Phoenix.Socket, only: [assign: 3, assign: 2]

Source: lib/phoenix/router.ex#L271

import Phoenix.Router
import Plug.Conn
import Phoenix.Controller

Why: The use macro sets up the module's environment — importing the functions you'll need. Phoenix.Channel imports assign from Socket because channels work with sockets constantly.

Anti-pattern: Importing everything without restriction — namespace pollution and hard-to-trace function origins.

When to Use

Triggers:

  • Your use macro needs to give the caller access to specific functions
  • You want to control exactly which functions enter the caller's namespace
  • The imported functions are central to the DSL or workflow the module enables

Example — before:

defmacro __using__(_opts) do
  quote do
    # Imports EVERYTHING from three modules — namespace soup
    import MyApp.Router.Helpers
    import MyApp.Router.Scoping
    import MyApp.Router.Compilation
  end
end

Example — after:

defmacro __using__(_opts) do
  quote do
    import MyApp.Router, only: [get: 2, post: 2, resources: 2, scope: 2]
    import MyApp.Conn, only: [assign: 3, put_status: 2]
  end
end

When NOT to Use

Don't use this when:

  • The caller could just import what they need themselves
  • You're importing utility functions that aren't part of your module's "DSL"
  • The imports create naming conflicts with common functions

Over-application example:

defmacro __using__(_opts) do
  quote do
    import MyApp.Utils  # 50+ utility functions dumped into caller
    import Enum         # Why? Caller can do this themselves
    import Map          # Polluting namespace with standard lib
  end
end

Better alternative:

defmacro __using__(_opts) do
  quote do
    # Only import what THIS module's workflow requires
    import MyApp.DSL, only: [field: 2, validate: 1]
  end
end

Why: use should import the minimum needed for the module's intended workflow. If you're importing generic utilities, you're making decisions for the caller that they should make themselves.


6. Alias at Module Scope for Readability

Source: lib/phoenix/router.ex#L268

alias Phoenix.Router.{Resource, Scope, Route, Helpers}

Why: Multi-alias reduces repetition and groups related modules. The curly-brace syntax makes it clear these all share a parent namespace.

Anti-pattern: Using full module paths everywhere (Phoenix.Router.Resource.new(...)) — verbose and hard to read.

When to Use

Triggers:

  • Multiple modules from the same parent namespace are used together
  • Full module paths are making code hard to read
  • The aliased modules are used frequently (3+ times in the file)

Example — before:

def process(input) do
  Phoenix.Router.Route.new(input)
  |> Phoenix.Router.Scope.apply_scope(Phoenix.Router.Scope.current())
  |> Phoenix.Router.Helpers.generate()
end

Example — after:

alias Phoenix.Router.{Route, Scope, Helpers}

def process(input) do
  Route.new(input)
  |> Scope.apply_scope(Scope.current())
  |> Helpers.generate()
end

When NOT to Use

Don't use this when:

  • A module is referenced only once (inline the full path)
  • The alias would be ambiguous (two Route modules from different namespaces)
  • You're in a test file and the full path makes assertions clearer

Over-application example:

# Aliasing a module used exactly once
alias MyApp.Workers.BatchProcessor

def run do
  BatchProcessor.start()  # Only reference — alias adds noise
end

Better alternative:

def run do
  MyApp.Workers.BatchProcessor.start()  # One use — full path is fine
end

Why: Aliases trade verbosity for indirection. When a module appears once, the full path is documentation. When it appears many times, the alias is readability. Find the crossover point (typically 2-3 uses).


7. Boolean-Suffixed Fields in Structs

Source: lib/phoenix/router/route.ex#L43

:trailing_slash?,
:warn_on_verify?

Why: The ? suffix on struct fields mirrors the Elixir convention for boolean-returning functions. It makes the field's type obvious without checking the typespec.

Anti-pattern: Using bare names like :trailing_slash or :has_trailing_slash — the ? convention is more idiomatic and self-documenting.

When to Use

Triggers:

  • A struct field stores a boolean value
  • The field answers a yes/no question about the struct
  • You want the field's type to be self-evident without checking typespecs

Example — before:

defstruct [:path, :trailing_slash, :verified]
# Is :trailing_slash the slash character? A boolean? The position?

Example — after:

defstruct [:path, :trailing_slash?, :verified?]
# Immediately clear these are booleans

When NOT to Use

Don't use this when:

  • The field isn't a boolean (e.g., :status that can be :active/:inactive)
  • You're working with external serialization that can't handle ? in keys
  • The field represents a count, enum, or value rather than a yes/no question

Over-application example:

defstruct [:user?, :admin?, :count?]
# :user? — is this "is user present?" or "the user value"?
# :count? — definitely not a boolean

Better alternative:

defstruct [:user, :admin?, :count]
# :user is the user struct, :admin? is a boolean, :count is an integer

Why: The ? suffix should only mark genuine booleans. Using it on non-boolean fields creates confusion about the field's type and breaks the convention's usefulness as a type signal.

Decision Tree