Files
2026-04-30 06:47:10 -07:00

28 KiB

Elixir Core vs Phoenix: Side-by-Side Comparison

How the same concepts are approached differently (or similarly) between Elixir core and Phoenix.

Process Lifecycle

Aspect Elixir Core Phoenix
Default restart :permanent (GenServer, Supervisor) :temporary (Channel)
Hibernation Not set by default 15s idle → hibernate (Channel)
Process identity Registry :via tuples Topic-based (channels identified by topic)
Supervision Direct supervisor reference Endpoint supervisor manages all

Source (Elixir): lib/elixir/lib/gen_server.ex:911-919 (child_spec defaults to :permanent via Supervisor.child_spec) Source (Phoenix): lib/phoenix/channel.ex:464-472 (explicit restart: :temporary)

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:

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

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

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

# 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

Aspect Elixir Core Phoenix
Exception design Minimal struct fields HTTP-aware (plug_status)
Bang functions File.read! raises broadcast! raises
Failure response {:error, reason} tuple {:error, reason} + HTTP status
Recovery Supervisor restart Client reconnection

Source (Elixir): lib/elixir/lib/agent.ex:187 (standard on_start type: {:ok, pid} | {:error, ...}) Source (Phoenix): lib/phoenix/router.ex:2-6 (NoRouteError with plug_status: 404)

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:

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

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

# 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

Aspect Elixir Core Phoenix
Required callbacks Most are optional Only join/3 required (Channel)
__using__ generates child_spec/1 + @behaviour child_spec + behaviour + config + imports
Configuration Via use Module, opts Via use Module, opts + module attributes
Before-compile Rarely used Heavily used (routes, intercepts)

Source (Elixir): lib/elixir/lib/gen_server.ex:899-919 (using generates child_spec + @behaviour) Source (Phoenix): lib/phoenix/channel.ex:450-485 (using generates child_spec + behaviour + DSL setup)

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:

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

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

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

# 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

Aspect Elixir Core Phoenix
Philosophy Minimal, prefer functions Justified by performance
__using__ Generates 1-2 functions Generates functions + sets up DSL
DSL creation Avoided (except Kernel/SpecialForms) Embraced (Router DSL)
Attribute accumulation Rare Central pattern (routes, sockets)

Source (Elixir): lib/elixir/lib/gen_server.ex:899 — simple __using__ (behaviour + child_spec + defaults) Source (Phoenix): lib/phoenix/router.ex:288-312 — complex DSL setup with attribute accumulation, imports, and @before_compile

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:

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

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

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

# 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

Aspect Elixir Core Phoenix
File naming gen_server.ex (snake_case) controller.ex (snake_case)
Nesting 2 levels max (Task.Supervised) 2-3 levels (Phoenix.Channel.Server)
Internal modules @moduledoc false @moduledoc false
Public API Functions on the main module Functions + macros on the main module

Both follow the same convention: public API on the parent module, implementation details in nested submodules with @moduledoc false.

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:

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

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

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

# 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

Aspect Elixir Core Phoenix
Agent Simple state, function-based access Socket assigns (assign/2)
GenServer Full control, handle_call/cast/info Channel handles (same callbacks)
State shape Any term (developer's choice) %Socket{} struct (framework-defined)
State access Direct in callbacks Via socket.assigns

Source (Elixir): lib/elixir/lib/agent.ex:62-82 (compute in server vs client pattern) Source (Phoenix): lib/phoenix/channel.ex:463 (import Phoenix.Socket, only: [assign: 3, assign: 2])

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:

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

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

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

# 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

Aspect Elixir Core Phoenix
Moduledoc size Very large (GenServer: 530 lines) Large (Router: ~260 lines)
Examples Doctests (verified by tests) Examples in docs (not always doctests)
Admonitions Info blocks for use Info blocks for use
Guides Linked from moduledoc Linked from moduledoc
Deprecation @doc deprecated: "Use X instead" Inline comments (TODO markers)

Both use the same documentation infrastructure (ExDoc), but Elixir core tends toward more exhaustive docs (GenServer's moduledoc is essentially a tutorial).

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:

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

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

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

# 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

Aspect Elixir Core Phoenix
Compile-time Module attributes Application.compile_env
Runtime Application env / init args config/2 callback + Application env
Per-instance Options to start_link Endpoint config per environment

Source (Phoenix): lib/phoenix/endpoint.ex:422-430 (compile-time config checking)

var!(code_reloading?) =
  Application.compile_env(@otp_app, [__MODULE__, :code_reloader], false)

This pattern — reading config at compile time and validating it against runtime — is Phoenix-specific. Elixir core reads config only at runtime.

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:

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

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

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

# 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

Aspect Elixir Core Phoenix
Built-in events None (telemetry is a separate library) Extensive event catalog
Instrumentation Manual by library authors Baked into router, endpoint, socket
Event naming Varies by library [:phoenix, :component, :phase] convention
Logging Logger calls Telemetry → Logger adapter (Phoenix.Logger)

Source (Phoenix): lib/phoenix/logger.ex:7-50 (telemetry event catalog) Source (Phoenix): lib/phoenix/router.ex:400-438 (telemetry in router dispatch)

Phoenix wraps every request dispatch in telemetry start/stop/exception events. This provides distributed tracing, monitoring, and logging without any application code changes.

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:

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

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

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

# 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

Aspect Elixir Core Phoenix
Test helper ExUnit.Case Phoenix.ConnTest, Phoenix.ChannelTest
Test subject Module functions Endpoint (full plug pipeline)
Communication Direct function calls HTTP verbs (ConnTest), messages (ChannelTest)
Isolation Process per test Process per test + sandbox (Ecto)

Source (Phoenix): lib/phoenix/test/conn_test.ex:1-30 (endpoint-based integration testing) Source (Phoenix): lib/phoenix/test/channel_test.ex:1-30 (process-based channel testing)

Phoenix test helpers test at the integration level by default — ConnTest dispatches through the full plug pipeline, ChannelTest exercises the full channel lifecycle via message passing. This catches middleware bugs that unit tests miss.

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:

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

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

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

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