Phoenix contexts should return tuples, not raise

almirsarajcic

almirsarajcic

2 hours ago

Phoenix context functions that raise exceptions break LiveView error handling and force try/catch everywhere. Tagged tuples enable composable error handling with with statements and seamless form validation.

# ❌ EXCEPTION-THROWING - Crashes can't be composed
defmodule Accounts do
  def get_user!(id) do
    # Crashes on missing user
    Repo.get!(User, id)
  end

  def create_user!(attrs) do
    %User{}
    |> User.changeset(attrs)
    # Crashes on validation errors
    |> Repo.insert!()
  end
end

# Caller can't handle errors gracefully
def handle_event("save", params, socket) do
  # Crashes entire LiveView!
  user = Accounts.create_user!(params)
  {:noreply, assign(socket, :user, user)}
end

# ✅ TAGGED TUPLES - Composable error handling
defmodule Accounts do
  def get_user(id) do
    case Repo.get(User, id) do
      nil -> {:error, :not_found}
      user -> {:ok, user}
    end
  end

  def create_user(attrs) do
    %User{}
    |> User.changeset(attrs)
    |> Repo.insert()
  end
end

# Caller handles all outcomes naturally
def handle_event("save", params, socket) do
  case Accounts.create_user(params) do
    {:ok, user} ->
      {:noreply, socket |> put_flash(:info, "Success!") |> assign(:user, user)}

    {:error, changeset} ->
      {:noreply, assign(socket, :changeset, changeset)}
  end
end

Why tagged tuples win

LiveView authorization - Handle nil results for not-found/unauthorized:

def handle_params(%{"id" => id}, _uri, socket) do
  case Drops.get_drop(socket.assigns.current_user, id) do
    nil ->
      {:noreply,
       socket
       |> put_flash(:error, "Drop not found")
       |> push_navigate(to: ~p"/drops")}

    drop ->
      {:noreply, assign(socket, :drop, drop)}
  end
end

For scoped queries, context functions can return nil directly - LiveView handles it gracefully without crashes.

Composable workflows - Chain multiple operations with with:

def transfer_money(from_id, to_id, amount) do
  with {:ok, from_account} <- get_account(from_id),
       {:ok, to_account} <- get_account(to_id),
       {:ok, _} <- validate_balance(from_account, amount),
       {:ok, from_account} <- withdraw(from_account, amount),
       {:ok, to_account} <- deposit(to_account, amount) do
    {:ok, {from_account, to_account}}
  end
end

If any step fails, the entire chain short-circuits and returns the error tuple. No nested try/catch blocks needed.

Phoenix LiveView integration - Forms show validation errors automatically:

def handle_event("save_user", %{"user" => params}, socket) do
  case Accounts.create_user(params) do
    {:ok, user} ->
      {:noreply,
       socket
       |> put_flash(:info, "User created!")
       |> push_navigate(to: ~p"/users/#{user.id}")}

    {:error, changeset} ->
      {:noreply, assign(socket, :changeset, changeset)}
  end
end

Phoenix renders changeset errors in your form without crashes or special handling.

When to keep the bang versions - Use ! functions only when you want crashes:

# Seeds that must succeed or app is broken
def seed_required_data do
  admin = Accounts.create_user!(admin_attrs)
  Accounts.create_role!(%{name: "admin", user_id: admin.id})
end

Pro tip: For scoped authorization queries (filtered by user/company), return nil on not-found - LiveView handles it naturally. For operations that need detailed errors, use {:error, :reason} tuples with with statements.