docs: add when/when-not to all Phoenix patterns
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user