Files
2026-04-30 06:47:10 -07:00

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:

  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:

# 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 (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:

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