docs: idiomatic Elixir and Phoenix patterns with source citations

Extracted patterns, conventions, and code smells directly from the
Elixir and Phoenix source code with file path and line number citations.

Covers: GenServer, error handling, data transforms, process design,
testing, documentation, typespecs, macros, behaviours, module organization,
Phoenix-specific patterns, framework deviations, and anti-patterns.
This commit is contained in:
Aaron Weiker
2026-04-29 22:50:12 -07:00
commit 4ea9a884aa
16 changed files with 4857 additions and 0 deletions
+151
View File
@@ -0,0 +1,151 @@
# Phoenix Deviations from Elixir Core
Where Phoenix deliberately differs from Elixir core patterns and why.
## 1. Heavy Macro Usage for Performance
**Elixir core philosophy:** Keep macro usage minimal. From the Router source:
> Phoenix does its best to keep the usage of macros low.
**Phoenix deviation:** The Router uses macros extensively.
**Source:** `lib/phoenix/router.ex:109-123`
> We use `get`, `post`, `put`, and `delete` to define your routes. We use macros
> for two purposes:
>
> * They define the routing engine... Phoenix compiles all of your routes to a
> single case-statement with pattern matching rules
>
> * For each route you define, we also define metadata to implement
> `Phoenix.VerifiedRoutes`
**Why the deviation:** Performance. Elixir core uses macros sparingly because they add cognitive complexity. Phoenix justifies them because routing is the hottest path in a web app — compile-time optimization yields measurable request/second gains.
---
## 2. `import` without Restriction in Router
**Elixir core pattern:** Always use `import Module, only: [...]` to be explicit.
**Phoenix deviation:** The Router imports entire modules:
**Source:** `lib/phoenix/router.ex:274-276`
```elixir
import Phoenix.Router
import Plug.Conn
import Phoenix.Controller
```
**Why the deviation:** The Router is a DSL. Users need `get`, `post`, `pipe_through`, `scope`, `resources`, `plug`, `fetch_session`, etc. — all available without qualification. Restricting imports would make the DSL unusable.
---
## 3. Compile-Time State Accumulation
**Elixir core pattern:** Modules are generally stateless during compilation. Functions are defined and that's it.
**Phoenix deviation:** Aggressive use of module attribute accumulation.
**Source:** `lib/phoenix/router.ex:271-280`
```elixir
Module.register_attribute(__MODULE__, :phoenix_routes, accumulate: true)
@phoenix_pipeline nil
Phoenix.Router.Scope.init(__MODULE__)
@before_compile unquote(__MODULE__)
```
**Why the deviation:** The Router needs to collect ALL routes, then compile them into a single dispatch function. This requires building up state during module compilation, then consuming it all at `@before_compile`.
---
## 4. Channel Restart Strategy: `:temporary`
**Elixir core GenServer default:** `:permanent` (always restart).
**Phoenix Channel default:** `:temporary` (never restart).
**Source:** `lib/phoenix/channel.ex:470-475`
```elixir
def child_spec(init_arg) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, [init_arg]},
shutdown: @phoenix_shutdown,
restart: :temporary
}
end
```
**Why the deviation:** A crashed channel should NOT auto-restart — the client needs to explicitly reconnect and rejoin. Auto-restarting would create a channel without a connected client, which is meaningless.
---
## 5. Auto-Hibernation
**Elixir core GenServer:** No default hibernation — processes stay in memory.
**Phoenix Channel:** Defaults to hibernate after 15 seconds of inactivity.
**Source:** `lib/phoenix/channel.ex:460`
```elixir
@phoenix_hibernate_after Keyword.get(opts, :hibernate_after, 15_000)
```
```elixir
def start_link(triplet) do
GenServer.start_link(Phoenix.Channel.Server, triplet,
hibernate_after: @phoenix_hibernate_after
)
end
```
**Why the deviation:** Web apps have many idle connections. Channels for users who are "connected but not active" are common. Hibernation reclaims memory for the heap without killing the process. A chat app with 10,000 connected users benefits enormously.
---
## 6. `Plug.Builder` vs Raw Behaviour
**Elixir core:** Behaviours define contracts. Implementations are manual.
**Phoenix Endpoint:** Uses `Plug.Builder` — a macro that generates the `call/2` pipeline by chaining plugs at compile time.
**Source:** `lib/phoenix/endpoint.ex:481-483`
```elixir
defp plug() do
quote location: :keep do
use Plug.Builder, init_mode: Phoenix.plug_init_mode()
...
end
end
```
**Why the deviation:** The Plug specification (`init/1` + `call/2`) is too low-level for composing dozens of middleware. `Plug.Builder` provides the `plug` macro that chains them automatically. It's a higher-level abstraction over the raw behaviour pattern.
---
## 7. Exception Structs with HTTP Status Codes
**Elixir core exceptions:** Pure data — message, maybe some context fields.
**Phoenix exceptions:** Include `plug_status` for HTTP response mapping.
**Source:** `lib/phoenix/router.ex:7-8`
```elixir
defmodule NoRouteError do
defexception plug_status: 404, message: "no route found", conn: nil, router: nil
end
defmodule MalformedURIError do
defexception [:message, plug_status: 400]
end
```
**Why the deviation:** In a web context, exceptions need to map to HTTP status codes. Plug's error handling middleware reads `plug_status` to determine the response code. This bridges the gap between Elixir's exception system and HTTP semantics.
+235
View File
@@ -0,0 +1,235 @@
# Phoenix Patterns
Patterns specific to Phoenix extracted from the framework source code.
## 1. Endpoint as Supervision Tree Root + Plug Pipeline
**Source:** `lib/phoenix/endpoint.ex:1-40` (moduledoc)
> The endpoint is the boundary where all requests to your web application start.
> It is also the interface your application provides to the underlying web servers.
>
> Overall, an endpoint has three responsibilities:
> - to provide a wrapper for starting and stopping the endpoint as part of a supervision tree
> - to define an initial plug pipeline for requests to pass through
> - to host web specific configuration for your application
**Source:** `lib/phoenix/endpoint.ex:408-418` (`__using__` macro)
```elixir
defmacro __using__(opts) do
quote do
@behaviour Phoenix.Endpoint
unquote(config(opts))
unquote(pubsub())
unquote(plug())
unquote(server())
end
end
```
The endpoint is four things composed together:
1. **Config** — compile-time and runtime configuration
2. **PubSub** — subscribe/broadcast interface
3. **Plug** — request pipeline (via `Plug.Builder`)
4. **Server** — supervision and HTTP server management
**Why:** The Endpoint is a supervisor, a plug pipeline, AND a configuration host — all in one module. This unification means one place to configure and start the entire web layer.
**Anti-pattern:** Splitting endpoint responsibilities across multiple unrelated modules — Phoenix deliberately consolidates the "boundary" concept.
---
## 2. Router: Compile-Time Route Optimization
**Source:** `lib/phoenix/router.ex:109-123` (Why the macros? info block)
> We use macros for two purposes:
>
> * They define the routing engine, used on every request, to choose which
> controller to dispatch the request to. Thanks to macros, Phoenix compiles
> all of your routes to a single case-statement with pattern matching rules,
> which is heavily optimized by the Erlang VM
>
> * For each route you define, we also define metadata to implement `Phoenix.VerifiedRoutes`.
**Source:** `lib/phoenix/router.ex:280` (route accumulation)
```elixir
Module.register_attribute(__MODULE__, :phoenix_routes, accumulate: true)
```
**Why:** Routes are defined with macros that accumulate route data at compile time. At `@before_compile`, all routes are compiled into a single pattern-match dispatch function. This is O(1) routing, not O(n) list scanning.
**Anti-pattern:** Runtime route tables (like maps or lists that are scanned per-request) — compile-time pattern matching is orders of magnitude faster.
---
## 3. Pipeline and `pipe_through` for Request Processing
**Source:** `lib/phoenix/router.ex:230-260` (Pipelines and plugs section)
```elixir
pipeline :browser do
plug :fetch_session
plug :accepts, ["html"]
end
scope "/" do
pipe_through :browser
# routes
end
```
**Why:** Pipelines are named, composable groups of plugs. Routes declare which pipelines they pass through. This separates concerns:
- Pipeline definition (what transformations exist)
- Route definition (which routes use which pipelines)
**Anti-pattern:** Putting plug logic directly in controllers or duplicating plug chains across routes.
---
## 4. Controller as Thin Dispatch Layer
**Source:** `lib/phoenix/controller.ex:28-45` (moduledoc examples)
```elixir
defmodule MyAppWeb.UserController do
use MyAppWeb, :controller
def show(conn, %{"id" => id}) do
user = Repo.get(User, id)
render(conn, :show, user: user)
end
end
```
Controllers:
- Pattern match on params (destructure what you need)
- Call domain logic (the Repo/context layer)
- Render the result
**Source:** `lib/phoenix/controller.ex:1-3` (imports)
```elixir
defmodule Phoenix.Controller do
import Plug.Conn
alias Plug.Conn.AlreadySentError
require Logger
```
**Why:** Controllers import `Plug.Conn` for connection manipulation. They're pluggable themselves — a controller IS a plug. The action is just the last step in the plug pipeline.
**Anti-pattern:** Fat controllers with business logic — controllers should delegate to context modules.
---
## 5. Channel as GenServer with Topic-Based Routing
**Source:** `lib/phoenix/channel.ex:1-20` (topic pattern)
```elixir
channel "room:*", MyAppWeb.RoomChannel
```
Then in the channel:
```elixir
def join("room:lobby", _payload, socket) do
{:ok, socket}
end
def join("room:" <> room_id, _payload, socket) do
{:ok, socket}
end
```
**Source:** `lib/phoenix/channel.ex:476-479` (channels are GenServers)
```elixir
def start_link(triplet) do
GenServer.start_link(Phoenix.Channel.Server, triplet,
hibernate_after: @phoenix_hibernate_after
)
end
```
**Why:** Each channel join creates a process. Pattern matching on the topic string provides natural routing. The GenServer backing means channels get supervision, hibernation, and all OTP semantics.
**Anti-pattern:** Managing channel state in shared ETS or external state — each channel IS its own process with its own state.
---
## 6. PubSub Integration via Endpoint
**Source:** `lib/phoenix/endpoint.ex:440-475` (pubsub macro)
```elixir
def subscribe(topic, opts \\ []) when is_binary(topic) do
Phoenix.PubSub.subscribe(pubsub_server!(), topic, opts)
end
def broadcast(topic, event, msg) do
Phoenix.Channel.Server.broadcast(pubsub_server!(), topic, event, msg)
end
defp pubsub_server! do
config(:pubsub_server) ||
raise ArgumentError, "no :pubsub_server configured for #{inspect(__MODULE__)}"
end
```
**Why:** PubSub is wired through the endpoint — `MyAppWeb.Endpoint.broadcast!("topic", "event", payload)`. The endpoint knows its pubsub server from config; channels broadcast through it transparently.
**Anti-pattern:** Passing PubSub server names around manually — the endpoint already knows and exposes the interface.
---
## 7. Socket as Authentication Boundary
**Source:** `lib/phoenix/socket.ex` (connect callback pattern)
```elixir
defmodule MyAppWeb.UserSocket do
use Phoenix.Socket
channel "room:*", MyAppWeb.RoomChannel
def connect(params, socket, _connect_info) do
{:ok, assign(socket, :user_id, params["user_id"])}
end
def id(socket), do: "users_socket:#{socket.assigns.user_id}"
end
```
**Why:** Authentication happens ONCE at socket connection. All channels on that socket inherit the authenticated identity. `id/1` enables targeted disconnection — `Endpoint.broadcast("users_socket:123", "disconnect", %{})`.
**Anti-pattern:** Authenticating in every `join/3` callback instead of at the socket level.
---
## 8. Plug Pattern: `init/1` + `call/2`
**Source:** `lib/phoenix/router/route.ex:51-58`
```elixir
@doc "Used as a plug on forwarding"
def init(opts), do: opts
@doc "Used as a plug on forwarding"
def call(%{path_info: path, script_name: script} = conn, {fwd_segments, plug, opts}) do
new_path = path -- fwd_segments
{base, ^new_path} = Enum.split(path, length(path) - length(new_path))
conn = %{conn | path_info: new_path, script_name: script ++ base}
conn = plug.call(conn, plug.init(opts))
%{conn | path_info: path, script_name: script}
end
```
**Why:** The Plug specification splits work into:
- `init/1` — compile-time setup (called once, result cached)
- `call/2` — runtime execution (called per-request, must be fast)
This is Phoenix's fundamental composition pattern. Everything is a plug.
**Anti-pattern:** Doing expensive setup work in `call/2` instead of `init/1` — it runs on every request.