jonnifyelixir
F5 / F5.08 / errors

F5.08 · part 3 of 3

Error contracts for the UI

A boundary that hands the UI a raw exception, an Ecto changeset, or the engine's internal reason has not finished the job. The UI then has to know the internals to render a message, and the coupling the facade removed comes back in through the error path. The fix is to make failure part of the contract: a closed set of typed errors — %Portal.Error{} with a code drawn from a fixed union — that the boundary produces and the UI renders. This is where the tagged tuples of F5.04 grow up: an expected failure becomes a named code with a human message; an impossible state stays a crash, caught by the supervisor from F5.06, because a bug is not an error contract. The UI is left with a finite list of outcomes it can render exhaustively.

Three failures, three rendered shapes. Select a case to see its internal cause and what the UI receives.

A closed set of outcomes

A closed set is the point. If the failures the UI can receive are a fixed union of codes, then the UI's rendering is total — every case is handled, and the compiler (or a test) can say so. Each code pairs an internal cause with a message a person can read. Select a case.

Error case · select one

:already_enrolled “you are already on this course” 409 conflict :course_not_found “we could not find that course” 404 not found :invalid_progress “progress must be between 0 and 100” 422 · field

code: already_enrolled

the UI shows: you are already on this course

When the set of failures is closed, the UI can render them exhaustively and a new failure cannot slip through as a blank screen. The contract is the list of things that can go wrong, named.

The Error struct

The vocabulary is a struct with a code typed as a union of atoms, a human message, and an optional field for form errors. The union is the closed set; adding a failure means adding a code here first, which is what keeps the UI and the engine in step.

defmodule Portal.Error do
  @moduledoc "The closed vocabulary of failures the UI may receive."

  @type code :: :already_enrolled | :course_not_found | :lesson_locked | :invalid_progress
  @type t :: %__MODULE__{code: code, message: String.t(), field: atom | nil}

  @enforce_keys [:code, :message]
  defstruct [:code, :message, field: nil]
end

Translating at the boundary

One function turns internal reasons into the contract. Each known domain failure has a clause; there is deliberately no catch-all. An unmodelled reason raises a FunctionClauseError rather than being mapped to a vague “something went wrong,” so a missing case fails loudly in development instead of leaking to a user. Expected failures become contracts; genuine bugs stay crashes for the supervisor in F5.06 to handle.

# only known domain failures become a contract — there is no catch-all on purpose
@spec from(atom | {atom, term}) :: t()
def from(:already_enrolled),
  do: %Error{code: :already_enrolled, message: "you are already on this course"}

def from(:course_not_found),
  do: %Error{code: :course_not_found, message: "we could not find that course"}

def from({:invalid_progress, _value}),
  do: %Error{code: :invalid_progress, field: :progress, message: "progress must be between 0 and 100"}

# an unmodelled reason has no clause: it raises, so the gap shows up in a test, not in production

The path from a failure to a screen is three steps, and it is closed at every one: an internal reason, the translation, and a render the UI can branch on exhaustively.

From reason to render

INTERNAL {:error, reason} Error.from/1 CONTRACT %Portal.Error{} render UI flash / field error every reason maps to a code, or it raises — nothing reaches the UI unnamed

Translate at the edge, once. Inside, code speaks in domain reasons; outside, the UI speaks in a closed set of errors; the boundary is the only place the two meet.

Errors as part of the contract

Because the codes are a closed union, the UI can branch on them exhaustively, and the contract tests from F5.07 cover each one: feed the precondition a bad value, assert the %Error{} code that comes back. Adding a failure is a three-part move kept honest by the type — a new code in the union, a new clause in from/1, and a new branch in the UI — and the message can be localised without touching any of the logic. On the screen it reduces to one total case:

# in the LiveView (F6): a finite set of outcomes, rendered exhaustively
case Portal.enroll(user_id, course_id) do
  :ok ->
    {:noreply, redirect(socket, to: ~p"/courses/#{course_id}")}

  {:error, %Portal.Error{field: nil, message: msg}} ->
    {:noreply, put_flash(socket, :error, msg)}

  {:error, %Portal.Error{field: field, message: msg}} ->
    {:noreply, assign(socket, :errors, %{field => msg})}
end

fail in a known shape

Expected failures are a closed set of typed errors; impossible ones stay crashes for the supervisor.

the UI stays total

A finite list of codes means one exhaustive case and no unhandled outcome.

That closes F5.08: the core declares its needs as ports, the facade is the one door the UI calls, and failures cross the boundary as a closed set of typed errors. The next module, F5.09 — the engine lab, assembles the facade end to end and mounts it behind a LiveView sketch, ready for F6. Back to the module overview or the chapter.

References

Primary sources for this lesson, and where it connects in the course.