- 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"
19 KiB
Module Organization Patterns
How modules are structured, named, and organized in Elixir core and Phoenix.
Contents
- One Module per Concept, Nested for Sub-Concepts
- Public API at the Top, Private Functions at the Bottom
@moduledoc falsefor Internal Modules- Struct Definition Conventions
- Selective Imports in
__using__ - Alias at Module Scope for Readability
- 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.Dis 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:
@moduledoc- Types
child_spec/__using__start_link/start(lifecycle)- Public API functions (alphabetical or logical grouping)
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:
- Task:
@enforce_keys+ minimal struct — all fields required, enforced at creation - 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
usemacro 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
importwhat 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
Routemodules 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.,
:statusthat 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
- If your module has grown beyond 300 lines with distinct sub-responsibilities → One Module per Concept, Nested for Sub-Concepts
- If you need to decide function ordering within a module → Public API at the Top, Private Functions at the Bottom
- If a module exists purely for internal code organization and should not appear in docs →
@moduledoc falsefor Internal Modules - If you need to define a struct and decide which fields are mandatory → Struct Definition Conventions
- If your
usemacro needs to set up the caller's namespace with specific functions → Selective Imports in__using__ - If multiple modules from the same parent namespace are used repeatedly → Alias at Module Scope for Readability
- If a struct field stores a boolean value and you want self-documenting naming → Boolean-Suffixed Fields in Structs