docs: add when/when-not to all Phoenix patterns
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user