690 lines
20 KiB
Markdown
690 lines
20 KiB
Markdown
# Phoenix Deviations from Elixir Core
|
|
|
|
Where Phoenix deliberately differs from Elixir core patterns and why.
|
|
|
|
## 1. Heavy Macro Usage for Performance
|
|
|
|
**Elixir core philosophy:** Keep macro usage minimal. From the Router source:
|
|
|
|
> Phoenix does its best to keep the usage of macros low.
|
|
|
|
**Phoenix deviation:** The Router uses macros extensively.
|
|
|
|
**Source:** `lib/phoenix/router.ex:106-128`
|
|
|
|
> We use `get`, `post`, `put`, and `delete` to define your routes. We use macros
|
|
> for two purposes:
|
|
>
|
|
> * They define the routing engine... Phoenix compiles all of your routes to a
|
|
> single case-statement with pattern matching rules
|
|
>
|
|
> * For each route you define, we also define metadata to implement
|
|
> `Phoenix.VerifiedRoutes`
|
|
|
|
**Why the deviation:** Performance. Elixir core uses macros sparingly because they add cognitive complexity. Phoenix justifies them because routing is the hottest path in a web app — compile-time optimization yields measurable request/second gains.
|
|
|
|
### 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
|
|
|
|
**Elixir core pattern:** Always use `import Module, only: [...]` to be explicit.
|
|
|
|
**Phoenix deviation:** The Router imports entire modules:
|
|
|
|
**Source:** `lib/phoenix/router.ex:303-306`
|
|
|
|
```elixir
|
|
import Phoenix.Router
|
|
|
|
# TODO v2: No longer automatically import dependencies
|
|
import Plug.Conn
|
|
import Phoenix.Controller
|
|
```
|
|
|
|
**Why the deviation:** The Router is a DSL. Users need `get`, `post`, `pipe_through`, `scope`, `resources`, `plug`, `fetch_session`, etc. — all available without qualification. Restricting imports would make the DSL unusable.
|
|
|
|
### 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
|
|
|
|
**Elixir core pattern:** Modules are generally stateless during compilation. Functions are defined and that's it.
|
|
|
|
**Phoenix deviation:** Aggressive use of module attribute accumulation.
|
|
|
|
**Source:** `lib/phoenix/router.ex:297-312`
|
|
|
|
```elixir
|
|
defp prelude(opts) do
|
|
quote do
|
|
Module.register_attribute(__MODULE__, :phoenix_routes, accumulate: true)
|
|
@phoenix_helpers Keyword.get(unquote(opts), :helpers, true)
|
|
|
|
import Phoenix.Router
|
|
import Plug.Conn
|
|
import Phoenix.Controller
|
|
|
|
# Set up initial scope
|
|
@phoenix_pipeline nil
|
|
Phoenix.Router.Scope.init(__MODULE__)
|
|
@before_compile unquote(__MODULE__)
|
|
end
|
|
end
|
|
```
|
|
|
|
**Why the deviation:** The Router needs to collect ALL routes, then compile them into a single dispatch function. This requires building up state during module compilation, then consuming it all at `@before_compile`.
|
|
|
|
### 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`
|
|
|
|
**Elixir core GenServer default:** `:permanent` (always restart).
|
|
|
|
**Phoenix Channel default:** `:temporary` (never restart).
|
|
|
|
**Source:** `lib/phoenix/channel.ex:464-472`
|
|
|
|
```elixir
|
|
def child_spec(init_arg) do
|
|
%{
|
|
id: __MODULE__,
|
|
start: {__MODULE__, :start_link, [init_arg]},
|
|
shutdown: @phoenix_shutdown,
|
|
restart: :temporary
|
|
}
|
|
end
|
|
```
|
|
|
|
**Why the deviation:** A crashed channel should NOT auto-restart — the client needs to explicitly reconnect and rejoin. Auto-restarting would create a channel without a connected client, which is meaningless.
|
|
|
|
### 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
|
|
|
|
**Elixir core GenServer:** No default hibernation — processes stay in memory.
|
|
|
|
**Phoenix Channel:** Defaults to hibernate after 15 seconds of inactivity.
|
|
|
|
**Source:** `lib/phoenix/channel.ex:459`
|
|
|
|
```elixir
|
|
@phoenix_hibernate_after Keyword.get(opts, :hibernate_after, 15_000)
|
|
```
|
|
|
|
```elixir
|
|
def start_link(triplet) do
|
|
GenServer.start_link(Phoenix.Channel.Server, triplet,
|
|
hibernate_after: @phoenix_hibernate_after
|
|
)
|
|
end
|
|
```
|
|
|
|
**Why the deviation:** Web apps have many idle connections. Channels for users who are "connected but not active" are common. Hibernation reclaims memory for the heap without killing the process. A chat app with 10,000 connected users benefits enormously.
|
|
|
|
### 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
|
|
|
|
**Elixir core:** Behaviours define contracts. Implementations are manual.
|
|
|
|
**Phoenix Endpoint:** Uses `Plug.Builder` — a macro that generates the `call/2` pipeline by chaining plugs at compile time.
|
|
|
|
**Source:** `lib/phoenix/endpoint.ex:478-480`
|
|
|
|
```elixir
|
|
defp plug() do
|
|
quote location: :keep do
|
|
use Plug.Builder, init_mode: Phoenix.plug_init_mode()
|
|
...
|
|
end
|
|
end
|
|
```
|
|
|
|
**Why the deviation:** The Plug specification (`init/1` + `call/2`) is too low-level for composing dozens of middleware. `Plug.Builder` provides the `plug` macro that chains them automatically. It's a higher-level abstraction over the raw behaviour pattern.
|
|
|
|
### 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
|
|
|
|
**Elixir core exceptions:** Pure data — message, maybe some context fields.
|
|
|
|
**Phoenix exceptions:** Include `plug_status` for HTTP response mapping.
|
|
|
|
**Source:** `lib/phoenix/router.ex:2-26`
|
|
|
|
```elixir
|
|
defmodule NoRouteError do
|
|
@moduledoc """
|
|
Exception raised when no route is found.
|
|
"""
|
|
defexception plug_status: 404, message: "no route found", conn: nil, router: nil
|
|
end
|
|
|
|
defmodule MalformedURIError do
|
|
@moduledoc """
|
|
Exception raised when the URI is malformed on matching.
|
|
"""
|
|
defexception [:message, plug_status: 400]
|
|
end
|
|
```
|
|
|
|
**Why the deviation:** In a web context, exceptions need to map to HTTP status codes. Plug's error handling middleware reads `plug_status` to determine the response code. This bridges the gap between Elixir's exception system and HTTP semantics.
|
|
|
|
### 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.
|