20 KiB
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, anddeleteto 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:
# 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:
# 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:
# 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:
# 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
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:
# 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:
# 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:
# 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:
# 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
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:
# 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:
# 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:
# 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:
# 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
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:
# 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:
# 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:
# 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:
# 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
@phoenix_hibernate_after Keyword.get(opts, :hibernate_after, 15_000)
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:
# 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:
# 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:
# 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:
# 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
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
plugmacro for declarative middleware ordering - You need automatic
init/1caching and pipeline compilation
Example — before:
# 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:
# 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:
# 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:
# 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
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:
# 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:
# 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:
# 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:
# 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.