tutorial 11 min read

Structured Error Handling in Elixir: The ZERO Doctrine

A comprehensive guide to error handling strategies in Elixir covering ok/error tuples, with chains, specific rescue clauses, and why bare rescue is banned.

Tomas Korcak (korczis) 488 words

Error handling in Elixir follows a philosophy fundamentally different from exception-heavy languages. The primary mechanism is return values: {:ok, value} and {:error, reason}. Exceptions exist but are reserved for truly exceptional situations. The Prismatic Platform's ZERO doctrine (Zero Error Runtime Operations) codifies this philosophy into enforceable rules.

The {:ok, value} / {:error, reason} Convention

Every function that can fail should return a tagged tuple:

@spec fetch_entity(String.t()) :: {:ok, Entity.t()} | {:error, fetch_error()}
@type fetch_error :: :not_found | :unauthorized | {:database_error, term()}

def fetch_entity(id) when is_binary(id) do
  case Repo.get(Entity, id) do
    nil -> {:error, :not_found}
    entity -> {:ok, entity}
  end
end

This pattern makes errors explicit in the type signature and forces callers to handle both cases:

case fetch_entity(id) do
  {:ok, entity} -> process(entity)
  {:error, :not_found} -> {:error, :entity_missing}
  {:error, :unauthorized} -> {:error, :access_denied}
  {:error, {:database_error, reason}} -> {:error, {:internal, reason}}
end

The with Chain

For operations that depend on multiple steps, each of which can fail, the with construct avoids deeply nested case statements:

@spec create_dd_case(map()) :: {:ok, Case.t()} | {:error, term()}
def create_dd_case(params) do
  with {:ok, validated} <- validate_params(params),
       {:ok, user} <- authorize_user(validated.user_id),
       {:ok, case_struct} <- build_case(validated, user),
       {:ok, saved} <- Repo.insert(case_struct),
       :ok <- notify_subscribers(saved) do
    {:ok, saved}
  else
    {:error, %Ecto.Changeset{} = changeset} ->
      {:error, {:validation, changeset}}

    {:error, :unauthorized} ->
      {:error, :access_denied}

    {:error, reason} ->
      Logger.error("Failed to create DD case: #{inspect(reason)}")
      {:error, {:internal, reason}}
  end
end

Each step in the with chain must return {:ok, value} for execution to continue. Any {:error, reason} short-circuits to the else block.

Tagged with Steps

When multiple steps can return the same error shape, tag them:

with {:validate, {:ok, params}} <- {:validate, validate(input)},
     {:fetch, {:ok, entity}} <- {:fetch, fetch_entity(params.id)},
     {:score, {:ok, score}} <- {:score, compute_score(entity)} do
  {:ok, %{entity: entity, score: score}}
else
  {:validate, {:error, reason}} -> {:error, {:validation_failed, reason}}
  {:fetch, {:error, :not_found}} -> {:error, :entity_not_found}
  {:score, {:error, reason}} -> {:error, {:scoring_failed, reason}}
end

Why Bare Rescue Is Banned

The ZERO doctrine explicitly bans bare rescue:

# BANNED - bare rescue swallows ALL errors
try do
  dangerous_operation()
rescue
  _ -> :error
end

# BANNED - catches everything including system errors
try do
  dangerous_operation()
rescue
  _e -> {:error, :unknown}
end

Why this matters:

  1. Lost information: The actual error type and message are discarded
  2. Hidden bugs: Programming errors (typos, bad arguments) are silently swallowed
  3. Debugging nightmare: Production issues become impossible to diagnose
  4. System errors masked: SystemLimitError, TokenMissingError get caught and ignored

The correct approach catches specific exceptions:

# CORRECT - catches only expected exceptions
try do
  Jason.decode!(payload)
rescue
  e in [Jason.DecodeError] ->
    Logger.warning("JSON decode failed", error: Exception.message(e), payload_size: byte_size(payload))
    {:error, {:invalid_json, Exception.message(e)}}
end

# CORRECT - multiple specific exceptions
try do
  HTTPoison.get!(url)
rescue
  e in [HTTPoison.Error] ->
    {:error, {:http_error, e.reason}}

  e in [URI.Error] ->
    {:error, {:invalid_url, Exception.message(e)}}
end

Error Types Hierarchy

Define explicit error types for your domain:

