Files
phoenix-conventions/patterns/patterns.md
T
2026-04-30 06:47:10 -07:00

1276 lines
37 KiB
Markdown

# Phoenix Patterns
Patterns specific to Phoenix extracted from the framework source code.
## 1. Endpoint as Supervision Tree Root + Plug Pipeline
**Source:** `lib/phoenix/endpoint.ex:1-40` (moduledoc)
> The endpoint is the boundary where all requests to your web application start.
> It is also the interface your application provides to the underlying web servers.
>
> Overall, an endpoint has three responsibilities:
> - to provide a wrapper for starting and stopping the endpoint as part of a supervision tree
> - to define an initial plug pipeline for requests to pass through
> - to host web specific configuration for your application
**Source:** `lib/phoenix/endpoint.ex:408-418` (`__using__` macro)
```elixir
defmacro __using__(opts) do
quote do
@behaviour Phoenix.Endpoint
unquote(config(opts))
unquote(pubsub())
unquote(plug())
unquote(server())
end
end
```
The endpoint is four things composed together:
1. **Config** — compile-time and runtime configuration
2. **PubSub** — subscribe/broadcast interface
3. **Plug** — request pipeline (via `Plug.Builder`)
4. **Server** — supervision and HTTP server management
**Why:** The Endpoint is a supervisor, a plug pipeline, AND a configuration host — all in one module. This unification means one place to configure and start the entire web layer.
**Anti-pattern:** Splitting endpoint responsibilities across multiple unrelated modules — Phoenix deliberately consolidates the "boundary" concept.
### When to Use
**Triggers:**
- You need a single entry point for all HTTP requests
- You want unified config, PubSub, and server supervision in one module
- You are starting a new Phoenix application or adding a separate web interface (e.g., admin endpoint)
**Example — before:**
```elixir
# Scattered config, manual PubSub setup, separate server management
defmodule MyApp.Application do
def start(_type, _args) do
children = [
{Phoenix.PubSub, name: MyApp.PubSub},
MyApp.WebServer,
MyApp.ConfigServer
]
Supervisor.start_link(children, strategy: :one_for_one)
end
end
```
**Example — after:**
```elixir
# Unified in endpoint — config, PubSub, plug pipeline, server all in one
defmodule MyAppWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :my_app
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug MyAppWeb.Router
end
```
### When NOT to Use
**Don't use this when:** You have non-HTTP services (background workers, scheduled tasks, message consumers) that don't need a web boundary.
**Over-application example:**
```elixir
# Bad: Using an Endpoint for a pure background job system
defmodule MyApp.WorkerEndpoint do
use Phoenix.Endpoint, otp_app: :my_app
# No plugs, no routes — just using it as a supervisor
end
```
**Better alternative:**
```elixir
# Use a plain Supervisor for non-HTTP concerns
defmodule MyApp.Workers.Supervisor do
use Supervisor
def start_link(opts) do
Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
end
def init(_opts) do
children = [MyApp.Workers.EmailSender, MyApp.Workers.DataImporter]
Supervisor.init(children, strategy: :one_for_one)
end
end
```
**Why:** The Endpoint carries HTTP-specific baggage (Plug pipeline, request telemetry, cowboy/bandit server). Using it for non-HTTP work adds unnecessary complexity and confuses the architecture.
---
## 2. Router: Compile-Time Route Optimization
**Source:** `lib/phoenix/router.ex:106-128` (Why the macros? info block)
> We use macros for two purposes:
>
> * They define the routing engine, used on every request, to choose which
> controller to dispatch the request to. Thanks to macros, Phoenix compiles
> all of your routes to a single case-statement with pattern matching rules,
> which is heavily optimized by the Erlang VM
>
> * For each route you define, we also define metadata to implement `Phoenix.VerifiedRoutes`.
**Source:** `lib/phoenix/router.ex:299` (route accumulation)
```elixir
Module.register_attribute(__MODULE__, :phoenix_routes, accumulate: true)
```
**Why:** Routes are defined with macros that accumulate route data at compile time. At `@before_compile`, all routes are compiled into a single pattern-match dispatch function. This is O(1) routing, not O(n) list scanning.
**Anti-pattern:** Runtime route tables (like maps or lists that are scanned per-request) — compile-time pattern matching is orders of magnitude faster.
### When to Use
**Triggers:**
- You are defining URL-to-controller mappings for your web app
- You want the fastest possible request dispatch
- You need verified routes (compile-time checked paths)
**Example — before:**
```elixir
# Runtime route lookup — slow, no compile-time verification
defmodule MyRouter do
@routes %{
{"GET", "/users"} => {UserController, :index},
{"GET", "/users/:id"} => {UserController, :show}
}
def dispatch(conn) do
case Map.get(@routes, {conn.method, conn.path_info}) do
{controller, action} -> apply(controller, action, [conn, conn.params])
nil -> send_resp(conn, 404, "Not Found")
end
end
end
```
**Example — after:**
```elixir
# Compile-time route optimization — O(1) dispatch via pattern matching
defmodule MyAppWeb.Router do
use Phoenix.Router
scope "/", MyAppWeb do
pipe_through :browser
get "/users", UserController, :index
get "/users/:id", UserController, :show
end
end
```
### When NOT to Use
**Don't use this when:** Routes need to be dynamic at runtime (e.g., user-configured URL mappings, plugin systems where routes are registered after compilation).
**Over-application example:**
```elixir
# Bad: Trying to define routes dynamically via database
defmodule MyAppWeb.Router do
use Phoenix.Router
# This won't work — routes are compiled, not runtime-configurable
for route <- Repo.all(CustomRoute) do
get route.path, PageController, :dynamic
end
end
```
**Better alternative:**
```elixir
# Use a catch-all route that dispatches based on runtime data
defmodule MyAppWeb.Router do
use Phoenix.Router
scope "/", MyAppWeb do
get "/pages/*path", DynamicPageController, :show
end
end
defmodule MyAppWeb.DynamicPageController do
use MyAppWeb, :controller
def show(conn, %{"path" => path}) do
case Pages.find_by_path(Enum.join(path, "/")) do
{:ok, page} -> render(conn, :show, page: page)
:error -> send_resp(conn, 404, "Not found")
end
end
end
```
**Why:** Phoenix routes are compiled into pattern matches — they cannot change at runtime. For dynamic routing, use a catch-all route that delegates to runtime logic.
---
## 3. Pipeline and `pipe_through` for Request Processing
**Source:** `lib/phoenix/router.ex:243-270` (Pipelines and plugs section)
```elixir
pipeline :browser do
plug :fetch_session
plug :accepts, ["html"]
end
scope "/" do
pipe_through :browser
# routes
end
```
**Why:** Pipelines are named, composable groups of plugs. Routes declare which pipelines they pass through. This separates concerns:
- Pipeline definition (what transformations exist)
- Route definition (which routes use which pipelines)
**Anti-pattern:** Putting plug logic directly in controllers or duplicating plug chains across routes.
### When to Use
**Triggers:**
- Multiple routes need the same set of plugs (auth, session, content type)
- You have different request processing needs (browser vs API vs admin)
- You want to DRY up repeated plug declarations
**Example — before:**
```elixir
# Duplicated plug logic in every controller
defmodule MyAppWeb.UserController do
use MyAppWeb, :controller
plug :fetch_session
plug :require_auth
plug :put_layout, html: {MyAppWeb.Layouts, :app}
def index(conn, _params), do: # ...
end
defmodule MyAppWeb.PostController do
use MyAppWeb, :controller
plug :fetch_session
plug :require_auth
plug :put_layout, html: {MyAppWeb.Layouts, :app}
def index(conn, _params), do: # ...
end
```
**Example — after:**
```elixir
# Pipelines in router — declared once, reused everywhere
defmodule MyAppWeb.Router do
use Phoenix.Router
pipeline :browser do
plug :fetch_session
plug :put_layout, html: {MyAppWeb.Layouts, :app}
end
pipeline :authenticated do
plug :require_auth
end
scope "/", MyAppWeb do
pipe_through [:browser, :authenticated]
resources "/users", UserController
resources "/posts", PostController
end
end
```
### When NOT to Use
**Don't use this when:** A plug is specific to a single controller action or needs per-action conditional logic.
**Over-application example:**
```elixir
# Bad: Creating a pipeline for something only one action needs
pipeline :with_special_header do
plug :add_special_header
end
scope "/", MyAppWeb do
pipe_through [:browser, :with_special_header]
get "/special", SpecialController, :show # only route using this
end
```
**Better alternative:**
```elixir
# Controller-level plug with guard for action-specific behavior
defmodule MyAppWeb.SpecialController do
use MyAppWeb, :controller
plug :add_special_header when action == :show
def show(conn, _params), do: # ...
defp add_special_header(conn, _opts) do
put_resp_header(conn, "x-special", "true")
end
end
```
**Why:** Pipelines are for shared cross-cutting concerns. Single-action or single-controller logic belongs in the controller itself, keeping the router clean and pipelines meaningful.
---
## 4. Controller as Thin Dispatch Layer
**Source:** `lib/phoenix/controller.ex:28-45` (moduledoc examples)
```elixir
defmodule MyAppWeb.UserController do
use MyAppWeb, :controller
def show(conn, %{"id" => id}) do
user = Repo.get(User, id)
render(conn, :show, user: user)
end
end
```
Controllers:
- Pattern match on params (destructure what you need)
- Call domain logic (the Repo/context layer)
- Render the result
**Source:** `lib/phoenix/controller.ex:1-5` (imports)
```elixir
defmodule Phoenix.Controller do
import Plug.Conn
alias Plug.Conn.AlreadySentError
require Logger
```
**Why:** Controllers import `Plug.Conn` for connection manipulation. They're pluggable themselves — a controller IS a plug. The action is just the last step in the plug pipeline.
**Anti-pattern:** Fat controllers with business logic — controllers should delegate to context modules.
### When to Use
**Triggers:**
- You need to handle an HTTP request and return a response
- The work is: receive params → call domain logic → render result
- You want a clean boundary between web concerns and business logic
**Example — before:**
```elixir
# Fat controller with business logic mixed in
defmodule MyAppWeb.OrderController do
use MyAppWeb, :controller
def create(conn, %{"order" => order_params}) do
user = conn.assigns.current_user
items = Repo.all(from i in Item, where: i.id in ^order_params["item_ids"])
total = Enum.reduce(items, 0, &(&1.price + &2))
discount = if user.membership == :premium, do: total * 0.1, else: 0
changeset = Order.changeset(%Order{}, %{
user_id: user.id,
total: total - discount,
items: items
})
case Repo.insert(changeset) do
{:ok, order} ->
Mailer.deliver(OrderEmail.confirmation(order))
conn |> put_flash(:info, "Order placed!") |> redirect(to: ~p"/orders/#{order}")
{:error, changeset} ->
render(conn, :new, changeset: changeset)
end
end
end
```
**Example — after:**
```elixir
# Thin controller — delegates to context
defmodule MyAppWeb.OrderController do
use MyAppWeb, :controller
def create(conn, %{"order" => order_params}) do
case Orders.place_order(conn.assigns.current_user, order_params) do
{:ok, order} ->
conn |> put_flash(:info, "Order placed!") |> redirect(to: ~p"/orders/#{order}")
{:error, changeset} ->
render(conn, :new, changeset: changeset)
end
end
end
```
### When NOT to Use
**Don't use this when:** You're building a LiveView-only application with no traditional request/response cycle, or when the "controller" would just be a pass-through with no meaningful web-layer work.
**Over-application example:**
```elixir
# Bad: Controller that just proxies to LiveView
defmodule MyAppWeb.DashboardController do
use MyAppWeb, :controller
def index(conn, _params) do
# All this does is redirect to LiveView — unnecessary layer
redirect(conn, to: ~p"/dashboard/live")
end
end
```
**Better alternative:**
```elixir
# Route directly to the LiveView
scope "/", MyAppWeb do
pipe_through :browser
live "/dashboard", DashboardLive
end
```
**Why:** Controllers exist to mediate between HTTP and your domain. If there's no mediation needed (no params to validate, no flash messages, no redirects based on outcome), routing directly to a LiveView or using `plug` functions is simpler.
---
## 5. Channel as GenServer with Topic-Based Routing
**Source:** `lib/phoenix/channel.ex:1-25` (topic pattern)
```elixir
channel "room:*", MyAppWeb.RoomChannel
```
Then in the channel:
```elixir
def join("room:lobby", _payload, socket) do
{:ok, socket}
end
def join("room:" <> room_id, _payload, socket) do
{:ok, socket}
end
```
**Source:** `lib/phoenix/channel.ex:474-478` (channels are GenServers)
```elixir
def start_link(triplet) do
GenServer.start_link(Phoenix.Channel.Server, triplet,
hibernate_after: @phoenix_hibernate_after
)
end
```
**Why:** Each channel join creates a process. Pattern matching on the topic string provides natural routing. The GenServer backing means channels get supervision, hibernation, and all OTP semantics.
**Anti-pattern:** Managing channel state in shared ETS or external state — each channel IS its own process with its own state.
### When to Use
**Triggers:**
- You need real-time bidirectional communication (chat, notifications, live updates)
- Each connected client needs its own isolated state
- You want pub/sub semantics with topic-based routing
- You need presence tracking (who's online)
**Example — before:**
```elixir
# Polling API endpoint — client hits this every 2 seconds
defmodule MyAppWeb.NotificationController do
use MyAppWeb, :controller
def index(conn, _params) do
user = conn.assigns.current_user
notifications = Notifications.unread_for(user)
json(conn, %{notifications: notifications})
end
end
```
**Example — after:**
```elixir
# Real-time channel — server pushes when notifications arrive
defmodule MyAppWeb.NotificationChannel do
use Phoenix.Channel
def join("notifications:" <> user_id, _payload, socket) do
if socket.assigns.user_id == user_id do
{:ok, socket}
else
{:error, %{reason: "unauthorized"}}
end
end
def handle_info({:new_notification, notification}, socket) do
push(socket, "new_notification", notification)
{:noreply, socket}
end
end
```
### When NOT to Use
**Don't use this when:** Communication is unidirectional (server → client only) with no per-client state, or when you need to fan out identical data to many thousands of clients without per-connection processes.
**Over-application example:**
```elixir
# Bad: Channel for read-only server-sent data with no client interaction
defmodule MyAppWeb.StockTickerChannel do
use Phoenix.Channel
def join("ticker:prices", _payload, socket) do
# Every client gets identical data, no per-client state
send(self(), :send_prices)
{:ok, socket}
end
def handle_info(:send_prices, socket) do
push(socket, "prices", get_all_prices())
Process.send_after(self(), :send_prices, 1000)
{:noreply, socket}
end
end
```
**Better alternative:**
```elixir
# Use PubSub broadcast — one process publishes, all subscribers receive
defmodule MyApp.StockTicker do
use GenServer
def handle_info(:tick, state) do
prices = fetch_prices()
Phoenix.PubSub.broadcast(MyApp.PubSub, "ticker:prices", {:prices, prices})
Process.send_after(self(), :tick, 1000)
{:noreply, state}
end
end
# Minimal channel just subscribes — no per-client timer/logic
defmodule MyAppWeb.StockTickerChannel do
use Phoenix.Channel
def join("ticker:prices", _payload, socket) do
Phoenix.PubSub.subscribe(MyApp.PubSub, "ticker:prices")
{:ok, socket}
end
def handle_info({:prices, prices}, socket) do
push(socket, "prices", prices)
{:noreply, socket}
end
end
```
**Why:** Each channel process consumes memory and CPU. If all clients receive identical data and have no individual state, use PubSub broadcast from a single GenServer rather than duplicating fetch logic in thousands of channel processes.
---
## 6. PubSub Integration via Endpoint
**Source:** `lib/phoenix/endpoint.ex:437-475` (pubsub macro)
```elixir
def subscribe(topic, opts \\ []) when is_binary(topic) do
Phoenix.PubSub.subscribe(pubsub_server!(), topic, opts)
end
def broadcast(topic, event, msg) do
Phoenix.Channel.Server.broadcast(pubsub_server!(), topic, event, msg)
end
defp pubsub_server! do
config(:pubsub_server) ||
raise ArgumentError, "no :pubsub_server configured for #{inspect(__MODULE__)}"
end
```
**Why:** PubSub is wired through the endpoint — `MyAppWeb.Endpoint.broadcast!("topic", "event", payload)`. The endpoint knows its pubsub server from config; channels broadcast through it transparently.
**Anti-pattern:** Passing PubSub server names around manually — the endpoint already knows and exposes the interface.
### When to Use
**Triggers:**
- You need to broadcast events from contexts/services to connected clients
- You want a single consistent PubSub interface across your application
- You are sending messages from outside a channel (e.g., from a context module after a DB write)
**Example — before:**
```elixir
# Manually passing PubSub server name everywhere
defmodule MyApp.Posts do
def create_post(attrs) do
case Repo.insert(Post.changeset(%Post{}, attrs)) do
{:ok, post} ->
# Have to know the PubSub name and wire it through
Phoenix.PubSub.broadcast(MyApp.PubSub, "posts:feed", {:new_post, post})
{:ok, post}
error -> error
end
end
end
```
**Example — after:**
```elixir
# Use the Endpoint's PubSub interface — already configured
defmodule MyApp.Posts do
def create_post(attrs) do
case Repo.insert(Post.changeset(%Post{}, attrs)) do
{:ok, post} ->
MyAppWeb.Endpoint.broadcast!("posts:feed", "new_post", %{id: post.id, title: post.title})
{:ok, post}
error -> error
end
end
end
```
### When NOT to Use
**Don't use this when:** You need PubSub between non-web services that have no Phoenix Endpoint (e.g., distributed GenServers communicating across nodes without a web layer).
**Over-application example:**
```elixir
# Bad: Referencing the web endpoint from a pure domain library
defmodule MyApp.Analytics.Aggregator do
# This pure data processing module shouldn't know about web endpoints
def aggregate(data) do
result = compute(data)
MyAppWeb.Endpoint.broadcast!("analytics:results", "update", result)
result
end
end
```
**Better alternative:**
```elixir
# Use Phoenix.PubSub directly when you're not in the web layer
defmodule MyApp.Analytics.Aggregator do
def aggregate(data) do
result = compute(data)
Phoenix.PubSub.broadcast(MyApp.PubSub, "analytics:results", {:update, result})
result
end
end
```
**Why:** `Endpoint.broadcast!/3` is a convenience wrapper. In non-web modules, depending on the endpoint creates a circular dependency (domain → web). Use `Phoenix.PubSub` directly to keep the dependency arrow clean: web depends on domain, not the reverse.
---
## 7. Socket as Authentication Boundary
**Source:** `lib/phoenix/socket.ex:1-30` (moduledoc: connect callback pattern)
```elixir
defmodule MyAppWeb.UserSocket do
use Phoenix.Socket
channel "room:*", MyAppWeb.RoomChannel
def connect(params, socket, _connect_info) do
{:ok, assign(socket, :user_id, params["user_id"])}
end
def id(socket), do: "users_socket:#{socket.assigns.user_id}"
end
```
**Why:** Authentication happens ONCE at socket connection. All channels on that socket inherit the authenticated identity. `id/1` enables targeted disconnection — `Endpoint.broadcast("users_socket:123", "disconnect", %{})`.
**Anti-pattern:** Authenticating in every `join/3` callback instead of at the socket level.
### When to Use
**Triggers:**
- You need authenticated real-time connections
- Multiple channels share the same user identity
- You want the ability to force-disconnect specific users
- Authentication happens via token/session that should be validated once
**Example — before:**
```elixir
# Bad: Re-authenticating in every channel join
defmodule MyAppWeb.ChatChannel do
use Phoenix.Channel
def join("chat:" <> room_id, %{"token" => token}, socket) do
case verify_token(token) do
{:ok, user_id} ->
{:ok, assign(socket, :user_id, user_id)}
:error ->
{:error, %{reason: "unauthorized"}}
end
end
end
defmodule MyAppWeb.NotifChannel do
use Phoenix.Channel
def join("notif:" <> user_id, %{"token" => token}, socket) do
# Same auth logic duplicated!
case verify_token(token) do
{:ok, uid} -> {:ok, assign(socket, :user_id, uid)}
:error -> {:error, %{reason: "unauthorized"}}
end
end
end
```
**Example — after:**
```elixir
# Auth once at socket level — all channels inherit identity
defmodule MyAppWeb.UserSocket do
use Phoenix.Socket
channel "chat:*", MyAppWeb.ChatChannel
channel "notif:*", MyAppWeb.NotifChannel
def connect(%{"token" => token}, socket, _connect_info) do
case Phoenix.Token.verify(MyAppWeb.Endpoint, "user", token, max_age: 86400) do
{:ok, user_id} -> {:ok, assign(socket, :user_id, user_id)}
{:error, _} -> :error
end
end
def id(socket), do: "users_socket:#{socket.assigns.user_id}"
end
# Channels just use socket.assigns — already authenticated
defmodule MyAppWeb.ChatChannel do
use Phoenix.Channel
def join("chat:" <> room_id, _payload, socket) do
if authorized?(socket.assigns.user_id, room_id) do
{:ok, socket}
else
{:error, %{reason: "unauthorized"}}
end
end
end
```
### When NOT to Use
**Don't use this when:** You have unauthenticated/public channels where no identity is needed, or each channel requires different credentials (multi-tenant with separate auth per service).
**Over-application example:**
```elixir
# Bad: Forcing authentication on a public broadcast socket
defmodule MyAppWeb.PublicSocket do
use Phoenix.Socket
channel "announcements:*", MyAppWeb.AnnouncementChannel
def connect(%{"token" => token}, socket, _connect_info) do
# Public announcements don't need auth — this blocks anonymous viewers
case verify_token(token) do
{:ok, user_id} -> {:ok, assign(socket, :user_id, user_id)}
{:error, _} -> :error
end
end
end
```
**Better alternative:**
```elixir
# Accept all connections for public channels
defmodule MyAppWeb.PublicSocket do
use Phoenix.Socket
channel "announcements:*", MyAppWeb.AnnouncementChannel
def connect(_params, socket, _connect_info) do
{:ok, socket}
end
def id(_socket), do: nil # No targeted disconnect needed
end
```
**Why:** Not every socket needs authentication. Public broadcast channels (announcements, live scores, status pages) should accept connections freely. Forcing auth adds friction and excludes legitimate anonymous users.
---
## 8. Plug Pattern: `init/1` + `call/2`
**Source:** `lib/phoenix/router/route.ex:47-57`
```elixir
@doc "Used as a plug on forwarding"
def init(opts), do: opts
@doc "Used as a plug on forwarding"
def call(%{path_info: path, script_name: script} = conn, {fwd_segments, plug, opts}) do
new_path = path -- fwd_segments
{base, ^new_path} = Enum.split(path, length(path) - length(new_path))
conn = %{conn | path_info: new_path, script_name: script ++ base}
conn = plug.call(conn, plug.init(opts))
%{conn | path_info: path, script_name: script}
end
```
**Why:** The Plug specification splits work into:
- `init/1` — compile-time setup (called once, result cached)
- `call/2` — runtime execution (called per-request, must be fast)
This is Phoenix's fundamental composition pattern. Everything is a plug.
**Anti-pattern:** Doing expensive setup work in `call/2` instead of `init/1` — it runs on every request.
### When to Use
**Triggers:**
- You need middleware (request/response transformation)
- You want composable, reusable request processing steps
- You have expensive setup (regex compilation, config parsing) that should run once
**Example — before:**
```elixir
# Bad: Expensive work on every request
defmodule MyApp.RateLimiter do
def call(conn, _opts) do
# Compiling regex on EVERY request
pattern = Regex.compile!("^/api/")
config = Application.get_env(:my_app, :rate_limit) # disk read each time
bucket_size = config[:bucket_size]
if Regex.match?(pattern, conn.request_path) do
check_rate(conn, bucket_size)
else
conn
end
end
end
```
**Example — after:**
```elixir
# Correct: Expensive work in init/1, fast work in call/2
defmodule MyApp.RateLimiter do
@behaviour Plug
def init(opts) do
%{
pattern: Regex.compile!(Keyword.get(opts, :path_pattern, "^/api/")),
bucket_size: Keyword.get(opts, :bucket_size, 100)
}
end
def call(conn, %{pattern: pattern, bucket_size: bucket_size}) do
if Regex.match?(pattern, conn.request_path) do
check_rate(conn, bucket_size)
else
conn
end
end
end
```
### When NOT to Use
**Don't use this when:** The logic is inherently per-request and cannot be split (e.g., the "init" data depends on the request itself), or when you are writing a simple helper function, not middleware.
**Over-application example:**
```elixir
# Bad: Forcing Plug pattern on a simple utility function
defmodule MyApp.Helpers.FormatDate do
@behaviour Plug
def init(opts), do: opts
def call(conn, _opts) do
# This doesn't transform the connection — it's not middleware
assign(conn, :formatted_date, Calendar.strftime(Date.utc_today(), "%B %d, %Y"))
end
end
```
**Better alternative:**
```elixir
# Just a function in your view helpers
defmodule MyAppWeb.Helpers do
def formatted_date do
Calendar.strftime(Date.utc_today(), "%B %d, %Y")
end
end
```
**Why:** Plugs are for transforming the connection as it flows through a pipeline. If something doesn't need request/response context or pipeline composition, a plain function is simpler and more testable.
---
## 9. Telemetry Integration in Router Dispatch
**Source:** `lib/phoenix/router.ex:400-438` (telemetry instrumentation)
```elixir
def __call__(conn, metadata, prepare, pipeline, {plug, opts}) do
conn = prepare.(conn, metadata)
start = System.monotonic_time()
measurements = %{system_time: System.system_time()}
metadata = %{metadata | conn: conn}
:telemetry.execute([:phoenix, :router_dispatch, :start], measurements, metadata)
case pipeline.(conn) do
%Plug.Conn{halted: true} = halted_conn ->
measurements = %{duration: System.monotonic_time() - start}
metadata = %{metadata | conn: halted_conn}
:telemetry.execute([:phoenix, :router_dispatch, :stop], measurements, metadata)
halted_conn
%Plug.Conn{} = piped_conn ->
try do
plug.call(piped_conn, plug.init(opts))
else
conn ->
measurements = %{duration: System.monotonic_time() - start}
metadata = %{metadata | conn: conn}
:telemetry.execute([:phoenix, :router_dispatch, :stop], measurements, metadata)
conn
rescue
e in Plug.Conn.WrapperError ->
measurements = %{duration: System.monotonic_time() - start}
:telemetry.execute([:phoenix, :router_dispatch, :exception], measurements, metadata)
Plug.Conn.WrapperError.reraise(e)
end
end
end
```
**Source:** `lib/phoenix/logger.ex:7-50` (telemetry event catalog)
Phoenix emits these telemetry events:
- `[:phoenix, :endpoint, :init]` — endpoint supervision tree started
- `[:phoenix, :endpoint, :start]` — request begins (via `Plug.Telemetry`)
- `[:phoenix, :endpoint, :stop]` — response sent
- `[:phoenix, :router_dispatch, :start]` — route dispatch begins
- `[:phoenix, :router_dispatch, :stop]` — route dispatch succeeds
- `[:phoenix, :router_dispatch, :exception]` — route dispatch raises
- `[:phoenix, :socket_connected]` — socket connection established
**Why:** Telemetry is baked into every request path. The router wraps ALL dispatches in start/stop/exception telemetry, enabling monitoring, tracing (OpenTelemetry), and logging without modifying application code.
**Anti-pattern:** Manual timing/logging in controllers — telemetry provides this automatically at the infrastructure level.
### When to Use
**Triggers:**
- You need request duration metrics, error rate monitoring, or distributed tracing
- You want observability without modifying business logic
- You are integrating with monitoring tools (Prometheus, Datadog, OpenTelemetry)
**Example — before:**
```elixir
# Bad: Manual timing in every controller action
defmodule MyAppWeb.UserController do
use MyAppWeb, :controller
require Logger
def index(conn, _params) do
start = System.monotonic_time()
users = Accounts.list_users()
duration = System.monotonic_time() - start
Logger.info("UserController.index took #{System.convert_time_unit(duration, :native, :millisecond)}ms")
render(conn, :index, users: users)
end
end
```
**Example — after:**
```elixir
# Correct: Attach telemetry handlers once, get metrics for all routes
defmodule MyApp.Telemetry do
def setup do
:telemetry.attach_many("my-app-telemetry", [
[:phoenix, :router_dispatch, :stop],
[:phoenix, :router_dispatch, :exception]
], &handle_event/4, nil)
end
def handle_event([:phoenix, :router_dispatch, :stop], %{duration: duration}, metadata, _config) do
route = "#{metadata.route}"
:telemetry.execute([:my_app, :request, :duration], %{value: duration}, %{route: route})
end
end
```
### When NOT to Use
**Don't use this when:** You need fine-grained timing of specific business logic steps within a single action — telemetry at the router level only measures total dispatch time.
**Over-application example:**
```elixir
# Bad: Trying to use router telemetry to measure individual DB queries
# Router telemetry measures the whole dispatch — not sub-operations
:telemetry.attach("db-timing", [:phoenix, :router_dispatch, :stop], fn _, %{duration: d}, _, _ ->
# This is total request time, NOT db time
Metrics.record_db_time(d)
end, nil)
```
**Better alternative:**
```elixir
# Use Ecto telemetry for DB-specific metrics
:telemetry.attach("ecto-timing", [:my_app, :repo, :query], fn _, measurements, metadata, _ ->
Metrics.record_db_time(measurements.total_time, %{
source: metadata.source,
query: metadata.query
})
end, nil)
```
**Why:** Phoenix router telemetry operates at the request boundary. For sub-operation metrics (DB queries, HTTP calls, cache lookups), attach to the appropriate library's telemetry events instead.
---
## 10. ConnTest Pattern: Endpoint-Based Integration Testing
**Source:** `lib/phoenix/test/conn_test.ex:1-30` (moduledoc)
```elixir
@endpoint MyAppWeb.Endpoint
test "says welcome on the home page" do
conn = get(build_conn(), "/")
assert conn.resp_body =~ "Welcome!"
end
test "logs in" do
conn = post(build_conn(), "/login", [username: "john", password: "doe"])
assert conn.resp_body =~ "Logged in!"
end
```
**Why:** `Phoenix.ConnTest` tests against the full endpoint stack (plugs, router, controller) without starting an HTTP server. `build_conn()` creates a test connection, HTTP verb functions dispatch through the endpoint. This gives integration-level confidence with unit-test speed.
**Anti-pattern:** Testing controllers in isolation without the plug pipeline — you miss middleware bugs (auth, CSRF, sessions).
### When to Use
**Triggers:**
- You want to test a request through the full plug pipeline
- You need to verify auth, CSRF, session, and content negotiation work together
- You want fast integration tests without HTTP overhead
**Example — before:**
```elixir
# Bad: Testing controller function directly — bypasses all middleware
test "shows user" do
conn = %Plug.Conn{params: %{"id" => "1"}, assigns: %{current_user: user}}
result = MyAppWeb.UserController.show(conn, %{"id" => "1"})
# This misses: auth plug, CSRF check, session, layout rendering
assert result.status == 200
end
```
**Example — after:**
```elixir
# Correct: Test through the endpoint — exercises the full stack
defmodule MyAppWeb.UserControllerTest do
use MyAppWeb.ConnCase
test "requires authentication", %{conn: conn} do
conn = get(conn, ~p"/users/1")
assert redirected_to(conn) == ~p"/login"
end
test "shows user when authenticated", %{conn: conn} do
user = insert(:user)
conn = conn |> log_in_user(user) |> get(~p"/users/#{user}")
assert html_response(conn, 200) =~ user.name
end
end
```
### When NOT to Use
**Don't use this when:** You are testing pure domain logic (context modules, schemas, business rules) that has no HTTP concerns.
**Over-application example:**
```elixir
# Bad: Using ConnTest to test business logic
defmodule MyApp.OrdersTest do
use MyAppWeb.ConnCase # unnecessary — no HTTP needed
test "calculates discount" do
# Making an HTTP request just to test a calculation
conn = post(build_conn(), "/api/orders/calculate", %{items: [%{price: 100}], coupon: "SAVE10"})
assert json_response(conn, 200)["total"] == 90
end
end
```
**Better alternative:**
```elixir
# Test the context directly — faster, clearer, no HTTP noise
defmodule MyApp.OrdersTest do
use MyApp.DataCase
test "calculates discount" do
assert Orders.calculate_total([%{price: 100}], coupon: "SAVE10") == 90
end
end
```
**Why:** ConnTest is for testing HTTP behavior (status codes, redirects, headers, rendered HTML). Domain logic should have its own tests that run faster and test more directly.
---
## 11. ChannelTest Pattern: Process-Based Channel Testing
**Source:** `lib/phoenix/test/channel_test.ex:1-30` (moduledoc)
```elixir
{:ok, _, socket} =
socket(UserSocket, "user:id", %{some_assigns: 1})
|> subscribe_and_join(RoomChannel, "room:lobby", %{"id" => 3})
# Or using connect/3 to call your UserSocket.connect callback:
{:ok, socket} = connect(UserSocket, %{"some" => "params"}, %{})
{:ok, _, socket} = subscribe_and_join(socket, "room:lobby", %{"id" => 3})
```
**Why:** Channel tests communicate via messages (not HTTP). `subscribe_and_join/4` connects a test process to the channel, and you can assert on broadcasts (`assert_broadcast`), pushes (`assert_push`), and replies (`assert_reply`). The test process subscribes to the same PubSub topic, so it sees everything the channel broadcasts.
**Anti-pattern:** Testing channels by connecting real WebSocket clients — too slow, too brittle, tests the transport layer unnecessarily.
### When to Use
**Triggers:**
- You need to test channel join authorization logic
- You want to verify broadcasts, pushes, and replies
- You need to test the full channel lifecycle (join → handle_in → leave)
**Example — before:**
```elixir
# Bad: Testing channels via real WebSocket connections
test "user can join and receive messages" do
{:ok, socket} = Phoenix.ChannelTest.connect(MyAppWeb.UserSocket, %{})
# Spinning up a real WebSocket client to test logic
{:ok, client} = WebSocketClient.connect("ws://localhost:4001/socket/websocket")
WebSocketClient.send(client, %{topic: "room:lobby", event: "phx_join", payload: %{}})
assert_receive %{event: "phx_reply", payload: %{"status" => "ok"}}
end
```
**Example — after:**
```elixir
# Correct: Process-based testing — fast, deterministic
defmodule MyAppWeb.RoomChannelTest do
use MyAppWeb.ChannelCase
test "joins successfully and broadcasts presence" do
{:ok, _, socket} =
socket(MyAppWeb.UserSocket, "user:1", %{user_id: 1})
|> subscribe_and_join(MyAppWeb.RoomChannel, "room:lobby")
assert_broadcast "presence_state", %{}
end
test "handles new message and broadcasts to room" do
{:ok, _, socket} =
socket(MyAppWeb.UserSocket, "user:1", %{user_id: 1})
|> subscribe_and_join(MyAppWeb.RoomChannel, "room:lobby")
push(socket, "new_msg", %{"body" => "hello"})
assert_broadcast "new_msg", %{"body" => "hello"}
end
end
```
### When NOT to Use
**Don't use this when:** You need to test WebSocket transport behavior (reconnection, heartbeats, encoding), or when testing client-side JavaScript channel logic.
**Over-application example:**
```elixir
# Bad: Using ChannelTest to test transport-level reconnection
test "client reconnects after disconnect" do
{:ok, _, socket} =
socket(MyAppWeb.UserSocket, "user:1", %{user_id: 1})
|> subscribe_and_join(MyAppWeb.RoomChannel, "room:lobby")
# ChannelTest doesn't simulate transport disconnections
# This tests nothing about actual reconnection behavior
Process.exit(socket.channel_pid, :kill)
# ... can't meaningfully test reconnect here
end
```
**Better alternative:**
```elixir
# Use a real integration test with a WebSocket client for transport testing
defmodule MyAppWeb.ReconnectionIntegrationTest do
use ExUnit.Case
@tag :integration
test "client reconnects and rejoins after server restart" do
# Use a real WS client library for transport-level testing
{:ok, client} = PhoenixClient.connect("ws://localhost:4002/socket/websocket")
{:ok, _} = PhoenixClient.join(client, "room:lobby")
# Simulate disconnect and verify reconnection
PhoenixClient.disconnect(client)
assert {:ok, _} = PhoenixClient.reconnect(client, timeout: 5000)
end
end
```
**Why:** ChannelTest exercises your server-side channel logic (join, handle_in, handle_info) in isolation. Transport concerns (WebSocket frames, heartbeats, reconnection) are a separate layer tested separately — mixing them makes tests slow and flaky.