Files
phoenix-conventions/patterns/patterns.md
T

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:

  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.


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 (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.


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.