From 5de38d6fc447759f8a0ddf0b930fbd19053e2329 Mon Sep 17 00:00:00 2001 From: Aaron Weiker Date: Thu, 30 Apr 2026 06:47:10 -0700 Subject: [PATCH] docs: add when/when-not to all Phoenix patterns --- patterns/comparison.md | 800 +++++++++++++++++++++++++++++++++++ patterns/deviations.md | 519 +++++++++++++++++++++++ patterns/patterns.md | 941 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 2260 insertions(+) diff --git a/patterns/comparison.md b/patterns/comparison.md index e2d437c..776c8ef 100644 --- a/patterns/comparison.md +++ b/patterns/comparison.md @@ -14,6 +14,77 @@ How the same concepts are approached differently (or similarly) between Elixir c **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) +### When to Use + +**Triggers:** +- You are designing a new process and need to choose lifecycle semantics +- You are deciding between Registry-based identity vs topic-based identity +- You need to choose a supervision strategy for client-bound vs autonomous processes + +**Example — before:** + +```elixir +# Copying Elixir defaults without thinking about the domain +defmodule MyApp.WebSocketSession do + use GenServer + # restart: :permanent (default) — but this is client-bound! + # If it crashes, it restarts without a client → zombie process + + def init({user_id, ws_pid}) do + {:ok, %{user_id: user_id, ws_pid: ws_pid}} + end +end +``` + +**Example — after:** + +```elixir +# Choosing lifecycle semantics based on the domain +defmodule MyApp.WebSocketSession do + use GenServer, restart: :temporary, hibernate_after: 15_000 + # temporary: client will reconnect if needed + # hibernate: many idle sessions expected + + def init({user_id, ws_pid}) do + Process.monitor(ws_pid) # die when client disconnects + {:ok, %{user_id: user_id, ws_pid: ws_pid}} + end +end +``` + +### When NOT to Use + +**Don't use this when:** You are blindly applying Phoenix's `:temporary` + hibernation to all processes regardless of their role. + +**Over-application example:** + +```elixir +# Bad: Applying channel-style lifecycle to a stateful worker +defmodule MyApp.OrderFulfillment do + use GenServer, restart: :temporary, hibernate_after: 15_000 + # This process processes orders from a queue + # If it crashes, orders are lost (temporary = no restart) + # It's always active processing — hibernate never triggers +end +``` + +**Better alternative:** + +```elixir +# Match lifecycle to the process's role +defmodule MyApp.OrderFulfillment do + use GenServer # restart: :permanent (default) — must survive crashes + # No hibernate_after — always active, processing queue items + + def init(state) do + # On restart, resume from last checkpoint + {:ok, recover_state(state)} + end +end +``` + +**Why:** Phoenix's lifecycle choices (temporary, hibernation) are optimized for client-bound, mostly-idle processes. Autonomous workers that own state need permanent restart and don't benefit from hibernation. + --- ## Error Handling @@ -28,6 +99,98 @@ How the same concepts are approached differently (or similarly) between Elixir c **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) +### When to Use + +**Triggers:** +- You are defining error types for a web application +- You need to decide between `{:error, reason}` tuples vs raising exceptions +- You want consistent error semantics across HTTP and internal code + +**Example — before:** + +```elixir +# Mixing error strategies inconsistently +defmodule MyApp.Accounts do + def get_user(id) do + case Repo.get(User, id) do + nil -> raise "User not found" # Raises in domain code — forces rescue everywhere + user -> user + end + end + + def update_user(user, attrs) do + # Returns tuple here but raised above — inconsistent + User.changeset(user, attrs) |> Repo.update() + end +end +``` + +**Example — after:** + +```elixir +# Elixir-style: tuples in domain, exceptions at boundaries +defmodule MyApp.Accounts do + def get_user(id) do + case Repo.get(User, id) do + nil -> {:error, :not_found} + user -> {:ok, user} + end + end + + def get_user!(id) do + Repo.get!(User, id) # Bang version raises — for use at boundaries + end + + def update_user(user, attrs) do + User.changeset(user, attrs) |> Repo.update() + end +end + +# Controller uses bang (boundary) or handles tuple +defmodule MyAppWeb.UserController do + def show(conn, %{"id" => id}) do + user = Accounts.get_user!(id) # Raises → 404 via ErrorView + render(conn, :show, user: user) + end +end +``` + +### When NOT to Use + +**Don't use this when:** You're embedding HTTP semantics deep in domain logic, or when you're using exceptions for control flow in non-error paths. + +**Over-application example:** + +```elixir +# Bad: Using exceptions for expected business outcomes +defmodule MyApp.Checkout do + def apply_coupon(cart, code) do + case Coupons.validate(code) do + {:ok, coupon} -> {:ok, apply_discount(cart, coupon)} + {:error, :expired} -> raise %CouponExpiredError{plug_status: 422} + {:error, :invalid} -> raise %CouponInvalidError{plug_status: 422} + # Expired/invalid coupons are expected outcomes, not exceptions! + end + end +end +``` + +**Better alternative:** + +```elixir +# Return tuples for expected outcomes — only raise for truly exceptional cases +defmodule MyApp.Checkout do + def apply_coupon(cart, code) do + case Coupons.validate(code) do + {:ok, coupon} -> {:ok, apply_discount(cart, coupon)} + {:error, reason} -> {:error, reason} # Let controller decide HTTP response + end + end +end +``` + +**Why:** Exceptions should be exceptional. Expected business outcomes (invalid coupon, insufficient funds, duplicate email) are not bugs — they're normal paths that should return tuples. Reserve exceptions for truly unexpected states. + --- ## Behaviour Design @@ -42,6 +205,95 @@ How the same concepts are approached differently (or similarly) between Elixir c **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) +### When to Use + +**Triggers:** +- You are designing a callback-based module (plugin system, handler framework) +- You need to decide which callbacks should be required vs optional +- You are choosing between minimal `__using__` (Elixir-style) vs rich `__using__` (Phoenix-style) + +**Example — before:** + +```elixir +# Over-specifying required callbacks — burdens implementers +defmodule MyApp.Handler do + @callback init(opts :: keyword()) :: {:ok, state :: term()} + @callback handle_event(event :: term(), state :: term()) :: {:ok, state :: term()} + @callback handle_error(error :: term(), state :: term()) :: {:ok, state :: term()} + @callback terminate(reason :: term(), state :: term()) :: :ok + @callback format_status(state :: term()) :: term() + # All required — implementers must define 5 functions even for simple cases +end +``` + +**Example — after:** + +```elixir +# Minimal required callbacks + sensible defaults (Phoenix-style) +defmodule MyApp.Handler do + @callback handle_event(event :: term(), state :: term()) :: {:ok, state :: term()} + # Only the essential callback is required + + @optional_callbacks [handle_error: 2, terminate: 2] + + defmacro __using__(opts) do + quote do + @behaviour MyApp.Handler + + # Default implementations — override only what you need + def handle_error(error, state) do + Logger.error("Unhandled error: #{inspect(error)}") + {:ok, state} + end + + def terminate(_reason, _state), do: :ok + + defoverridable handle_error: 2, terminate: 2 + end + end +end +``` + +### When NOT to Use + +**Don't use this when:** Every implementation genuinely needs all callbacks (no sensible defaults exist), or when `__using__` would generate so much code that the user can't understand what their module does. + +**Over-application example:** + +```elixir +# Bad: __using__ that generates everything — user's module is empty +defmodule MyApp.MagicModule do + defmacro __using__(_opts) do + quote do + # Generates 200 lines of functions, imports, attributes + # The user's module is just `use MyApp.MagicModule` with nothing else + # Impossible to understand, debug, or customize + end + end +end +``` + +**Better alternative:** + +```elixir +# Generate only the boilerplate; leave the interesting code to the user +defmodule MyApp.Handler do + defmacro __using__(opts) do + quote do + @behaviour MyApp.Handler + @impl true + def child_spec(init_arg) do + # Only generating the standard boilerplate + %{id: __MODULE__, start: {__MODULE__, :start_link, [init_arg]}} + end + defoverridable child_spec: 1 + end + end +end +``` + +**Why:** `__using__` should reduce boilerplate, not hide architecture. If a user can't tell what their module does without reading the macro source, the abstraction has gone too far. + --- ## Macro Usage @@ -56,6 +308,72 @@ How the same concepts are approached differently (or similarly) between Elixir c **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 +### When to Use + +**Triggers:** +- You need compile-time code generation for performance (hot path optimization) +- You are building a user-facing DSL where macros enable a natural syntax +- The alternative (runtime dispatch, dynamic functions) has measurable performance cost + +**Example — before:** + +```elixir +# Runtime dispatch — acceptable for most code +defmodule MyApp.Serializer do + @formats %{json: Jason, msgpack: Msgpax, csv: NimbleCSV} + + def encode(data, format) do + module = Map.fetch!(@formats, format) + module.encode!(data) + end +end +``` + +**Example — after:** + +```elixir +# Compile-time dispatch — justified if called millions of times/sec +defmodule MyApp.Serializer do + @formats %{json: Jason, msgpack: Msgpax, csv: NimbleCSV} + + for {format, module} <- @formats do + def encode(data, unquote(format)) do + unquote(module).encode!(data) + end + end +end +``` + +### When NOT to Use + +**Don't use this when:** A function would work fine, the code path isn't hot, or the macro makes error messages and stack traces harder to understand. + +**Over-application example:** + +```elixir +# Bad: Macro for something called once at startup +defmacro configure(opts) do + quote do + @config unquote(opts) + def config, do: @config + end +end + +# Used once: +configure(database: "myapp", pool_size: 10) +``` + +**Better alternative:** + +```elixir +# A function — simpler, debuggable, no compile-time complexity +def config do + %{database: "myapp", pool_size: 10} +end +``` + +**Why:** Every macro is a bet that compile-time complexity pays for itself in runtime performance or developer ergonomics. If neither benefit materializes (cold path, simple structure), the macro just adds confusion. + --- ## Module Organization @@ -69,6 +387,70 @@ How the same concepts are approached differently (or similarly) between Elixir c Both follow the same convention: public API on the parent module, implementation details in nested submodules with `@moduledoc false`. +### When to Use + +**Triggers:** +- You are organizing a library or application into modules +- You need to decide what's public API vs internal implementation +- You are choosing module nesting depth + +**Example — before:** + +```elixir +# Flat structure — everything exposed, no clear boundaries +defmodule MyApp.Auth do ... end +defmodule MyApp.AuthToken do ... end +defmodule MyApp.AuthSession do ... end +defmodule MyApp.AuthHelpers do ... end +defmodule MyApp.AuthPasswordReset do ... end +# Which ones are public API? Which are implementation details? +``` + +**Example — after:** + +```elixir +# Nested with clear public/private boundaries +defmodule MyApp.Auth do + @moduledoc "Public authentication API" + # Public: login/2, logout/1, current_user/1 +end + +defmodule MyApp.Auth.Token do + @moduledoc false # Internal — used by Auth, not called directly +end + +defmodule MyApp.Auth.Session do + @moduledoc false # Internal +end +``` + +### When NOT to Use + +**Don't use this when:** Nesting creates deeply nested modules (4+ levels) that are hard to reference, or when flat organization genuinely reflects the lack of hierarchy. + +**Over-application example:** + +```elixir +# Bad: Excessive nesting — hard to type, hard to alias +defmodule MyApp.Accounts.Users.Authentication.Strategies.OAuth.Google.Callback do + # 7 levels deep — unmanageable +end +``` + +**Better alternative:** + +```elixir +# 2-3 levels max — clear but manageable +defmodule MyApp.Accounts.OAuth do + @moduledoc "OAuth authentication strategies" + + def google_callback(params), do: # ... + def github_callback(params), do: # ... +end +``` + +**Why:** Module nesting should reflect logical containment, not directory structure. Beyond 3 levels, the module names become unwieldy and the hierarchy stops communicating useful information. + --- ## State Management @@ -83,6 +465,102 @@ Both follow the same convention: public API on the parent module, implementation **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]`) +### When to Use + +**Triggers:** +- You are choosing a state management approach for a new process +- You need to decide between free-form state vs structured assigns +- You are designing a framework/library that manages state on behalf of users + +**Example — before:** + +```elixir +# Unstructured state — grows into a mess +defmodule MyApp.ChatRoom do + use GenServer + + def init(_) do + {:ok, %{}} # What goes here? Nobody knows until they read all handlers + end + + def handle_call(:get_users, _from, state) do + {:reply, state.users, state} # Hope `users` key exists... + end + + def handle_cast({:add_message, msg}, state) do + messages = [msg | state[:messages] || []] + {:noreply, Map.put(state, :messages, messages)} + end +end +``` + +**Example — after:** + +```elixir +# Structured state with assigns pattern (Phoenix-style) +defmodule MyApp.ChatRoom do + use GenServer + + defstruct [:room_id, users: MapSet.new(), messages: []] + + def init(room_id) do + {:ok, %__MODULE__{room_id: room_id}} + end + + def handle_call(:get_users, _from, %{users: users} = state) do + {:reply, MapSet.to_list(users), state} + end + + def handle_cast({:add_message, msg}, state) do + {:noreply, %{state | messages: [msg | state.messages]}} + end +end +``` + +### When NOT to Use + +**Don't use this when:** The state is genuinely simple (a counter, a single value) and a struct adds unnecessary ceremony. + +**Over-application example:** + +```elixir +# Bad: Over-engineering state for a simple counter +defmodule MyApp.Counter do + use GenServer + + defstruct [:name, :namespace, :created_at, value: 0, history: [], metadata: %{}] + + def init(opts) do + {:ok, %__MODULE__{ + name: opts[:name], + namespace: opts[:namespace] || :default, + created_at: DateTime.utc_now(), + metadata: %{version: 1} + }} + end + + # All this for increment/decrement... +end +``` + +**Better alternative:** + +```elixir +# Simple state for simple needs +defmodule MyApp.Counter do + use Agent + + def start_link(initial \\ 0) do + Agent.start_link(fn -> initial end) + end + + def increment(counter), do: Agent.update(counter, &(&1 + 1)) + def value(counter), do: Agent.get(counter, & &1) +end +``` + +**Why:** State structure should match problem complexity. A counter doesn't need a struct. A chat room with users, messages, and metadata does. Match the tool to the job. + --- ## Documentation @@ -97,6 +575,104 @@ Both follow the same convention: public API on the parent module, implementation Both use the same documentation infrastructure (ExDoc), but Elixir core tends toward more exhaustive docs (GenServer's moduledoc is essentially a tutorial). +### When to Use + +**Triggers:** +- You are writing a public API (library or shared module) +- You need to decide between doctests vs example blocks +- You are structuring documentation for a complex module + +**Example — before:** + +```elixir +# Minimal docs — users have to read source to understand +defmodule MyApp.Cache do + @moduledoc "A cache." + + @doc "Gets a value." + def get(key), do: # ... + + @doc "Puts a value." + def put(key, value), do: # ... +end +``` + +**Example — after:** + +```elixir +# Rich docs with examples and context (Elixir core style) +defmodule MyApp.Cache do + @moduledoc """ + An in-memory cache with TTL support. + + ## Usage + + cache = MyApp.Cache.start_link(ttl: :timer.minutes(5)) + MyApp.Cache.put(cache, "key", "value") + MyApp.Cache.get(cache, "key") + #=> {:ok, "value"} + + ## Options + + * `:ttl` - Time-to-live in milliseconds (default: 60_000) + * `:max_size` - Maximum entries (default: 1000) + + ## Eviction + + When `max_size` is exceeded, the oldest entries are evicted first (FIFO). + """ + + @doc """ + Gets a value by key. + + Returns `{:ok, value}` if found, `:error` if missing or expired. + + ## Examples + + iex> {:ok, cache} = MyApp.Cache.start_link([]) + iex> MyApp.Cache.put(cache, "k", "v") + iex> MyApp.Cache.get(cache, "k") + {:ok, "v"} + iex> MyApp.Cache.get(cache, "missing") + :error + """ + def get(cache, key), do: # ... +end +``` + +### When NOT to Use + +**Don't use this when:** The module is internal (`@moduledoc false`), or when docs would just restate the function name. + +**Over-application example:** + +```elixir +# Bad: Docs that add no information +defmodule MyApp.Internal.Helper do + @moduledoc "Internal helper module." + + @doc "Adds two numbers." + def add(a, b), do: a + b + + @doc "Subtracts b from a." + def subtract(a, b), do: a - b +end +``` + +**Better alternative:** + +```elixir +# Internal module — skip the ceremony +defmodule MyApp.Internal.Helper do + @moduledoc false + + def add(a, b), do: a + b + def subtract(a, b), do: a - b +end +``` + +**Why:** Documentation exists to help users understand non-obvious behavior. If the function signature already communicates everything, docs are noise. Internal modules don't need public-facing documentation. + --- ## Configuration @@ -116,6 +692,74 @@ var!(code_reloading?) = This pattern — reading config at compile time and validating it against runtime — is Phoenix-specific. Elixir core reads config only at runtime. +### When to Use + +**Triggers:** +- You need compile-time decisions (code generation, conditional compilation) +- You want to catch configuration errors at build time, not production runtime +- You have config that truly cannot change after compilation (module structure, generated functions) + +**Example — before:** + +```elixir +# Runtime config check on every call — wasteful for static decisions +defmodule MyApp.Mailer do + def deliver(email) do + if Application.get_env(:my_app, :enable_emails, true) do + # Actually send + HTTPClient.post(email) + else + # Dev mode — just log + Logger.info("Would send: #{inspect(email)}") + end + end +end +``` + +**Example — after:** + +```elixir +# Compile-time decision — no runtime branch for static config +defmodule MyApp.Mailer do + @send_emails Application.compile_env(:my_app, :enable_emails, true) + + if @send_emails do + def deliver(email), do: HTTPClient.post(email) + else + def deliver(email), do: Logger.info("Would send: #{inspect(email)}") + end +end +``` + +### When NOT to Use + +**Don't use this when:** The config might change at runtime (feature flags, environment variables read at startup), or when you need different behavior across nodes in a release. + +**Over-application example:** + +```elixir +# Bad: Compile-time config for something that should be toggleable +defmodule MyApp.FeatureFlags do + @dark_mode Application.compile_env(:my_app, :dark_mode, false) + # Can't toggle dark mode without recompiling and redeploying! + + def dark_mode_enabled?, do: @dark_mode +end +``` + +**Better alternative:** + +```elixir +# Runtime config for things that change +defmodule MyApp.FeatureFlags do + def dark_mode_enabled? do + Application.get_env(:my_app, :dark_mode, false) + end +end +``` + +**Why:** `compile_env` bakes the value into the BEAM bytecode. It's correct for structural decisions (which modules to compile, which code paths to include) but wrong for operational toggles that need to change without redeployment. + --- ## Telemetry @@ -132,6 +776,82 @@ This pattern — reading config at compile time and validating it against runtim Phoenix wraps every request dispatch in telemetry start/stop/exception events. This provides distributed tracing, monitoring, and logging without any application code changes. +### When to Use + +**Triggers:** +- You are building a library or framework that others will monitor +- You want to provide observability hooks without coupling to specific monitoring tools +- You need structured event emission at well-defined lifecycle points + +**Example — before:** + +```elixir +# Coupled to Logger — users can't plug in Prometheus/Datadog +defmodule MyApp.Queue do + require Logger + + def process(job) do + start = System.monotonic_time() + result = do_work(job) + duration = System.monotonic_time() - start + Logger.info("Job #{job.id} completed in #{duration}ns") + result + end +end +``` + +**Example — after:** + +```elixir +# Telemetry events — any monitoring tool can attach +defmodule MyApp.Queue do + def process(job) do + start = System.monotonic_time() + metadata = %{job_id: job.id, queue: job.queue} + :telemetry.execute([:my_app, :queue, :start], %{system_time: System.system_time()}, metadata) + + result = do_work(job) + + duration = System.monotonic_time() - start + :telemetry.execute([:my_app, :queue, :stop], %{duration: duration}, metadata) + result + rescue + e -> + :telemetry.execute([:my_app, :queue, :exception], %{duration: System.monotonic_time() - start}, metadata) + reraise e, __STACKTRACE__ + end +end +``` + +### When NOT to Use + +**Don't use this when:** You just need simple logging for debugging, or when the overhead of telemetry events isn't justified (internal helpers called rarely). + +**Over-application example:** + +```elixir +# Bad: Telemetry on a trivial helper function +defmodule MyApp.StringUtils do + def capitalize_name(name) do + :telemetry.execute([:my_app, :string_utils, :capitalize, :start], %{}, %{}) + result = String.capitalize(name) + :telemetry.execute([:my_app, :string_utils, :capitalize, :stop], %{}, %{}) + result + end +end +``` + +**Better alternative:** + +```elixir +# Just a function — no instrumentation needed +defmodule MyApp.StringUtils do + def capitalize_name(name), do: String.capitalize(name) +end +``` + +**Why:** Telemetry adds function call overhead and complexity. It's justified at boundaries (HTTP requests, DB queries, queue processing) where measurements drive operational decisions. Pure utility functions don't need observability hooks. + --- ## Testing @@ -147,3 +867,83 @@ Phoenix wraps every request dispatch in telemetry start/stop/exception events. T **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. + +### When to Use + +**Triggers:** +- You are testing Phoenix controllers, channels, or LiveViews +- You want to verify the full request/response cycle including middleware +- You need to test auth, CSRF, session handling, and content negotiation together + +**Example — before:** + +```elixir +# Testing at the wrong level — too low for web, misses middleware +defmodule MyAppWeb.ApiTest do + use ExUnit.Case + + test "returns user data" do + # Calling controller directly — bypasses auth, rate limiting, CORS + conn = Phoenix.ConnTest.build_conn() + result = MyAppWeb.ApiController.show(conn, %{"id" => "1"}) + assert result.status == 200 + end +end +``` + +**Example — after:** + +```elixir +# Testing at the right level — full integration through endpoint +defmodule MyAppWeb.ApiTest do + use MyAppWeb.ConnCase + + test "returns 401 without auth token" do + conn = get(build_conn(), ~p"/api/users/1") + assert json_response(conn, 401) + end + + test "returns user data with valid token" do + user = insert(:user) + conn = + build_conn() + |> put_req_header("authorization", "Bearer #{generate_token(user)}") + |> get(~p"/api/users/#{user}") + + assert %{"id" => id, "name" => name} = json_response(conn, 200) + assert id == user.id + end +end +``` + +### When NOT to Use + +**Don't use this when:** You are testing pure business logic, schema validations, or context functions that have no HTTP concerns. + +**Over-application example:** + +```elixir +# Bad: Using ConnCase for everything, even non-HTTP logic +defmodule MyApp.MathTest do + use MyAppWeb.ConnCase # Starts endpoint, sets up sandbox — all unnecessary + + test "adds numbers" do + assert MyApp.Math.add(1, 2) == 3 + end +end +``` + +**Better alternative:** + +```elixir +# Use the lightest test case that works +defmodule MyApp.MathTest do + use ExUnit.Case, async: true + + test "adds numbers" do + assert MyApp.Math.add(1, 2) == 3 + end +end +``` + +**Why:** `ConnCase` starts the endpoint supervisor, sets up the Ecto sandbox, and configures HTTP testing infrastructure. For pure functions, that's wasted setup time and obscured intent. Use `ExUnit.Case` (or `DataCase` for DB tests) when HTTP isn't involved. diff --git a/patterns/deviations.md b/patterns/deviations.md index 98a7b32..a1acf39 100644 --- a/patterns/deviations.md +++ b/patterns/deviations.md @@ -23,6 +23,81 @@ Where Phoenix deliberately differs from Elixir core patterns and why. **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. +### When to Use + +**Triggers:** +- You are writing a library where the hot path is called millions of times +- Compile-time knowledge can eliminate runtime branching entirely +- The macro generates a known, bounded set of functions (not arbitrary code) + +**Example — before:** + +```elixir +# Runtime dispatch — works but slow on hot paths +defmodule MyApp.Permissions do + @permissions [:read, :write, :delete, :admin] + + def check(user, permission) do + if permission in @permissions do + MapSet.member?(user.permissions, permission) + else + raise "Unknown permission: #{permission}" + end + end +end +``` + +**Example — after:** + +```elixir +# Compile-time generated pattern-match functions — O(1) dispatch +defmodule MyApp.Permissions do + @permissions [:read, :write, :delete, :admin] + + for perm <- @permissions do + def check(user, unquote(perm)) do + MapSet.member?(user.permissions, unquote(perm)) + end + end + + def check(_user, unknown), do: raise "Unknown permission: #{unknown}" +end +``` + +### When NOT to Use + +**Don't use this when:** The performance gain is negligible (cold paths, admin screens), or when the same result can be achieved with pattern matching or maps. + +**Over-application example:** + +```elixir +# Bad: Macros for a configuration screen hit once per admin session +defmodule MyApp.Admin.Settings do + # Generating functions for 3 settings that change once a month + for {key, default} <- [theme: "light", locale: "en", tz: "UTC"] do + defmacro unquote(:"get_#{key}")(user) do + # Over-engineered — this is called once when an admin loads settings + quote do: Map.get(unquote(user).settings, unquote(unquote(key)), unquote(unquote(default))) + end + end +end +``` + +**Better alternative:** + +```elixir +# Plain function — clear, testable, fast enough for cold paths +defmodule MyApp.Admin.Settings do + @defaults %{theme: "light", locale: "en", tz: "UTC"} + + def get(user, key) when is_map_key(@defaults, key) do + Map.get(user.settings, key, @defaults[key]) + end +end +``` + +**Why:** Macros add compile-time complexity, make stack traces harder to read, and complicate debugging. They're justified only when the performance difference matters at scale (thousands+ calls/second). + --- ## 2. `import` without Restriction in Router @@ -43,6 +118,83 @@ 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. +### When to Use + +**Triggers:** +- You are building a DSL where users need many functions from your module +- The imported functions are the primary API (not helpers leaking into scope) +- Qualified calls would make the DSL unreadable + +**Example — before:** + +```elixir +# With restricted imports — DSL becomes unreadable +defmodule MyAppWeb.Router do + import Phoenix.Router, only: [get: 3, post: 3, scope: 2, pipe_through: 1, pipeline: 2, plug: 1, plug: 2, resources: 3] + import Plug.Conn, only: [fetch_session: 2, put_secure_browser_headers: 2] + + Phoenix.Router.pipeline :browser do + Phoenix.Router.plug :fetch_session + # ... + end +end +``` + +**Example — after:** + +```elixir +# Full import — DSL reads naturally +defmodule MyAppWeb.Router do + use Phoenix.Router + + pipeline :browser do + plug :fetch_session + plug :accepts, ["html"] + end + + scope "/", MyAppWeb do + pipe_through :browser + get "/", PageController, :home + resources "/users", UserController + end +end +``` + +### When NOT to Use + +**Don't use this when:** You are importing a utility module where only 1-2 functions are needed, or in application code (not DSL contexts). + +**Over-application example:** + +```elixir +# Bad: Blanket import of a utility module in application code +defmodule MyApp.Workers.DataProcessor do + import Enum # pulls in 80+ functions + import String # pulls in 50+ functions + import Map # pulls in 30+ functions + + def process(data) do + data |> map(&transform/1) |> filter(&valid?/1) + # Which `map` is this? Enum.map or Map... confusion + end +end +``` + +**Better alternative:** + +```elixir +# Explicit imports in application code +defmodule MyApp.Workers.DataProcessor do + import Enum, only: [map: 2, filter: 2] + + def process(data) do + data |> map(&transform/1) |> filter(&valid?/1) + end +end +``` + +**Why:** Unrestricted imports in application code create namespace collisions and make code harder to trace. The DSL exception exists because DSLs are designed to be a controlled vocabulary — application code is not. + --- ## 3. Compile-Time State Accumulation @@ -73,6 +225,101 @@ 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`. +### When to Use + +**Triggers:** +- You need to collect declarations across a module and process them holistically +- The final output (generated function) depends on ALL accumulated items together +- Individual declarations can't be compiled in isolation — they need global context + +**Example — before:** + +```elixir +# Without accumulation — each handler is independent, no holistic optimization +defmodule MyApp.EventRouter do + def handle("user.created", payload), do: UserHandler.created(payload) + def handle("user.deleted", payload), do: UserHandler.deleted(payload) + def handle("order.placed", payload), do: OrderHandler.placed(payload) + # Works, but can't generate a dispatch table or validate at compile time +end +``` + +**Example — after:** + +```elixir +# With accumulation — collect all events, compile into optimized dispatch +defmodule MyApp.EventRouter do + use MyApp.EventRouter.DSL + + handle "user.created", UserHandler, :created + handle "user.deleted", UserHandler, :deleted + handle "order.placed", OrderHandler, :placed +end + +# The DSL accumulates handlers and generates optimized dispatch at @before_compile +defmodule MyApp.EventRouter.DSL do + defmacro __using__(_opts) do + quote do + Module.register_attribute(__MODULE__, :event_handlers, accumulate: true) + import MyApp.EventRouter.DSL, only: [handle: 3] + @before_compile MyApp.EventRouter.DSL + end + end + + defmacro handle(event, module, function) do + quote do + @event_handlers {unquote(event), unquote(module), unquote(function)} + end + end + + defmacro __before_compile__(env) do + handlers = Module.get_attribute(env.module, :event_handlers) + for {event, mod, fun} <- handlers do + quote do + def dispatch(unquote(event), payload), do: unquote(mod).unquote(fun)(payload) + end + end + end +end +``` + +### When NOT to Use + +**Don't use this when:** Each item can be compiled independently, or when a simple map/config file would suffice. + +**Over-application example:** + +```elixir +# Bad: Using compile-time accumulation for static config +defmodule MyApp.FeatureFlags do + Module.register_attribute(__MODULE__, :flags, accumulate: true) + + @flags {:dark_mode, true} + @flags {:beta_search, false} + @flags {:new_checkout, true} + + @before_compile __MODULE__ + # Over-engineered — these are just config values +end +``` + +**Better alternative:** + +```elixir +# A map is simpler and more flexible (can be loaded from config/env) +defmodule MyApp.FeatureFlags do + @flags %{ + dark_mode: true, + beta_search: false, + new_checkout: true + } + + def enabled?(flag), do: Map.get(@flags, flag, false) +end +``` + +**Why:** Compile-time accumulation adds cognitive overhead and makes the module harder to understand. If you don't need holistic processing of all items together (no optimized dispatch, no cross-item validation), a plain data structure is clearer. + --- ## 4. Channel Restart Strategy: `:temporary` @@ -96,6 +343,80 @@ 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. +### When to Use + +**Triggers:** +- The process's existence only makes sense while an external connection is active +- Restarting without the original context (socket, params, auth) would be meaningless +- The client has reconnection logic that will re-establish the process naturally + +**Example — before:** + +```elixir +# Bad: Default permanent restart for a session-bound process +defmodule MyApp.GameSession do + use GenServer + # Default child_spec → restart: :permanent + # If this crashes, it restarts without the player's connection — broken state + + def start_link({player_id, game_id}) do + GenServer.start_link(__MODULE__, {player_id, game_id}) + end +end +``` + +**Example — after:** + +```elixir +# Correct: Temporary restart — client reconnects and re-creates +defmodule MyApp.GameSession do + use GenServer, restart: :temporary + + def start_link({player_id, game_id}) do + GenServer.start_link(__MODULE__, {player_id, game_id}) + end +end +``` + +### When NOT to Use + +**Don't use this when:** The process owns durable state that must survive crashes (e.g., a stateful worker processing a queue, an aggregator collecting metrics). + +**Over-application example:** + +```elixir +# Bad: Temporary restart on a process that owns critical state +defmodule MyApp.InvoiceGenerator do + use GenServer, restart: :temporary + # If this crashes mid-generation, the work is lost forever + # No client will "reconnect" to restart it + + def handle_cast({:generate, invoice_id}, state) do + # Long-running PDF generation... + result = generate_pdf(invoice_id) + store_result(result) + {:noreply, state} + end +end +``` + +**Better alternative:** + +```elixir +# Permanent restart — crash recovery is handled by OTP +defmodule MyApp.InvoiceGenerator do + use GenServer # restart: :permanent (default) + + def init(state) do + # On restart, check for incomplete work and resume + incomplete = Jobs.find_incomplete(:invoice_generation) + {:ok, %{state | pending: incomplete}} + end +end +``` + +**Why:** `:temporary` means "if it dies, it's gone." That's correct for client-bound processes (channels, sessions) but dangerous for processes that own work. If no external actor will re-trigger the process, use `:permanent` or `:transient`. + --- ## 5. Auto-Hibernation @@ -120,6 +441,78 @@ 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. +### When to Use + +**Triggers:** +- Many processes spend most of their time idle (connected but waiting) +- Process count is high (thousands+) and memory pressure matters +- Latency on the first message after idle is acceptable (GC overhead on wake) + +**Example — before:** + +```elixir +# Default GenServer — 10,000 idle processes each holding heap memory +defmodule MyApp.UserPresence do + use GenServer + + def start_link(user_id) do + GenServer.start_link(__MODULE__, %{user_id: user_id, last_seen: DateTime.utc_now()}) + end + # Each idle process keeps its heap allocated even if doing nothing +end +``` + +**Example — after:** + +```elixir +# With hibernation — idle processes release heap memory +defmodule MyApp.UserPresence do + use GenServer, hibernate_after: 15_000 + + def start_link(user_id) do + GenServer.start_link(__MODULE__, %{user_id: user_id, last_seen: DateTime.utc_now()}, + hibernate_after: 15_000 + ) + end + # After 15s idle, heap is compacted — memory reclaimed +end +``` + +### When NOT to Use + +**Don't use this when:** The process is frequently active (messages every few seconds), or when wake-up latency is unacceptable (real-time processing pipelines). + +**Over-application example:** + +```elixir +# Bad: Hibernation on a process that receives messages every 100ms +defmodule MyApp.MetricsCollector do + use GenServer, hibernate_after: 5_000 + # Receives telemetry events every 100ms — will never actually hibernate + # The hibernate_after timer just adds overhead resetting on every message + + def handle_info({:telemetry, _event}, state) do + {:noreply, update_metrics(state)} + end +end +``` + +**Better alternative:** + +```elixir +# Don't set hibernate_after on high-throughput processes +defmodule MyApp.MetricsCollector do + use GenServer + # No hibernation — this process is always active, never idle + + def handle_info({:telemetry, _event}, state) do + {:noreply, update_metrics(state)} + end +end +``` + +**Why:** Hibernation has a cost: waking from hibernate requires a full GC pass to reconstruct the heap. For processes that are always active, the timer reset overhead and impossible hibernation provide no benefit. + --- ## 6. `Plug.Builder` vs Raw Behaviour @@ -141,6 +534,69 @@ 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. +### When to Use + +**Triggers:** +- You are composing multiple plugs into a pipeline +- You want the `plug` macro for declarative middleware ordering +- You need automatic `init/1` caching and pipeline compilation + +**Example — before:** + +```elixir +# Manual plug chaining — error-prone, verbose +defmodule MyApp.Pipeline do + @behaviour Plug + + def init(opts), do: opts + + def call(conn, _opts) do + conn + |> Plug.RequestId.call(Plug.RequestId.init([])) + |> Plug.Logger.call(Plug.Logger.init([])) + |> MyApp.Auth.call(MyApp.Auth.init([])) + |> MyApp.Router.call(MyApp.Router.init([])) + end +end +``` + +**Example — after:** + +```elixir +# Plug.Builder — declarative, compiled, correct +defmodule MyApp.Pipeline do + use Plug.Builder + + plug Plug.RequestId + plug Plug.Logger + plug MyApp.Auth + plug MyApp.Router +end +``` + +### When NOT to Use + +**Don't use this when:** You have a single plug or need dynamic/conditional middleware selection at runtime. + +**Over-application example:** + +```elixir +# Bad: Using Plug.Builder for a single plug +defmodule MyApp.JustAuth do + use Plug.Builder + plug MyApp.Auth # Only one plug — Builder adds no value +end +``` + +**Better alternative:** + +```elixir +# Just use the plug directly +plug MyApp.Auth # In your router or endpoint +``` + +**Why:** `Plug.Builder` generates pipeline compilation machinery. For a single plug, it's overhead with no benefit — just reference the plug directly where it's needed. + --- ## 7. Exception Structs with HTTP Status Codes @@ -168,3 +624,66 @@ 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. + +### When to Use + +**Triggers:** +- You are raising exceptions that should map to specific HTTP responses +- You want Plug's error handling to automatically return the correct status code +- Your error is domain-specific but has a clear HTTP semantic (404, 403, 422) + +**Example — before:** + +```elixir +# Generic exception — Plug returns 500 for everything +defmodule MyApp.NotFoundError do + defexception message: "Resource not found" +end + +# In controller: +raise MyApp.NotFoundError # → 500 Internal Server Error (wrong!) +``` + +**Example — after:** + +```elixir +# Exception with plug_status — correct HTTP mapping +defmodule MyApp.NotFoundError do + defexception plug_status: 404, message: "Resource not found" +end + +# In controller: +raise MyApp.NotFoundError # → 404 Not Found (correct!) +``` + +### When NOT to Use + +**Don't use this when:** The exception is used in non-HTTP contexts (CLI tools, background workers, library code) where HTTP status codes are meaningless. + +**Over-application example:** + +```elixir +# Bad: HTTP status on a domain exception used in background jobs +defmodule MyApp.PaymentDeclinedError do + defexception plug_status: 402, message: "Payment declined" + # This exception is also raised in async payment retry workers + # where plug_status is meaningless and confusing +end +``` + +**Better alternative:** + +```elixir +# Domain exception — pure, no HTTP coupling +defmodule MyApp.PaymentDeclinedError do + defexception [:message, :reason, :transaction_id] +end + +# Map to HTTP at the boundary (ErrorView or fallback controller) +defimpl Plug.Exception, for: MyApp.PaymentDeclinedError do + def status(_exception), do: 402 + def actions(_exception), do: [] +end +``` + +**Why:** Embedding `plug_status` couples the exception to HTTP. If the exception is used in multiple contexts (web, workers, CLI), implement the `Plug.Exception` protocol separately to keep the domain exception pure and the HTTP mapping at the boundary. diff --git a/patterns/patterns.md b/patterns/patterns.md index 3a5c9e0..f492f72 100644 --- a/patterns/patterns.md +++ b/patterns/patterns.md @@ -38,6 +38,76 @@ The endpoint is four things composed together: **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:** + +```elixir +# 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:** + +```elixir +# 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:** + +```elixir +# 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:** + +```elixir +# 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 @@ -63,6 +133,91 @@ Module.register_attribute(__MODULE__, :phoenix_routes, accumulate: true) **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:** + +```elixir +# 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:** + +```elixir +# 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:** + +```elixir +# 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:** + +```elixir +# 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 @@ -87,6 +242,99 @@ end **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:** + +```elixir +# 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:** + +```elixir +# 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:** + +```elixir +# 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:** + +```elixir +# 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 @@ -122,6 +370,91 @@ defmodule Phoenix.Controller do **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:** + +```elixir +# 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:** + +```elixir +# 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:** + +```elixir +# 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:** + +```elixir +# 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 @@ -157,6 +490,109 @@ end **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:** + +```elixir +# 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:** + +```elixir +# 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:** + +```elixir +# 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:** + +```elixir +# 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 @@ -182,6 +618,79 @@ end **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:** + +```elixir +# 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:** + +```elixir +# 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:** + +```elixir +# 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:** + +```elixir +# 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 @@ -206,6 +715,120 @@ end **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:** + +```elixir +# 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:** + +```elixir +# 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:** + +```elixir +# 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:** + +```elixir +# 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` @@ -234,6 +857,90 @@ 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:** + +```elixir +# 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:** + +```elixir +# 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:** + +```elixir +# 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:** + +```elixir +# 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 @@ -289,6 +996,79 @@ Phoenix emits these telemetry events: **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:** + +```elixir +# 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:** + +```elixir +# 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:** + +```elixir +# 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:** + +```elixir +# 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 @@ -313,6 +1093,79 @@ end **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:** + +```elixir +# 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:** + +```elixir +# 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:** + +```elixir +# 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:** + +```elixir +# 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 @@ -332,3 +1185,91 @@ end **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:** + +```elixir +# 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:** + +```elixir +# 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:** + +```elixir +# 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:** + +```elixir +# 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.