refactor: remove Phoenix content (moved to rodin/phoenix-patterns)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user