335 lines
11 KiB
Markdown
335 lines
11 KiB
Markdown
# Phoenix Patterns
|
|
|
|
Patterns specific to Phoenix extracted from the framework source code.
|
|
|
|
## 1. Endpoint as Supervision Tree Root + Plug Pipeline
|
|
|
|
**Source:** `lib/phoenix/endpoint.ex:1-40` (moduledoc)
|
|
|
|
> The endpoint is the boundary where all requests to your web application start.
|
|
> It is also the interface your application provides to the underlying web servers.
|
|
>
|
|
> Overall, an endpoint has three responsibilities:
|
|
> - to provide a wrapper for starting and stopping the endpoint as part of a supervision tree
|
|
> - to define an initial plug pipeline for requests to pass through
|
|
> - to host web specific configuration for your application
|
|
|
|
**Source:** `lib/phoenix/endpoint.ex:408-418` (`__using__` macro)
|
|
|
|
```elixir
|
|
defmacro __using__(opts) do
|
|
quote do
|
|
@behaviour Phoenix.Endpoint
|
|
unquote(config(opts))
|
|
unquote(pubsub())
|
|
unquote(plug())
|
|
unquote(server())
|
|
end
|
|
end
|
|
```
|
|
|
|
The endpoint is four things composed together:
|
|
1. **Config** — compile-time and runtime configuration
|
|
2. **PubSub** — subscribe/broadcast interface
|
|
3. **Plug** — request pipeline (via `Plug.Builder`)
|
|
4. **Server** — supervision and HTTP server management
|
|
|
|
**Why:** The Endpoint is a supervisor, a plug pipeline, AND a configuration host — all in one module. This unification means one place to configure and start the entire web layer.
|
|
|
|
**Anti-pattern:** Splitting endpoint responsibilities across multiple unrelated modules — Phoenix deliberately consolidates the "boundary" concept.
|
|
|
|
---
|
|
|
|
## 2. Router: Compile-Time Route Optimization
|
|
|
|
**Source:** `lib/phoenix/router.ex:106-128` (Why the macros? info block)
|
|
|
|
> We use macros for two purposes:
|
|
>
|
|
> * They define the routing engine, used on every request, to choose which
|
|
> controller to dispatch the request to. Thanks to macros, Phoenix compiles
|
|
> all of your routes to a single case-statement with pattern matching rules,
|
|
> which is heavily optimized by the Erlang VM
|
|
>
|
|
> * For each route you define, we also define metadata to implement `Phoenix.VerifiedRoutes`.
|
|
|
|
**Source:** `lib/phoenix/router.ex:299` (route accumulation)
|
|
|
|
```elixir
|
|
Module.register_attribute(__MODULE__, :phoenix_routes, accumulate: true)
|
|
```
|
|
|
|
**Why:** Routes are defined with macros that accumulate route data at compile time. At `@before_compile`, all routes are compiled into a single pattern-match dispatch function. This is O(1) routing, not O(n) list scanning.
|
|
|
|
**Anti-pattern:** Runtime route tables (like maps or lists that are scanned per-request) — compile-time pattern matching is orders of magnitude faster.
|
|
|
|
---
|
|
|
|
## 3. Pipeline and `pipe_through` for Request Processing
|
|
|
|
**Source:** `lib/phoenix/router.ex:243-270` (Pipelines and plugs section)
|
|
|
|
```elixir
|
|
pipeline :browser do
|
|
plug :fetch_session
|
|
plug :accepts, ["html"]
|
|
end
|
|
|
|
scope "/" do
|
|
pipe_through :browser
|
|
# routes
|
|
end
|
|
```
|
|
|
|
**Why:** Pipelines are named, composable groups of plugs. Routes declare which pipelines they pass through. This separates concerns:
|
|
- Pipeline definition (what transformations exist)
|
|
- Route definition (which routes use which pipelines)
|
|
|
|
**Anti-pattern:** Putting plug logic directly in controllers or duplicating plug chains across routes.
|
|
|
|
---
|
|
|
|
## 4. Controller as Thin Dispatch Layer
|
|
|
|
**Source:** `lib/phoenix/controller.ex:28-45` (moduledoc examples)
|
|
|
|
```elixir
|
|
defmodule MyAppWeb.UserController do
|
|
use MyAppWeb, :controller
|
|
|
|
def show(conn, %{"id" => id}) do
|
|
user = Repo.get(User, id)
|
|
render(conn, :show, user: user)
|
|
end
|
|
end
|
|
```
|
|
|
|
Controllers:
|
|
- Pattern match on params (destructure what you need)
|
|
- Call domain logic (the Repo/context layer)
|
|
- Render the result
|
|
|
|
**Source:** `lib/phoenix/controller.ex:1-5` (imports)
|
|
|
|
```elixir
|
|
defmodule Phoenix.Controller do
|
|
import Plug.Conn
|
|
alias Plug.Conn.AlreadySentError
|
|
require Logger
|
|
```
|
|
|
|
**Why:** Controllers import `Plug.Conn` for connection manipulation. They're pluggable themselves — a controller IS a plug. The action is just the last step in the plug pipeline.
|
|
|
|
**Anti-pattern:** Fat controllers with business logic — controllers should delegate to context modules.
|
|
|
|
---
|
|
|
|
## 5. Channel as GenServer with Topic-Based Routing
|
|
|
|
**Source:** `lib/phoenix/channel.ex:1-25` (topic pattern)
|
|
|
|
```elixir
|
|
channel "room:*", MyAppWeb.RoomChannel
|
|
```
|
|
|
|
Then in the channel:
|
|
```elixir
|
|
def join("room:lobby", _payload, socket) do
|
|
{:ok, socket}
|
|
end
|
|
|
|
def join("room:" <> room_id, _payload, socket) do
|
|
{:ok, socket}
|
|
end
|
|
```
|
|
|
|
**Source:** `lib/phoenix/channel.ex:474-478` (channels are GenServers)
|
|
|
|
```elixir
|
|
def start_link(triplet) do
|
|
GenServer.start_link(Phoenix.Channel.Server, triplet,
|
|
hibernate_after: @phoenix_hibernate_after
|
|
)
|
|
end
|
|
```
|
|
|
|
**Why:** Each channel join creates a process. Pattern matching on the topic string provides natural routing. The GenServer backing means channels get supervision, hibernation, and all OTP semantics.
|
|
|
|
**Anti-pattern:** Managing channel state in shared ETS or external state — each channel IS its own process with its own state.
|
|
|
|
---
|
|
|
|
## 6. PubSub Integration via Endpoint
|
|
|
|
**Source:** `lib/phoenix/endpoint.ex:437-475` (pubsub macro)
|
|
|
|
```elixir
|
|
def subscribe(topic, opts \\ []) when is_binary(topic) do
|
|
Phoenix.PubSub.subscribe(pubsub_server!(), topic, opts)
|
|
end
|
|
|
|
def broadcast(topic, event, msg) do
|
|
Phoenix.Channel.Server.broadcast(pubsub_server!(), topic, event, msg)
|
|
end
|
|
|
|
defp pubsub_server! do
|
|
config(:pubsub_server) ||
|
|
raise ArgumentError, "no :pubsub_server configured for #{inspect(__MODULE__)}"
|
|
end
|
|
```
|
|
|
|
**Why:** PubSub is wired through the endpoint — `MyAppWeb.Endpoint.broadcast!("topic", "event", payload)`. The endpoint knows its pubsub server from config; channels broadcast through it transparently.
|
|
|
|
**Anti-pattern:** Passing PubSub server names around manually — the endpoint already knows and exposes the interface.
|
|
|
|
---
|
|
|
|
## 7. Socket as Authentication Boundary
|
|
|
|
**Source:** `lib/phoenix/socket.ex:1-30` (moduledoc: connect callback pattern)
|
|
|
|
```elixir
|
|
defmodule MyAppWeb.UserSocket do
|
|
use Phoenix.Socket
|
|
|
|
channel "room:*", MyAppWeb.RoomChannel
|
|
|
|
def connect(params, socket, _connect_info) do
|
|
{:ok, assign(socket, :user_id, params["user_id"])}
|
|
end
|
|
|
|
def id(socket), do: "users_socket:#{socket.assigns.user_id}"
|
|
end
|
|
```
|
|
|
|
**Why:** Authentication happens ONCE at socket connection. All channels on that socket inherit the authenticated identity. `id/1` enables targeted disconnection — `Endpoint.broadcast("users_socket:123", "disconnect", %{})`.
|
|
|
|
**Anti-pattern:** Authenticating in every `join/3` callback instead of at the socket level.
|
|
|
|
---
|
|
|
|
## 8. Plug Pattern: `init/1` + `call/2`
|
|
|
|
**Source:** `lib/phoenix/router/route.ex:47-57`
|
|
|
|
```elixir
|
|
@doc "Used as a plug on forwarding"
|
|
def init(opts), do: opts
|
|
|
|
@doc "Used as a plug on forwarding"
|
|
def call(%{path_info: path, script_name: script} = conn, {fwd_segments, plug, opts}) do
|
|
new_path = path -- fwd_segments
|
|
{base, ^new_path} = Enum.split(path, length(path) - length(new_path))
|
|
conn = %{conn | path_info: new_path, script_name: script ++ base}
|
|
conn = plug.call(conn, plug.init(opts))
|
|
%{conn | path_info: path, script_name: script}
|
|
end
|
|
```
|
|
|
|
**Why:** The Plug specification splits work into:
|
|
- `init/1` — compile-time setup (called once, result cached)
|
|
- `call/2` — runtime execution (called per-request, must be fast)
|
|
|
|
This is Phoenix's fundamental composition pattern. Everything is a plug.
|
|
|
|
**Anti-pattern:** Doing expensive setup work in `call/2` instead of `init/1` — it runs on every request.
|
|
|
|
---
|
|
|
|
## 9. Telemetry Integration in Router Dispatch
|
|
|
|
**Source:** `lib/phoenix/router.ex:400-438` (telemetry instrumentation)
|
|
|
|
```elixir
|
|
def __call__(conn, metadata, prepare, pipeline, {plug, opts}) do
|
|
conn = prepare.(conn, metadata)
|
|
start = System.monotonic_time()
|
|
measurements = %{system_time: System.system_time()}
|
|
metadata = %{metadata | conn: conn}
|
|
:telemetry.execute([:phoenix, :router_dispatch, :start], measurements, metadata)
|
|
|
|
case pipeline.(conn) do
|
|
%Plug.Conn{halted: true} = halted_conn ->
|
|
measurements = %{duration: System.monotonic_time() - start}
|
|
metadata = %{metadata | conn: halted_conn}
|
|
:telemetry.execute([:phoenix, :router_dispatch, :stop], measurements, metadata)
|
|
halted_conn
|
|
|
|
%Plug.Conn{} = piped_conn ->
|
|
try do
|
|
plug.call(piped_conn, plug.init(opts))
|
|
else
|
|
conn ->
|
|
measurements = %{duration: System.monotonic_time() - start}
|
|
metadata = %{metadata | conn: conn}
|
|
:telemetry.execute([:phoenix, :router_dispatch, :stop], measurements, metadata)
|
|
conn
|
|
rescue
|
|
e in Plug.Conn.WrapperError ->
|
|
measurements = %{duration: System.monotonic_time() - start}
|
|
:telemetry.execute([:phoenix, :router_dispatch, :exception], measurements, metadata)
|
|
Plug.Conn.WrapperError.reraise(e)
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
**Source:** `lib/phoenix/logger.ex:7-50` (telemetry event catalog)
|
|
|
|
Phoenix emits these telemetry events:
|
|
- `[:phoenix, :endpoint, :init]` — endpoint supervision tree started
|
|
- `[:phoenix, :endpoint, :start]` — request begins (via `Plug.Telemetry`)
|
|
- `[:phoenix, :endpoint, :stop]` — response sent
|
|
- `[:phoenix, :router_dispatch, :start]` — route dispatch begins
|
|
- `[:phoenix, :router_dispatch, :stop]` — route dispatch succeeds
|
|
- `[:phoenix, :router_dispatch, :exception]` — route dispatch raises
|
|
- `[:phoenix, :socket_connected]` — socket connection established
|
|
|
|
**Why:** Telemetry is baked into every request path. The router wraps ALL dispatches in start/stop/exception telemetry, enabling monitoring, tracing (OpenTelemetry), and logging without modifying application code.
|
|
|
|
**Anti-pattern:** Manual timing/logging in controllers — telemetry provides this automatically at the infrastructure level.
|
|
|
|
---
|
|
|
|
## 10. ConnTest Pattern: Endpoint-Based Integration Testing
|
|
|
|
**Source:** `lib/phoenix/test/conn_test.ex:1-30` (moduledoc)
|
|
|
|
```elixir
|
|
@endpoint MyAppWeb.Endpoint
|
|
|
|
test "says welcome on the home page" do
|
|
conn = get(build_conn(), "/")
|
|
assert conn.resp_body =~ "Welcome!"
|
|
end
|
|
|
|
test "logs in" do
|
|
conn = post(build_conn(), "/login", [username: "john", password: "doe"])
|
|
assert conn.resp_body =~ "Logged in!"
|
|
end
|
|
```
|
|
|
|
**Why:** `Phoenix.ConnTest` tests against the full endpoint stack (plugs, router, controller) without starting an HTTP server. `build_conn()` creates a test connection, HTTP verb functions dispatch through the endpoint. This gives integration-level confidence with unit-test speed.
|
|
|
|
**Anti-pattern:** Testing controllers in isolation without the plug pipeline — you miss middleware bugs (auth, CSRF, sessions).
|
|
|
|
---
|
|
|
|
## 11. ChannelTest Pattern: Process-Based Channel Testing
|
|
|
|
**Source:** `lib/phoenix/test/channel_test.ex:1-30` (moduledoc)
|
|
|
|
```elixir
|
|
{:ok, _, socket} =
|
|
socket(UserSocket, "user:id", %{some_assigns: 1})
|
|
|> subscribe_and_join(RoomChannel, "room:lobby", %{"id" => 3})
|
|
|
|
# Or using connect/3 to call your UserSocket.connect callback:
|
|
{:ok, socket} = connect(UserSocket, %{"some" => "params"}, %{})
|
|
{:ok, _, socket} = subscribe_and_join(socket, "room:lobby", %{"id" => 3})
|
|
```
|
|
|
|
**Why:** Channel tests communicate via messages (not HTTP). `subscribe_and_join/4` connects a test process to the channel, and you can assert on broadcasts (`assert_broadcast`), pushes (`assert_push`), and replies (`assert_reply`). The test process subscribes to the same PubSub topic, so it sees everything the channel broadcasts.
|
|
|
|
**Anti-pattern:** Testing channels by connecting real WebSocket clients — too slow, too brittle, tests the transport layer unnecessarily.
|