11 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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.