Files
2026-04-30 06:47:10 -07:00

690 lines
20 KiB
Markdown

# 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:106-128`
> 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.
### 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
**Elixir core pattern:** Always use `import Module, only: [...]` to be explicit.
**Phoenix deviation:** The Router imports entire modules:
**Source:** `lib/phoenix/router.ex:303-306`
```elixir
import Phoenix.Router
# TODO v2: No longer automatically import dependencies
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.
### 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
**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:297-312`
```elixir
defp prelude(opts) do
quote do
Module.register_attribute(__MODULE__, :phoenix_routes, accumulate: true)
@phoenix_helpers Keyword.get(unquote(opts), :helpers, true)
import Phoenix.Router
import Plug.Conn
import Phoenix.Controller
# Set up initial scope
@phoenix_pipeline nil
Phoenix.Router.Scope.init(__MODULE__)
@before_compile unquote(__MODULE__)
end
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`
**Elixir core GenServer default:** `:permanent` (always restart).
**Phoenix Channel default:** `:temporary` (never restart).
**Source:** `lib/phoenix/channel.ex:464-472`
```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.
### 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
**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:459`
```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.
### 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
**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:478-480`
```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.
### 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
**Elixir core exceptions:** Pure data — message, maybe some context fields.
**Phoenix exceptions:** Include `plug_status` for HTTP response mapping.
**Source:** `lib/phoenix/router.ex:2-26`
```elixir
defmodule NoRouteError do
@moduledoc """
Exception raised when no route is found.
"""
defexception plug_status: 404, message: "no route found", conn: nil, router: nil
end
defmodule MalformedURIError do
@moduledoc """
Exception raised when the URI is malformed on matching.
"""
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.
### 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.