defmodule Prismatic.DD.Error do
  @moduledoc """
  Structured error types for the DD domain.
  """

  @type t ::
    validation_error()
    | authorization_error()
    | not_found_error()
    | scoring_error()
    | storage_error()

  @type validation_error :: {:validation, field :: atom(), message :: String.t()}
  @type authorization_error :: {:unauthorized, user_id :: String.t(), action :: atom()}
  @type not_found_error :: {:not_found, entity_type :: atom(), id :: String.t()}
  @type scoring_error :: {:scoring_failed, reason :: term()}
  @type storage_error :: {:storage_error, operation :: atom(), reason :: term()}
end

Functions throughout the DD domain return {:error, Prismatic.DD.Error.t()}, making error handling consistent and typed.

Error Context Enrichment

When propagating errors up the call stack, add context at each level:

defmodule Prismatic.DD.CaseService do
  @spec analyze_case(String.t()) :: {:ok, Analysis.t()} | {:error, term()}
  def analyze_case(case_id) do
    with {:ok, dd_case} <- fetch_case(case_id),
         {:ok, entities} <- fetch_entities(dd_case),
         {:ok, analysis} <- run_analysis(entities) do
      {:ok, analysis}
    else
      {:error, reason} ->
        enriched = %{
          original_error: reason,
          case_id: case_id,
          step: determine_failed_step(reason),
          timestamp: DateTime.utc_now()
        }

        Logger.error("Case analysis failed", enriched)
        {:error, {:analysis_failed, enriched}}
    end
  end
end

The ! Convention

Elixir's convention: functions ending in ! raise on error, functions without ! return tuples:

# Returns {:ok, value} or {:error, reason}
Repo.insert(changeset)

# Raises Ecto.InvalidChangesetError on failure
Repo.insert!(changeset)

Use ! variants only when:

  • You are in a context where failure should crash the process (let it crash philosophy)
  • You have already validated inputs and failure is truly unexpected
  • You are in a script or migration, not production request handling

Pattern: Error Middleware

For APIs, centralize error formatting:

defmodule PrismaticWeb.ErrorHandler do
  @moduledoc """
  Translates domain errors to HTTP responses.
  """

  @spec handle_error(Plug.Conn.t(), term()) :: Plug.Conn.t()
  def handle_error(conn, {:error, :not_found}) do
    conn |> put_status(404) |> json(%{error: %{code: "not_found", message: "Resource not found"}})
  end

  def handle_error(conn, {:error, :unauthorized}) do
    conn |> put_status(401) |> json(%{error: %{code: "unauthorized", message: "Authentication required"}})
  end

  def handle_error(conn, {:error, {:validation, changeset}}) do
    errors = format_changeset_errors(changeset)
    conn |> put_status(422) |> json(%{error: %{code: "validation_error", details: errors}})
  end

  def handle_error(conn, {:error, {:internal, _reason}}) do
    conn |> put_status(500) |> json(%{error: %{code: "internal_error", message: "An unexpected error occurred"}})
  end
end

ZERO Doctrine Enforcement

The pre-commit hook scans for banned patterns:

PatternDetectionSeverity
rescue _ or rescue _ ->Grep in staged filesBLOCKING
rescue _e -> (unused variable)Grep in staged filesBLOCKING
String.to_atom/1Grep in staged filesBLOCKING
:erlang.binary_to_term/1 without :safeGrep in staged filesBLOCKING
Process.sleep in GenServerAST analysisADVISORY

The CI pipeline runs a deeper check via mix validate-zero that uses AST analysis to find bare rescue blocks even when formatted unconventionally.

Testing Error Paths

Every error path should have test coverage:

describe "fetch_entity/1" do
  test "returns {:error, :not_found} for missing entities" do
    assert {:error, :not_found} = DDService.fetch_entity("nonexistent")
  end

  test "returns {:error, :unauthorized} for restricted entities" do
    entity = insert(:entity, restricted: true)
    assert {:error, :unauthorized} = DDService.fetch_entity(entity.id)
  end
end

The TACH doctrine requires test files for all modules, and the ZERO doctrine requires that error paths specifically are tested, not just happy paths.

Summary

ApproachWhen to UseExample
{:ok, val}/{:error, reason}Any fallible operationDatabase queries, validation
with chainMulti-step operationsService functions
Tagged withAmbiguous error sourcesComplex pipelines
Specific rescueExternal library callsJSON parsing, HTTP clients
! functionsCrash-on-failure contextsScripts, migrations
Error middlewareAPI boundariesController error handling

Treat errors as data, not exceptions. The ZERO doctrine ensures this discipline is maintained across the entire codebase through automated enforcement.