docs: add when/when-not to all Phoenix patterns

This commit is contained in:
Aaron Weiker
2026-04-30 06:47:10 -07:00
parent e04de487a2
commit 5de38d6fc4
3 changed files with 2260 additions and 0 deletions
+800
View File
@@ -14,6 +14,77 @@ How the same concepts are approached differently (or similarly) between Elixir c
**Source (Elixir):** `lib/elixir/lib/gen_server.ex:911-919` (child_spec defaults to :permanent via Supervisor.child_spec)
**Source (Phoenix):** `lib/phoenix/channel.ex:464-472` (explicit restart: :temporary)
### When to Use
**Triggers:**
- You are designing a new process and need to choose lifecycle semantics
- You are deciding between Registry-based identity vs topic-based identity
- You need to choose a supervision strategy for client-bound vs autonomous processes
**Example — before:**
```elixir
# Copying Elixir defaults without thinking about the domain
defmodule MyApp.WebSocketSession do
use GenServer
# restart: :permanent (default) — but this is client-bound!
# If it crashes, it restarts without a client → zombie process
def init({user_id, ws_pid}) do
{:ok, %{user_id: user_id, ws_pid: ws_pid}}
end
end
```
**Example — after:**
```elixir
# Choosing lifecycle semantics based on the domain
defmodule MyApp.WebSocketSession do
use GenServer, restart: :temporary, hibernate_after: 15_000
# temporary: client will reconnect if needed
# hibernate: many idle sessions expected
def init({user_id, ws_pid}) do
Process.monitor(ws_pid) # die when client disconnects
{:ok, %{user_id: user_id, ws_pid: ws_pid}}
end
end
```
### When NOT to Use
**Don't use this when:** You are blindly applying Phoenix's `:temporary` + hibernation to all processes regardless of their role.
**Over-application example:**
```elixir
# Bad: Applying channel-style lifecycle to a stateful worker
defmodule MyApp.OrderFulfillment do
use GenServer, restart: :temporary, hibernate_after: 15_000
# This process processes orders from a queue
# If it crashes, orders are lost (temporary = no restart)
# It's always active processing — hibernate never triggers
end
```
**Better alternative:**
```elixir
# Match lifecycle to the process's role
defmodule MyApp.OrderFulfillment do
use GenServer # restart: :permanent (default) — must survive crashes
# No hibernate_after — always active, processing queue items
def init(state) do
# On restart, resume from last checkpoint
{:ok, recover_state(state)}
end
end
```
**Why:** Phoenix's lifecycle choices (temporary, hibernation) are optimized for client-bound, mostly-idle processes. Autonomous workers that own state need permanent restart and don't benefit from hibernation.
---
## Error Handling
@@ -28,6 +99,98 @@ How the same concepts are approached differently (or similarly) between Elixir c
**Source (Elixir):** `lib/elixir/lib/agent.ex:187` (standard on_start type: `{:ok, pid} | {:error, ...}`)
**Source (Phoenix):** `lib/phoenix/router.ex:2-6` (NoRouteError with plug_status: 404)
### When to Use
**Triggers:**
- You are defining error types for a web application
- You need to decide between `{:error, reason}` tuples vs raising exceptions
- You want consistent error semantics across HTTP and internal code
**Example — before:**
```elixir
# Mixing error strategies inconsistently
defmodule MyApp.Accounts do
def get_user(id) do
case Repo.get(User, id) do
nil -> raise "User not found" # Raises in domain code — forces rescue everywhere
user -> user
end
end
def update_user(user, attrs) do
# Returns tuple here but raised above — inconsistent
User.changeset(user, attrs) |> Repo.update()
end
end
```
**Example — after:**
```elixir
# Elixir-style: tuples in domain, exceptions at boundaries
defmodule MyApp.Accounts do
def get_user(id) do
case Repo.get(User, id) do
nil -> {:error, :not_found}
user -> {:ok, user}
end
end
def get_user!(id) do
Repo.get!(User, id) # Bang version raises — for use at boundaries
end
def update_user(user, attrs) do
User.changeset(user, attrs) |> Repo.update()
end
end
# Controller uses bang (boundary) or handles tuple
defmodule MyAppWeb.UserController do
def show(conn, %{"id" => id}) do
user = Accounts.get_user!(id) # Raises → 404 via ErrorView
render(conn, :show, user: user)
end
end
```
### When NOT to Use
**Don't use this when:** You're embedding HTTP semantics deep in domain logic, or when you're using exceptions for control flow in non-error paths.
**Over-application example:**
```elixir
# Bad: Using exceptions for expected business outcomes
defmodule MyApp.Checkout do
def apply_coupon(cart, code) do
case Coupons.validate(code) do
{:ok, coupon} -> {:ok, apply_discount(cart, coupon)}
{:error, :expired} -> raise %CouponExpiredError{plug_status: 422}
{:error, :invalid} -> raise %CouponInvalidError{plug_status: 422}
# Expired/invalid coupons are expected outcomes, not exceptions!
end
end
end
```
**Better alternative:**
```elixir
# Return tuples for expected outcomes — only raise for truly exceptional cases
defmodule MyApp.Checkout do
def apply_coupon(cart, code) do
case Coupons.validate(code) do
{:ok, coupon} -> {:ok, apply_discount(cart, coupon)}
{:error, reason} -> {:error, reason} # Let controller decide HTTP response
end
end
end
```
**Why:** Exceptions should be exceptional. Expected business outcomes (invalid coupon, insufficient funds, duplicate email) are not bugs — they're normal paths that should return tuples. Reserve exceptions for truly unexpected states.
---
## Behaviour Design
@@ -42,6 +205,95 @@ How the same concepts are approached differently (or similarly) between Elixir c
**Source (Elixir):** `lib/elixir/lib/gen_server.ex:899-919` (__using__ generates child_spec + @behaviour)
**Source (Phoenix):** `lib/phoenix/channel.ex:450-485` (__using__ generates child_spec + behaviour + DSL setup)
### When to Use
**Triggers:**
- You are designing a callback-based module (plugin system, handler framework)
- You need to decide which callbacks should be required vs optional
- You are choosing between minimal `__using__` (Elixir-style) vs rich `__using__` (Phoenix-style)
**Example — before:**
```elixir
# Over-specifying required callbacks — burdens implementers
defmodule MyApp.Handler do
@callback init(opts :: keyword()) :: {:ok, state :: term()}
@callback handle_event(event :: term(), state :: term()) :: {:ok, state :: term()}
@callback handle_error(error :: term(), state :: term()) :: {:ok, state :: term()}
@callback terminate(reason :: term(), state :: term()) :: :ok
@callback format_status(state :: term()) :: term()
# All required — implementers must define 5 functions even for simple cases
end
```
**Example — after:**
```elixir
# Minimal required callbacks + sensible defaults (Phoenix-style)
defmodule MyApp.Handler do
@callback handle_event(event :: term(), state :: term()) :: {:ok, state :: term()}
# Only the essential callback is required
@optional_callbacks [handle_error: 2, terminate: 2]
defmacro __using__(opts) do
quote do
@behaviour MyApp.Handler
# Default implementations — override only what you need
def handle_error(error, state) do
Logger.error("Unhandled error: #{inspect(error)}")
{:ok, state}
end
def terminate(_reason, _state), do: :ok
defoverridable handle_error: 2, terminate: 2
end
end
end
```
### When NOT to Use
**Don't use this when:** Every implementation genuinely needs all callbacks (no sensible defaults exist), or when `__using__` would generate so much code that the user can't understand what their module does.
**Over-application example:**
```elixir
# Bad: __using__ that generates everything — user's module is empty
defmodule MyApp.MagicModule do
defmacro __using__(_opts) do
quote do
# Generates 200 lines of functions, imports, attributes
# The user's module is just `use MyApp.MagicModule` with nothing else
# Impossible to understand, debug, or customize
end
end
end
```
**Better alternative:**
```elixir
# Generate only the boilerplate; leave the interesting code to the user
defmodule MyApp.Handler do
defmacro __using__(opts) do
quote do
@behaviour MyApp.Handler
@impl true
def child_spec(init_arg) do
# Only generating the standard boilerplate
%{id: __MODULE__, start: {__MODULE__, :start_link, [init_arg]}}
end
defoverridable child_spec: 1
end
end
end
```
**Why:** `__using__` should reduce boilerplate, not hide architecture. If a user can't tell what their module does without reading the macro source, the abstraction has gone too far.
---
## Macro Usage
@@ -56,6 +308,72 @@ How the same concepts are approached differently (or similarly) between Elixir c
**Source (Elixir):** `lib/elixir/lib/gen_server.ex:899` — simple `__using__` (behaviour + child_spec + defaults)
**Source (Phoenix):** `lib/phoenix/router.ex:288-312` — complex DSL setup with attribute accumulation, imports, and @before_compile
### When to Use
**Triggers:**
- You need compile-time code generation for performance (hot path optimization)
- You are building a user-facing DSL where macros enable a natural syntax
- The alternative (runtime dispatch, dynamic functions) has measurable performance cost
**Example — before:**
```elixir
# Runtime dispatch — acceptable for most code
defmodule MyApp.Serializer do
@formats %{json: Jason, msgpack: Msgpax, csv: NimbleCSV}
def encode(data, format) do
module = Map.fetch!(@formats, format)
module.encode!(data)
end
end
```
**Example — after:**
```elixir
# Compile-time dispatch — justified if called millions of times/sec
defmodule MyApp.Serializer do
@formats %{json: Jason, msgpack: Msgpax, csv: NimbleCSV}
for {format, module} <- @formats do
def encode(data, unquote(format)) do
unquote(module).encode!(data)
end
end
end
```
### When NOT to Use
**Don't use this when:** A function would work fine, the code path isn't hot, or the macro makes error messages and stack traces harder to understand.
**Over-application example:**
```elixir
# Bad: Macro for something called once at startup
defmacro configure(opts) do
quote do
@config unquote(opts)
def config, do: @config
end
end
# Used once:
configure(database: "myapp", pool_size: 10)
```
**Better alternative:**
```elixir
# A function — simpler, debuggable, no compile-time complexity
def config do
%{database: "myapp", pool_size: 10}
end
```
**Why:** Every macro is a bet that compile-time complexity pays for itself in runtime performance or developer ergonomics. If neither benefit materializes (cold path, simple structure), the macro just adds confusion.
---
## Module Organization
@@ -69,6 +387,70 @@ How the same concepts are approached differently (or similarly) between Elixir c
Both follow the same convention: public API on the parent module, implementation details in nested submodules with `@moduledoc false`.
### When to Use
**Triggers:**
- You are organizing a library or application into modules
- You need to decide what's public API vs internal implementation
- You are choosing module nesting depth
**Example — before:**
```elixir
# Flat structure — everything exposed, no clear boundaries
defmodule MyApp.Auth do ... end
defmodule MyApp.AuthToken do ... end
defmodule MyApp.AuthSession do ... end
defmodule MyApp.AuthHelpers do ... end
defmodule MyApp.AuthPasswordReset do ... end
# Which ones are public API? Which are implementation details?
```
**Example — after:**
```elixir
# Nested with clear public/private boundaries
defmodule MyApp.Auth do
@moduledoc "Public authentication API"
# Public: login/2, logout/1, current_user/1
end
defmodule MyApp.Auth.Token do
@moduledoc false # Internal — used by Auth, not called directly
end
defmodule MyApp.Auth.Session do
@moduledoc false # Internal
end
```
### When NOT to Use
**Don't use this when:** Nesting creates deeply nested modules (4+ levels) that are hard to reference, or when flat organization genuinely reflects the lack of hierarchy.
**Over-application example:**
```elixir
# Bad: Excessive nesting — hard to type, hard to alias
defmodule MyApp.Accounts.Users.Authentication.Strategies.OAuth.Google.Callback do
# 7 levels deep — unmanageable
end
```
**Better alternative:**
```elixir
# 2-3 levels max — clear but manageable
defmodule MyApp.Accounts.OAuth do
@moduledoc "OAuth authentication strategies"
def google_callback(params), do: # ...
def github_callback(params), do: # ...
end
```
**Why:** Module nesting should reflect logical containment, not directory structure. Beyond 3 levels, the module names become unwieldy and the hierarchy stops communicating useful information.
---
## State Management
@@ -83,6 +465,102 @@ Both follow the same convention: public API on the parent module, implementation
**Source (Elixir):** `lib/elixir/lib/agent.ex:62-82` (compute in server vs client pattern)
**Source (Phoenix):** `lib/phoenix/channel.ex:463` (`import Phoenix.Socket, only: [assign: 3, assign: 2]`)
### When to Use
**Triggers:**
- You are choosing a state management approach for a new process
- You need to decide between free-form state vs structured assigns
- You are designing a framework/library that manages state on behalf of users
**Example — before:**
```elixir
# Unstructured state — grows into a mess
defmodule MyApp.ChatRoom do
use GenServer
def init(_) do
{:ok, %{}} # What goes here? Nobody knows until they read all handlers
end
def handle_call(:get_users, _from, state) do
{:reply, state.users, state} # Hope `users` key exists...
end
def handle_cast({:add_message, msg}, state) do
messages = [msg | state[:messages] || []]
{:noreply, Map.put(state, :messages, messages)}
end
end
```
**Example — after:**
```elixir
# Structured state with assigns pattern (Phoenix-style)
defmodule MyApp.ChatRoom do
use GenServer
defstruct [:room_id, users: MapSet.new(), messages: []]
def init(room_id) do
{:ok, %__MODULE__{room_id: room_id}}
end
def handle_call(:get_users, _from, %{users: users} = state) do
{:reply, MapSet.to_list(users), state}
end
def handle_cast({:add_message, msg}, state) do
{:noreply, %{state | messages: [msg | state.messages]}}
end
end
```
### When NOT to Use
**Don't use this when:** The state is genuinely simple (a counter, a single value) and a struct adds unnecessary ceremony.
**Over-application example:**
```elixir
# Bad: Over-engineering state for a simple counter
defmodule MyApp.Counter do
use GenServer
defstruct [:name, :namespace, :created_at, value: 0, history: [], metadata: %{}]
def init(opts) do
{:ok, %__MODULE__{
name: opts[:name],
namespace: opts[:namespace] || :default,
created_at: DateTime.utc_now(),
metadata: %{version: 1}
}}
end
# All this for increment/decrement...
end
```
**Better alternative:**
```elixir
# Simple state for simple needs
defmodule MyApp.Counter do
use Agent
def start_link(initial \\ 0) do
Agent.start_link(fn -> initial end)
end
def increment(counter), do: Agent.update(counter, &(&1 + 1))
def value(counter), do: Agent.get(counter, & &1)
end
```
**Why:** State structure should match problem complexity. A counter doesn't need a struct. A chat room with users, messages, and metadata does. Match the tool to the job.
---
## Documentation
@@ -97,6 +575,104 @@ Both follow the same convention: public API on the parent module, implementation
Both use the same documentation infrastructure (ExDoc), but Elixir core tends toward more exhaustive docs (GenServer's moduledoc is essentially a tutorial).
### When to Use
**Triggers:**
- You are writing a public API (library or shared module)
- You need to decide between doctests vs example blocks
- You are structuring documentation for a complex module
**Example — before:**
```elixir
# Minimal docs — users have to read source to understand
defmodule MyApp.Cache do
@moduledoc "A cache."
@doc "Gets a value."
def get(key), do: # ...
@doc "Puts a value."
def put(key, value), do: # ...
end
```
**Example — after:**
```elixir
# Rich docs with examples and context (Elixir core style)
defmodule MyApp.Cache do
@moduledoc """
An in-memory cache with TTL support.
## Usage
cache = MyApp.Cache.start_link(ttl: :timer.minutes(5))
MyApp.Cache.put(cache, "key", "value")
MyApp.Cache.get(cache, "key")
#=> {:ok, "value"}
## Options
* `:ttl` - Time-to-live in milliseconds (default: 60_000)
* `:max_size` - Maximum entries (default: 1000)
## Eviction
When `max_size` is exceeded, the oldest entries are evicted first (FIFO).
"""
@doc """
Gets a value by key.
Returns `{:ok, value}` if found, `:error` if missing or expired.
## Examples
iex> {:ok, cache} = MyApp.Cache.start_link([])
iex> MyApp.Cache.put(cache, "k", "v")
iex> MyApp.Cache.get(cache, "k")
{:ok, "v"}
iex> MyApp.Cache.get(cache, "missing")
:error
"""
def get(cache, key), do: # ...
end
```
### When NOT to Use
**Don't use this when:** The module is internal (`@moduledoc false`), or when docs would just restate the function name.
**Over-application example:**
```elixir
# Bad: Docs that add no information
defmodule MyApp.Internal.Helper do
@moduledoc "Internal helper module."
@doc "Adds two numbers."
def add(a, b), do: a + b
@doc "Subtracts b from a."
def subtract(a, b), do: a - b
end
```
**Better alternative:**
```elixir
# Internal module — skip the ceremony
defmodule MyApp.Internal.Helper do
@moduledoc false
def add(a, b), do: a + b
def subtract(a, b), do: a - b
end
```
**Why:** Documentation exists to help users understand non-obvious behavior. If the function signature already communicates everything, docs are noise. Internal modules don't need public-facing documentation.
---
## Configuration
@@ -116,6 +692,74 @@ var!(code_reloading?) =
This pattern — reading config at compile time and validating it against runtime — is Phoenix-specific. Elixir core reads config only at runtime.
### When to Use
**Triggers:**
- You need compile-time decisions (code generation, conditional compilation)
- You want to catch configuration errors at build time, not production runtime
- You have config that truly cannot change after compilation (module structure, generated functions)
**Example — before:**
```elixir
# Runtime config check on every call — wasteful for static decisions
defmodule MyApp.Mailer do
def deliver(email) do
if Application.get_env(:my_app, :enable_emails, true) do
# Actually send
HTTPClient.post(email)
else
# Dev mode — just log
Logger.info("Would send: #{inspect(email)}")
end
end
end
```
**Example — after:**
```elixir
# Compile-time decision — no runtime branch for static config
defmodule MyApp.Mailer do
@send_emails Application.compile_env(:my_app, :enable_emails, true)
if @send_emails do
def deliver(email), do: HTTPClient.post(email)
else
def deliver(email), do: Logger.info("Would send: #{inspect(email)}")
end
end
```
### When NOT to Use
**Don't use this when:** The config might change at runtime (feature flags, environment variables read at startup), or when you need different behavior across nodes in a release.
**Over-application example:**
```elixir
# Bad: Compile-time config for something that should be toggleable
defmodule MyApp.FeatureFlags do
@dark_mode Application.compile_env(:my_app, :dark_mode, false)
# Can't toggle dark mode without recompiling and redeploying!
def dark_mode_enabled?, do: @dark_mode
end
```
**Better alternative:**
```elixir
# Runtime config for things that change
defmodule MyApp.FeatureFlags do
def dark_mode_enabled? do
Application.get_env(:my_app, :dark_mode, false)
end
end
```
**Why:** `compile_env` bakes the value into the BEAM bytecode. It's correct for structural decisions (which modules to compile, which code paths to include) but wrong for operational toggles that need to change without redeployment.
---
## Telemetry
@@ -132,6 +776,82 @@ This pattern — reading config at compile time and validating it against runtim
Phoenix wraps every request dispatch in telemetry start/stop/exception events. This provides distributed tracing, monitoring, and logging without any application code changes.
### When to Use
**Triggers:**
- You are building a library or framework that others will monitor
- You want to provide observability hooks without coupling to specific monitoring tools
- You need structured event emission at well-defined lifecycle points
**Example — before:**
```elixir
# Coupled to Logger — users can't plug in Prometheus/Datadog
defmodule MyApp.Queue do
require Logger
def process(job) do
start = System.monotonic_time()
result = do_work(job)
duration = System.monotonic_time() - start
Logger.info("Job #{job.id} completed in #{duration}ns")
result
end
end
```
**Example — after:**
```elixir
# Telemetry events — any monitoring tool can attach
defmodule MyApp.Queue do
def process(job) do
start = System.monotonic_time()
metadata = %{job_id: job.id, queue: job.queue}
:telemetry.execute([:my_app, :queue, :start], %{system_time: System.system_time()}, metadata)
result = do_work(job)
duration = System.monotonic_time() - start
:telemetry.execute([:my_app, :queue, :stop], %{duration: duration}, metadata)
result
rescue
e ->
:telemetry.execute([:my_app, :queue, :exception], %{duration: System.monotonic_time() - start}, metadata)
reraise e, __STACKTRACE__
end
end
```
### When NOT to Use
**Don't use this when:** You just need simple logging for debugging, or when the overhead of telemetry events isn't justified (internal helpers called rarely).
**Over-application example:**
```elixir
# Bad: Telemetry on a trivial helper function
defmodule MyApp.StringUtils do
def capitalize_name(name) do
:telemetry.execute([:my_app, :string_utils, :capitalize, :start], %{}, %{})
result = String.capitalize(name)
:telemetry.execute([:my_app, :string_utils, :capitalize, :stop], %{}, %{})
result
end
end
```
**Better alternative:**
```elixir
# Just a function — no instrumentation needed
defmodule MyApp.StringUtils do
def capitalize_name(name), do: String.capitalize(name)
end
```
**Why:** Telemetry adds function call overhead and complexity. It's justified at boundaries (HTTP requests, DB queries, queue processing) where measurements drive operational decisions. Pure utility functions don't need observability hooks.
---
## Testing
@@ -147,3 +867,83 @@ Phoenix wraps every request dispatch in telemetry start/stop/exception events. T
**Source (Phoenix):** `lib/phoenix/test/channel_test.ex:1-30` (process-based channel testing)
Phoenix test helpers test at the integration level by default — `ConnTest` dispatches through the full plug pipeline, `ChannelTest` exercises the full channel lifecycle via message passing. This catches middleware bugs that unit tests miss.
### When to Use
**Triggers:**
- You are testing Phoenix controllers, channels, or LiveViews
- You want to verify the full request/response cycle including middleware
- You need to test auth, CSRF, session handling, and content negotiation together
**Example — before:**
```elixir
# Testing at the wrong level — too low for web, misses middleware
defmodule MyAppWeb.ApiTest do
use ExUnit.Case
test "returns user data" do
# Calling controller directly — bypasses auth, rate limiting, CORS
conn = Phoenix.ConnTest.build_conn()
result = MyAppWeb.ApiController.show(conn, %{"id" => "1"})
assert result.status == 200
end
end
```
**Example — after:**
```elixir
# Testing at the right level — full integration through endpoint
defmodule MyAppWeb.ApiTest do
use MyAppWeb.ConnCase
test "returns 401 without auth token" do
conn = get(build_conn(), ~p"/api/users/1")
assert json_response(conn, 401)
end
test "returns user data with valid token" do
user = insert(:user)
conn =
build_conn()
|> put_req_header("authorization", "Bearer #{generate_token(user)}")
|> get(~p"/api/users/#{user}")
assert %{"id" => id, "name" => name} = json_response(conn, 200)
assert id == user.id
end
end
```
### When NOT to Use
**Don't use this when:** You are testing pure business logic, schema validations, or context functions that have no HTTP concerns.
**Over-application example:**
```elixir
# Bad: Using ConnCase for everything, even non-HTTP logic
defmodule MyApp.MathTest do
use MyAppWeb.ConnCase # Starts endpoint, sets up sandbox — all unnecessary
test "adds numbers" do
assert MyApp.Math.add(1, 2) == 3
end
end
```
**Better alternative:**
```elixir
# Use the lightest test case that works
defmodule MyApp.MathTest do
use ExUnit.Case, async: true
test "adds numbers" do
assert MyApp.Math.add(1, 2) == 3
end
end
```
**Why:** `ConnCase` starts the endpoint supervisor, sets up the Ecto sandbox, and configures HTTP testing infrastructure. For pure functions, that's wasted setup time and obscured intent. Use `ExUnit.Case` (or `DataCase` for DB tests) when HTTP isn't involved.
+519
View File
@@ -23,6 +23,81 @@ Where Phoenix deliberately differs from Elixir core patterns and why.
**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.
### When to Use
**Triggers:**
- You are writing a library where the hot path is called millions of times
- Compile-time knowledge can eliminate runtime branching entirely
- The macro generates a known, bounded set of functions (not arbitrary code)
**Example — before:**
```elixir
# Runtime dispatch — works but slow on hot paths
defmodule MyApp.Permissions do
@permissions [:read, :write, :delete, :admin]
def check(user, permission) do
if permission in @permissions do
MapSet.member?(user.permissions, permission)
else
raise "Unknown permission: #{permission}"
end
end
end
```
**Example — after:**
```elixir
# Compile-time generated pattern-match functions — O(1) dispatch
defmodule MyApp.Permissions do
@permissions [:read, :write, :delete, :admin]
for perm <- @permissions do
def check(user, unquote(perm)) do
MapSet.member?(user.permissions, unquote(perm))
end
end
def check(_user, unknown), do: raise "Unknown permission: #{unknown}"
end
```
### When NOT to Use
**Don't use this when:** The performance gain is negligible (cold paths, admin screens), or when the same result can be achieved with pattern matching or maps.
**Over-application example:**
```elixir
# Bad: Macros for a configuration screen hit once per admin session
defmodule MyApp.Admin.Settings do
# Generating functions for 3 settings that change once a month
for {key, default} <- [theme: "light", locale: "en", tz: "UTC"] do
defmacro unquote(:"get_#{key}")(user) do
# Over-engineered — this is called once when an admin loads settings
quote do: Map.get(unquote(user).settings, unquote(unquote(key)), unquote(unquote(default)))
end
end
end
```
**Better alternative:**
```elixir
# Plain function — clear, testable, fast enough for cold paths
defmodule MyApp.Admin.Settings do
@defaults %{theme: "light", locale: "en", tz: "UTC"}
def get(user, key) when is_map_key(@defaults, key) do
Map.get(user.settings, key, @defaults[key])
end
end
```
**Why:** Macros add compile-time complexity, make stack traces harder to read, and complicate debugging. They're justified only when the performance difference matters at scale (thousands+ calls/second).
---
## 2. `import` without Restriction in Router
@@ -43,6 +118,83 @@ 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.
### When to Use
**Triggers:**
- You are building a DSL where users need many functions from your module
- The imported functions are the primary API (not helpers leaking into scope)
- Qualified calls would make the DSL unreadable
**Example — before:**
```elixir
# With restricted imports — DSL becomes unreadable
defmodule MyAppWeb.Router do
import Phoenix.Router, only: [get: 3, post: 3, scope: 2, pipe_through: 1, pipeline: 2, plug: 1, plug: 2, resources: 3]
import Plug.Conn, only: [fetch_session: 2, put_secure_browser_headers: 2]
Phoenix.Router.pipeline :browser do
Phoenix.Router.plug :fetch_session
# ...
end
end
```
**Example — after:**
```elixir
# Full import — DSL reads naturally
defmodule MyAppWeb.Router do
use Phoenix.Router
pipeline :browser do
plug :fetch_session
plug :accepts, ["html"]
end
scope "/", MyAppWeb do
pipe_through :browser
get "/", PageController, :home
resources "/users", UserController
end
end
```
### When NOT to Use
**Don't use this when:** You are importing a utility module where only 1-2 functions are needed, or in application code (not DSL contexts).
**Over-application example:**
```elixir
# Bad: Blanket import of a utility module in application code
defmodule MyApp.Workers.DataProcessor do
import Enum # pulls in 80+ functions
import String # pulls in 50+ functions
import Map # pulls in 30+ functions
def process(data) do
data |> map(&transform/1) |> filter(&valid?/1)
# Which `map` is this? Enum.map or Map... confusion
end
end
```
**Better alternative:**
```elixir
# Explicit imports in application code
defmodule MyApp.Workers.DataProcessor do
import Enum, only: [map: 2, filter: 2]
def process(data) do
data |> map(&transform/1) |> filter(&valid?/1)
end
end
```
**Why:** Unrestricted imports in application code create namespace collisions and make code harder to trace. The DSL exception exists because DSLs are designed to be a controlled vocabulary — application code is not.
---
## 3. Compile-Time State Accumulation
@@ -73,6 +225,101 @@ end
**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`.
### When to Use
**Triggers:**
- You need to collect declarations across a module and process them holistically
- The final output (generated function) depends on ALL accumulated items together
- Individual declarations can't be compiled in isolation — they need global context
**Example — before:**
```elixir
# Without accumulation — each handler is independent, no holistic optimization
defmodule MyApp.EventRouter do
def handle("user.created", payload), do: UserHandler.created(payload)
def handle("user.deleted", payload), do: UserHandler.deleted(payload)
def handle("order.placed", payload), do: OrderHandler.placed(payload)
# Works, but can't generate a dispatch table or validate at compile time
end
```
**Example — after:**
```elixir
# With accumulation — collect all events, compile into optimized dispatch
defmodule MyApp.EventRouter do
use MyApp.EventRouter.DSL
handle "user.created", UserHandler, :created
handle "user.deleted", UserHandler, :deleted
handle "order.placed", OrderHandler, :placed
end
# The DSL accumulates handlers and generates optimized dispatch at @before_compile
defmodule MyApp.EventRouter.DSL do
defmacro __using__(_opts) do
quote do
Module.register_attribute(__MODULE__, :event_handlers, accumulate: true)
import MyApp.EventRouter.DSL, only: [handle: 3]
@before_compile MyApp.EventRouter.DSL
end
end
defmacro handle(event, module, function) do
quote do
@event_handlers {unquote(event), unquote(module), unquote(function)}
end
end
defmacro __before_compile__(env) do
handlers = Module.get_attribute(env.module, :event_handlers)
for {event, mod, fun} <- handlers do
quote do
def dispatch(unquote(event), payload), do: unquote(mod).unquote(fun)(payload)
end
end
end
end
```
### When NOT to Use
**Don't use this when:** Each item can be compiled independently, or when a simple map/config file would suffice.
**Over-application example:**
```elixir
# Bad: Using compile-time accumulation for static config
defmodule MyApp.FeatureFlags do
Module.register_attribute(__MODULE__, :flags, accumulate: true)
@flags {:dark_mode, true}
@flags {:beta_search, false}
@flags {:new_checkout, true}
@before_compile __MODULE__
# Over-engineered — these are just config values
end
```
**Better alternative:**
```elixir
# A map is simpler and more flexible (can be loaded from config/env)
defmodule MyApp.FeatureFlags do
@flags %{
dark_mode: true,
beta_search: false,
new_checkout: true
}
def enabled?(flag), do: Map.get(@flags, flag, false)
end
```
**Why:** Compile-time accumulation adds cognitive overhead and makes the module harder to understand. If you don't need holistic processing of all items together (no optimized dispatch, no cross-item validation), a plain data structure is clearer.
---
## 4. Channel Restart Strategy: `:temporary`
@@ -96,6 +343,80 @@ 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.
### When to Use
**Triggers:**
- The process's existence only makes sense while an external connection is active
- Restarting without the original context (socket, params, auth) would be meaningless
- The client has reconnection logic that will re-establish the process naturally
**Example — before:**
```elixir
# Bad: Default permanent restart for a session-bound process
defmodule MyApp.GameSession do
use GenServer
# Default child_spec → restart: :permanent
# If this crashes, it restarts without the player's connection — broken state
def start_link({player_id, game_id}) do
GenServer.start_link(__MODULE__, {player_id, game_id})
end
end
```
**Example — after:**
```elixir
# Correct: Temporary restart — client reconnects and re-creates
defmodule MyApp.GameSession do
use GenServer, restart: :temporary
def start_link({player_id, game_id}) do
GenServer.start_link(__MODULE__, {player_id, game_id})
end
end
```
### When NOT to Use
**Don't use this when:** The process owns durable state that must survive crashes (e.g., a stateful worker processing a queue, an aggregator collecting metrics).
**Over-application example:**
```elixir
# Bad: Temporary restart on a process that owns critical state
defmodule MyApp.InvoiceGenerator do
use GenServer, restart: :temporary
# If this crashes mid-generation, the work is lost forever
# No client will "reconnect" to restart it
def handle_cast({:generate, invoice_id}, state) do
# Long-running PDF generation...
result = generate_pdf(invoice_id)
store_result(result)
{:noreply, state}
end
end
```
**Better alternative:**
```elixir
# Permanent restart — crash recovery is handled by OTP
defmodule MyApp.InvoiceGenerator do
use GenServer # restart: :permanent (default)
def init(state) do
# On restart, check for incomplete work and resume
incomplete = Jobs.find_incomplete(:invoice_generation)
{:ok, %{state | pending: incomplete}}
end
end
```
**Why:** `:temporary` means "if it dies, it's gone." That's correct for client-bound processes (channels, sessions) but dangerous for processes that own work. If no external actor will re-trigger the process, use `:permanent` or `:transient`.
---
## 5. Auto-Hibernation
@@ -120,6 +441,78 @@ 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.
### When to Use
**Triggers:**
- Many processes spend most of their time idle (connected but waiting)
- Process count is high (thousands+) and memory pressure matters
- Latency on the first message after idle is acceptable (GC overhead on wake)
**Example — before:**
```elixir
# Default GenServer — 10,000 idle processes each holding heap memory
defmodule MyApp.UserPresence do
use GenServer
def start_link(user_id) do
GenServer.start_link(__MODULE__, %{user_id: user_id, last_seen: DateTime.utc_now()})
end
# Each idle process keeps its heap allocated even if doing nothing
end
```
**Example — after:**
```elixir
# With hibernation — idle processes release heap memory
defmodule MyApp.UserPresence do
use GenServer, hibernate_after: 15_000
def start_link(user_id) do
GenServer.start_link(__MODULE__, %{user_id: user_id, last_seen: DateTime.utc_now()},
hibernate_after: 15_000
)
end
# After 15s idle, heap is compacted — memory reclaimed
end
```
### When NOT to Use
**Don't use this when:** The process is frequently active (messages every few seconds), or when wake-up latency is unacceptable (real-time processing pipelines).
**Over-application example:**
```elixir
# Bad: Hibernation on a process that receives messages every 100ms
defmodule MyApp.MetricsCollector do
use GenServer, hibernate_after: 5_000
# Receives telemetry events every 100ms — will never actually hibernate
# The hibernate_after timer just adds overhead resetting on every message
def handle_info({:telemetry, _event}, state) do
{:noreply, update_metrics(state)}
end
end
```
**Better alternative:**
```elixir
# Don't set hibernate_after on high-throughput processes
defmodule MyApp.MetricsCollector do
use GenServer
# No hibernation — this process is always active, never idle
def handle_info({:telemetry, _event}, state) do
{:noreply, update_metrics(state)}
end
end
```
**Why:** Hibernation has a cost: waking from hibernate requires a full GC pass to reconstruct the heap. For processes that are always active, the timer reset overhead and impossible hibernation provide no benefit.
---
## 6. `Plug.Builder` vs Raw Behaviour
@@ -141,6 +534,69 @@ 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.
### When to Use
**Triggers:**
- You are composing multiple plugs into a pipeline
- You want the `plug` macro for declarative middleware ordering
- You need automatic `init/1` caching and pipeline compilation
**Example — before:**
```elixir
# Manual plug chaining — error-prone, verbose
defmodule MyApp.Pipeline do
@behaviour Plug
def init(opts), do: opts
def call(conn, _opts) do
conn
|> Plug.RequestId.call(Plug.RequestId.init([]))
|> Plug.Logger.call(Plug.Logger.init([]))
|> MyApp.Auth.call(MyApp.Auth.init([]))
|> MyApp.Router.call(MyApp.Router.init([]))
end
end
```
**Example — after:**
```elixir
# Plug.Builder — declarative, compiled, correct
defmodule MyApp.Pipeline do
use Plug.Builder
plug Plug.RequestId
plug Plug.Logger
plug MyApp.Auth
plug MyApp.Router
end
```
### When NOT to Use
**Don't use this when:** You have a single plug or need dynamic/conditional middleware selection at runtime.
**Over-application example:**
```elixir
# Bad: Using Plug.Builder for a single plug
defmodule MyApp.JustAuth do
use Plug.Builder
plug MyApp.Auth # Only one plug — Builder adds no value
end
```
**Better alternative:**
```elixir
# Just use the plug directly
plug MyApp.Auth # In your router or endpoint
```
**Why:** `Plug.Builder` generates pipeline compilation machinery. For a single plug, it's overhead with no benefit — just reference the plug directly where it's needed.
---
## 7. Exception Structs with HTTP Status Codes
@@ -168,3 +624,66 @@ 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.
### When to Use
**Triggers:**
- You are raising exceptions that should map to specific HTTP responses
- You want Plug's error handling to automatically return the correct status code
- Your error is domain-specific but has a clear HTTP semantic (404, 403, 422)
**Example — before:**
```elixir
# Generic exception — Plug returns 500 for everything
defmodule MyApp.NotFoundError do
defexception message: "Resource not found"
end
# In controller:
raise MyApp.NotFoundError # → 500 Internal Server Error (wrong!)
```
**Example — after:**
```elixir
# Exception with plug_status — correct HTTP mapping
defmodule MyApp.NotFoundError do
defexception plug_status: 404, message: "Resource not found"
end
# In controller:
raise MyApp.NotFoundError # → 404 Not Found (correct!)
```
### When NOT to Use
**Don't use this when:** The exception is used in non-HTTP contexts (CLI tools, background workers, library code) where HTTP status codes are meaningless.
**Over-application example:**
```elixir
# Bad: HTTP status on a domain exception used in background jobs
defmodule MyApp.PaymentDeclinedError do
defexception plug_status: 402, message: "Payment declined"
# This exception is also raised in async payment retry workers
# where plug_status is meaningless and confusing
end
```
**Better alternative:**
```elixir
# Domain exception — pure, no HTTP coupling
defmodule MyApp.PaymentDeclinedError do
defexception [:message, :reason, :transaction_id]
end
# Map to HTTP at the boundary (ErrorView or fallback controller)
defimpl Plug.Exception, for: MyApp.PaymentDeclinedError do
def status(_exception), do: 402
def actions(_exception), do: []
end
```
**Why:** Embedding `plug_status` couples the exception to HTTP. If the exception is used in multiple contexts (web, workers, CLI), implement the `Plug.Exception` protocol separately to keep the domain exception pure and the HTTP mapping at the boundary.
File diff suppressed because it is too large Load Diff