From a1eebb48a5c8a905afb8331a4e3db87aec3a8be3 Mon Sep 17 00:00:00 2001 From: Aaron Weiker Date: Wed, 29 Apr 2026 23:10:14 -0700 Subject: [PATCH] refactor: remove Phoenix content (moved to rodin/phoenix-patterns) --- README.md | 46 +---- comparison/elixir-vs-phoenix.md | 149 -------------- phoenix/deviations.md | 170 ---------------- phoenix/patterns.md | 334 -------------------------------- 4 files changed, 7 insertions(+), 692 deletions(-) delete mode 100644 comparison/elixir-vs-phoenix.md delete mode 100644 phoenix/deviations.md delete mode 100644 phoenix/patterns.md diff --git a/README.md b/README.md index 0389894..cdda78d 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,15 @@ -# Idiomatic Elixir & Phoenix Patterns +# Elixir Patterns -Patterns, conventions, and code smells extracted directly from the Elixir and Phoenix source code — with citations to specific files and line numbers. - -This is not opinion. This is what the source actually does. - -## Source Versions - -- **Elixir:** `main` branch (cloned 2026-04-29) -- **Phoenix:** `main` branch (cloned 2026-04-29) +Idiomatic Elixir patterns extracted from the [Elixir source code](https://github.com/elixir-lang/elixir) with verified file:line citations. ## Structure -### Core Patterns (`patterns/`) -- [GenServer Patterns](patterns/genserver.md) — Client/server separation, callbacks, state design -- [Error Handling](patterns/error-handling.md) — Error tuples, raise vs return, `with` chains -- [Data Transforms](patterns/data-transforms.md) — Pipelines, Enum/Stream idioms, reduce patterns -- [Process Design](patterns/process-design.md) — Supervision trees, process lifecycle, naming -- [Testing](patterns/testing.md) — ExUnit patterns, assertions, test organization -- [Documentation](patterns/documentation.md) — @moduledoc, @doc, @spec conventions -- [Typespecs](patterns/typespecs.md) — Type definitions, opaque types, when to use what -- [Macros](patterns/macros.md) — Macro patterns, hygiene, compile-time work -- [Behaviours](patterns/behaviours.md) — Behaviour design, callbacks, optional callbacks -- [Modules](patterns/modules.md) — Module organization, naming, structure - -### Phoenix Patterns (`phoenix/`) -- [Phoenix Patterns](phoenix/patterns.md) — Endpoint, Router, Controller, Channel, PubSub -- [Phoenix Deviations](phoenix/deviations.md) — Where Phoenix differs from Elixir core - -### Comparison (`comparison/`) -- [Elixir vs Phoenix](comparison/elixir-vs-phoenix.md) — Side-by-side comparison of approaches - -### Code Smells (`smells/`) -- [Anti-Patterns](smells/anti-patterns.md) — Things the source avoids and why -- [Common Mistakes](smells/common-mistakes.md) — What "bad Elixir" looks like +- `patterns/` — Core patterns (GenServer, error handling, data transforms, processes, testing, docs, typespecs, macros, behaviours, modules) +- `smells/` — Anti-patterns and common mistakes the Elixir team avoids +- `changelog/` — Daily digest of merged Elixir PRs with discussion summaries ## Philosophy -Every pattern here is backed by a source citation. If a pattern can't point to where the Elixir or Phoenix team actually does it, it doesn't belong here. +These rules are derived from what the Elixir source code *actually does*, not opinions or blog posts. Every pattern cites specific files and line numbers. -## Contributing - -Found a pattern worth adding? Open a PR with: -1. The pattern name -2. The exact file path and line range -3. Why it matters -4. What the anti-pattern looks like +When unsure how to do something in Elixir, look at how Elixir core does it. This is how we define what "idiomatic" actually means. diff --git a/comparison/elixir-vs-phoenix.md b/comparison/elixir-vs-phoenix.md deleted file mode 100644 index e2d437c..0000000 --- a/comparison/elixir-vs-phoenix.md +++ /dev/null @@ -1,149 +0,0 @@ -# Elixir Core vs Phoenix: Side-by-Side Comparison - -How the same concepts are approached differently (or similarly) between Elixir core and Phoenix. - -## Process Lifecycle - -| Aspect | Elixir Core | Phoenix | -|--------|-------------|---------| -| **Default restart** | `:permanent` (GenServer, Supervisor) | `:temporary` (Channel) | -| **Hibernation** | Not set by default | 15s idle → hibernate (Channel) | -| **Process identity** | Registry `:via` tuples | Topic-based (channels identified by topic) | -| **Supervision** | Direct supervisor reference | Endpoint supervisor manages all | - -**Source (Elixir):** `lib/elixir/lib/gen_server.ex:911-919` (child_spec defaults to :permanent via Supervisor.child_spec) -**Source (Phoenix):** `lib/phoenix/channel.ex:464-472` (explicit restart: :temporary) - ---- - -## Error Handling - -| Aspect | Elixir Core | Phoenix | -|--------|-------------|---------| -| **Exception design** | Minimal struct fields | HTTP-aware (`plug_status`) | -| **Bang functions** | `File.read!` raises | `broadcast!` raises | -| **Failure response** | `{:error, reason}` tuple | `{:error, reason}` + HTTP status | -| **Recovery** | Supervisor restart | Client reconnection | - -**Source (Elixir):** `lib/elixir/lib/agent.ex:187` (standard on_start type: `{:ok, pid} | {:error, ...}`) -**Source (Phoenix):** `lib/phoenix/router.ex:2-6` (NoRouteError with plug_status: 404) - ---- - -## Behaviour Design - -| Aspect | Elixir Core | Phoenix | -|--------|-------------|---------| -| **Required callbacks** | Most are optional | Only `join/3` required (Channel) | -| **`__using__` generates** | `child_spec/1` + `@behaviour` | child_spec + behaviour + config + imports | -| **Configuration** | Via `use Module, opts` | Via `use Module, opts` + module attributes | -| **Before-compile** | Rarely used | Heavily used (routes, intercepts) | - -**Source (Elixir):** `lib/elixir/lib/gen_server.ex:899-919` (__using__ generates child_spec + @behaviour) -**Source (Phoenix):** `lib/phoenix/channel.ex:450-485` (__using__ generates child_spec + behaviour + DSL setup) - ---- - -## Macro Usage - -| Aspect | Elixir Core | Phoenix | -|--------|-------------|---------| -| **Philosophy** | Minimal, prefer functions | Justified by performance | -| **`__using__`** | Generates 1-2 functions | Generates functions + sets up DSL | -| **DSL creation** | Avoided (except Kernel/SpecialForms) | Embraced (Router DSL) | -| **Attribute accumulation** | Rare | Central pattern (routes, sockets) | - -**Source (Elixir):** `lib/elixir/lib/gen_server.ex:899` — simple `__using__` (behaviour + child_spec + defaults) -**Source (Phoenix):** `lib/phoenix/router.ex:288-312` — complex DSL setup with attribute accumulation, imports, and @before_compile - ---- - -## Module Organization - -| Aspect | Elixir Core | Phoenix | -|--------|-------------|---------| -| **File naming** | `gen_server.ex` (snake_case) | `controller.ex` (snake_case) | -| **Nesting** | 2 levels max (`Task.Supervised`) | 2-3 levels (`Phoenix.Channel.Server`) | -| **Internal modules** | `@moduledoc false` | `@moduledoc false` | -| **Public API** | Functions on the main module | Functions + macros on the main module | - -Both follow the same convention: public API on the parent module, implementation details in nested submodules with `@moduledoc false`. - ---- - -## State Management - -| Aspect | Elixir Core | Phoenix | -|--------|-------------|---------| -| **Agent** | Simple state, function-based access | Socket assigns (`assign/2`) | -| **GenServer** | Full control, handle_call/cast/info | Channel handles (same callbacks) | -| **State shape** | Any term (developer's choice) | `%Socket{}` struct (framework-defined) | -| **State access** | Direct in callbacks | Via `socket.assigns` | - -**Source (Elixir):** `lib/elixir/lib/agent.ex:62-82` (compute in server vs client pattern) -**Source (Phoenix):** `lib/phoenix/channel.ex:463` (`import Phoenix.Socket, only: [assign: 3, assign: 2]`) - ---- - -## Documentation - -| Aspect | Elixir Core | Phoenix | -|--------|-------------|---------| -| **Moduledoc size** | Very large (GenServer: 530 lines) | Large (Router: ~260 lines) | -| **Examples** | Doctests (verified by tests) | Examples in docs (not always doctests) | -| **Admonitions** | Info blocks for `use` | Info blocks for `use` | -| **Guides** | Linked from moduledoc | Linked from moduledoc | -| **Deprecation** | `@doc deprecated: "Use X instead"` | Inline comments (TODO markers) | - -Both use the same documentation infrastructure (ExDoc), but Elixir core tends toward more exhaustive docs (GenServer's moduledoc is essentially a tutorial). - ---- - -## Configuration - -| Aspect | Elixir Core | Phoenix | -|--------|-------------|---------| -| **Compile-time** | Module attributes | `Application.compile_env` | -| **Runtime** | Application env / init args | `config/2` callback + Application env | -| **Per-instance** | Options to `start_link` | Endpoint config per environment | - -**Source (Phoenix):** `lib/phoenix/endpoint.ex:422-430` (compile-time config checking) - -```elixir -var!(code_reloading?) = - Application.compile_env(@otp_app, [__MODULE__, :code_reloader], false) -``` - -This pattern — reading config at compile time and validating it against runtime — is Phoenix-specific. Elixir core reads config only at runtime. - ---- - -## Telemetry - -| Aspect | Elixir Core | Phoenix | -|--------|-------------|---------| -| **Built-in events** | None (telemetry is a separate library) | Extensive event catalog | -| **Instrumentation** | Manual by library authors | Baked into router, endpoint, socket | -| **Event naming** | Varies by library | `[:phoenix, :component, :phase]` convention | -| **Logging** | `Logger` calls | Telemetry → Logger adapter (`Phoenix.Logger`) | - -**Source (Phoenix):** `lib/phoenix/logger.ex:7-50` (telemetry event catalog) -**Source (Phoenix):** `lib/phoenix/router.ex:400-438` (telemetry in router dispatch) - -Phoenix wraps every request dispatch in telemetry start/stop/exception events. This provides distributed tracing, monitoring, and logging without any application code changes. - ---- - -## Testing - -| Aspect | Elixir Core | Phoenix | -|--------|-------------|---------| -| **Test helper** | `ExUnit.Case` | `Phoenix.ConnTest`, `Phoenix.ChannelTest` | -| **Test subject** | Module functions | Endpoint (full plug pipeline) | -| **Communication** | Direct function calls | HTTP verbs (ConnTest), messages (ChannelTest) | -| **Isolation** | Process per test | Process per test + sandbox (Ecto) | - -**Source (Phoenix):** `lib/phoenix/test/conn_test.ex:1-30` (endpoint-based integration testing) -**Source (Phoenix):** `lib/phoenix/test/channel_test.ex:1-30` (process-based channel testing) - -Phoenix test helpers test at the integration level by default — `ConnTest` dispatches through the full plug pipeline, `ChannelTest` exercises the full channel lifecycle via message passing. This catches middleware bugs that unit tests miss. diff --git a/phoenix/deviations.md b/phoenix/deviations.md deleted file mode 100644 index 98a7b32..0000000 --- a/phoenix/deviations.md +++ /dev/null @@ -1,170 +0,0 @@ -# Phoenix Deviations from Elixir Core - -Where Phoenix deliberately differs from Elixir core patterns and why. - -## 1. Heavy Macro Usage for Performance - -**Elixir core philosophy:** Keep macro usage minimal. From the Router source: - -> Phoenix does its best to keep the usage of macros low. - -**Phoenix deviation:** The Router uses macros extensively. - -**Source:** `lib/phoenix/router.ex:106-128` - -> We use `get`, `post`, `put`, and `delete` to define your routes. We use macros -> for two purposes: -> -> * They define the routing engine... Phoenix compiles all of your routes to a -> single case-statement with pattern matching rules -> -> * For each route you define, we also define metadata to implement -> `Phoenix.VerifiedRoutes` - -**Why the deviation:** Performance. Elixir core uses macros sparingly because they add cognitive complexity. Phoenix justifies them because routing is the hottest path in a web app — compile-time optimization yields measurable request/second gains. - ---- - -## 2. `import` without Restriction in Router - -**Elixir core pattern:** Always use `import Module, only: [...]` to be explicit. - -**Phoenix deviation:** The Router imports entire modules: - -**Source:** `lib/phoenix/router.ex:303-306` - -```elixir -import Phoenix.Router - -# TODO v2: No longer automatically import dependencies -import Plug.Conn -import Phoenix.Controller -``` - -**Why the deviation:** The Router is a DSL. Users need `get`, `post`, `pipe_through`, `scope`, `resources`, `plug`, `fetch_session`, etc. — all available without qualification. Restricting imports would make the DSL unusable. - ---- - -## 3. Compile-Time State Accumulation - -**Elixir core pattern:** Modules are generally stateless during compilation. Functions are defined and that's it. - -**Phoenix deviation:** Aggressive use of module attribute accumulation. - -**Source:** `lib/phoenix/router.ex:297-312` - -```elixir -defp prelude(opts) do - quote do - Module.register_attribute(__MODULE__, :phoenix_routes, accumulate: true) - @phoenix_helpers Keyword.get(unquote(opts), :helpers, true) - - import Phoenix.Router - import Plug.Conn - import Phoenix.Controller - - # Set up initial scope - @phoenix_pipeline nil - Phoenix.Router.Scope.init(__MODULE__) - @before_compile unquote(__MODULE__) - end -end -``` - -**Why the deviation:** The Router needs to collect ALL routes, then compile them into a single dispatch function. This requires building up state during module compilation, then consuming it all at `@before_compile`. - ---- - -## 4. Channel Restart Strategy: `:temporary` - -**Elixir core GenServer default:** `:permanent` (always restart). - -**Phoenix Channel default:** `:temporary` (never restart). - -**Source:** `lib/phoenix/channel.ex:464-472` - -```elixir -def child_spec(init_arg) do - %{ - id: __MODULE__, - start: {__MODULE__, :start_link, [init_arg]}, - shutdown: @phoenix_shutdown, - restart: :temporary - } -end -``` - -**Why the deviation:** A crashed channel should NOT auto-restart — the client needs to explicitly reconnect and rejoin. Auto-restarting would create a channel without a connected client, which is meaningless. - ---- - -## 5. Auto-Hibernation - -**Elixir core GenServer:** No default hibernation — processes stay in memory. - -**Phoenix Channel:** Defaults to hibernate after 15 seconds of inactivity. - -**Source:** `lib/phoenix/channel.ex:459` - -```elixir -@phoenix_hibernate_after Keyword.get(opts, :hibernate_after, 15_000) -``` - -```elixir -def start_link(triplet) do - GenServer.start_link(Phoenix.Channel.Server, triplet, - hibernate_after: @phoenix_hibernate_after - ) -end -``` - -**Why the deviation:** Web apps have many idle connections. Channels for users who are "connected but not active" are common. Hibernation reclaims memory for the heap without killing the process. A chat app with 10,000 connected users benefits enormously. - ---- - -## 6. `Plug.Builder` vs Raw Behaviour - -**Elixir core:** Behaviours define contracts. Implementations are manual. - -**Phoenix Endpoint:** Uses `Plug.Builder` — a macro that generates the `call/2` pipeline by chaining plugs at compile time. - -**Source:** `lib/phoenix/endpoint.ex:478-480` - -```elixir -defp plug() do - quote location: :keep do - use Plug.Builder, init_mode: Phoenix.plug_init_mode() - ... - end -end -``` - -**Why the deviation:** The Plug specification (`init/1` + `call/2`) is too low-level for composing dozens of middleware. `Plug.Builder` provides the `plug` macro that chains them automatically. It's a higher-level abstraction over the raw behaviour pattern. - ---- - -## 7. Exception Structs with HTTP Status Codes - -**Elixir core exceptions:** Pure data — message, maybe some context fields. - -**Phoenix exceptions:** Include `plug_status` for HTTP response mapping. - -**Source:** `lib/phoenix/router.ex:2-26` - -```elixir -defmodule NoRouteError do - @moduledoc """ - Exception raised when no route is found. - """ - defexception plug_status: 404, message: "no route found", conn: nil, router: nil -end - -defmodule MalformedURIError do - @moduledoc """ - Exception raised when the URI is malformed on matching. - """ - defexception [:message, plug_status: 400] -end -``` - -**Why the deviation:** In a web context, exceptions need to map to HTTP status codes. Plug's error handling middleware reads `plug_status` to determine the response code. This bridges the gap between Elixir's exception system and HTTP semantics. diff --git a/phoenix/patterns.md b/phoenix/patterns.md deleted file mode 100644 index 3a5c9e0..0000000 --- a/phoenix/patterns.md +++ /dev/null @@ -1,334 +0,0 @@ -# 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.