37 KiB
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)
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:
- Config — compile-time and runtime configuration
- PubSub — subscribe/broadcast interface
- Plug — request pipeline (via
Plug.Builder) - 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:
# 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:
# 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:
# 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:
# 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)
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:
# 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:
# 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:
# 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:
# 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)
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:
# 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:
# 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:
# 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:
# 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)
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)
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:
# 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:
# 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:
# 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:
# 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)
channel "room:*", MyAppWeb.RoomChannel
Then in the channel:
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)
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:
# 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:
# 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:
# 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:
# 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)
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:
# 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:
# 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:
# 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:
# 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)
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:
# 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:
# 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:
# 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:
# 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
@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:
# 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:
# 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:
# 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:
# 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)
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 (viaPlug.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:
# 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:
# 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:
# 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:
# 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)
@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:
# 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:
# 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:
# 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:
# 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)
{: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:
# 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:
# 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:
# 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:
# 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.