Files
phoenix-conventions/patterns/deviations.md
T
2026-04-30 06:47:10 -07:00

20 KiB

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:

# 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:

# 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:

# 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:

# 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

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:

# 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:

# 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:

# 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:

# 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

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:

# 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:

# 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:

# 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:

# 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

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:

# 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:

# 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:

# 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:

# 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

@phoenix_hibernate_after Keyword.get(opts, :hibernate_after, 15_000)
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:

# 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:

# 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:

# 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:

# 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

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:

# 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:

# 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:

# 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:

# 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

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:

# 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:

# 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:

# 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:

# 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.