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
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
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.
Sources
- Alistair Cockburn — Hexagonal architecture — ports and adapters around a core.
- Elixir — Typespecs and behaviours — @callback defines the port contract.
- José Valim — Mocks and explicit contracts — test seams without mutating globals.