View Source Design notes
Conceptual material that is useful once you have defined a few error types, but that nobody needs before their first one.
Choosing a kind
Every Errata error has a :kind — :domain, :infrastructure, or :general —
fixed when you define the type. This section covers what the choice decides, how
to make it, and how to opt out of it entirely.
What the kind decides
The kind supplies the default for two of the boundary classifications:
| kind | http_status/1 | retryable?/1 |
|---|---|---|
:domain | 422 | false |
:infrastructure | 503 | true |
:general | 500 | false |
Both are defaults only, overridden per type with the :http_status and
:retryable options. Nothing else derives from the kind: severity/1 defaults
to :error for every kind, and code/1 has no default at all.
Treat the retryable?/1 default as usually right and the http_status/1 default
as a starting point. Domain errors in particular often want a more specific
status than 422:
defmodule MyApp.Orders.OrderNotFound do
use Errata.DomainError, http_status: 404
endThe kind also backs two guards, Errata.is_domain_error/1 and
Errata.is_infrastructure_error/1, which let a boundary branch on the kind in a
function head. This is the part that keeps paying off as an application grows:
a fallback controller written today handles domain error types added months
later, without being edited.
defmodule MyAppWeb.FallbackController do
use Phoenix.Controller
import Errata
# Safe to show the caller. `display_message/1` is `nil` for a type defined
# without `:default_message` and no `:default_display_message` configured, so
# give it a fallback.
def call(conn, {:error, error}) when is_domain_error(error) do
conn
|> put_status(Errata.http_status(error))
|> json(%{error: Errata.display_message(error) || "invalid request"})
end
# Ours to fix — log it and stay vague.
def call(conn, {:error, error}) when is_error(error) do
Errata.log(error)
conn
|> put_status(Errata.http_status(error))
|> json(%{error: "something went wrong"})
end
endThe bodies are inlined here to keep the branching in view; a real boundary renders through a view instead, and a worked boundary shows one in full.
Making the choice
The most reliable question is not whose fault the error is, but who acts on it:
- The caller can act on it by changing the request —
:domain. - An operator acts on it, or it resolves on its own if retried —
:infrastructure. - Neither, or it does not matter —
:general.
That question resolves most of the cases that feel ambiguous when phrased as "domain or infrastructure":
| situation | kind | because |
|---|---|---|
| input fails a validation rule | :domain | the caller fixes the input |
| an order is not found | :domain | the caller asks for a different order |
| the database connection drops | :infrastructure | an operator or a retry fixes it |
| a third-party API is unreachable | :infrastructure | same — see below |
| a malformed request body arrives | :domain | the caller sends valid JSON |
| a malformed response from an upstream service | :infrastructure | the caller can do nothing |
| an unexpected error from library code | :general | nobody planned for it |
Whatever convention you settle on, apply it consistently across an application.
The kind's defaults are silent — a misclassified infrastructure error returns
422 for a database outage, and nothing warns you.
External-service errors
A third-party API failing is :infrastructure. An application reaches an
external service through an adapter, and that adapter is infrastructure; from the
caller's point of view there is no useful difference between "our database is
down" and "their API is down." Both are already retryable by default, and a
specific status is one option away:
defmodule MyApp.Payments.GatewayUnavailable do
use Errata.InfrastructureError,
default_message: "the payment gateway is unavailable",
http_status: 502,
code: "GATEWAY_UNAVAILABLE"
endIf you need to tell your outages from someone else's — for circuit breakers, alerting, or deciding who gets paged — that distinction is about ownership rather than layer, and it does not have to live in the kind. Define a guard over your own types:
defmodule MyApp.Errors do
@external [MyApp.Payments.GatewayUnavailable, MyApp.Shipping.CarrierTimeout]
defguard is_external_error(term)
when is_struct(term) and :erlang.map_get(:__struct__, term) in @external
endIt is usable in function heads and case clauses exactly like
is_infrastructure_error/1, and it names the set explicitly rather than
inferring it:
defmodule MyApp.Boundary do
import MyApp.Errors
def handle(error) when is_external_error(error), do: :open_circuit
def handle(_error), do: :normal
endThe same technique works for any classification an application wants — by subsystem, by team, by alerting policy — without the taxonomy having to anticipate it.
Know its one weakness before relying on it: the set lives away from the error definitions, so nothing catches a type that should have been added to it. Define a new error for a third-party call, forget to list it, and there is no warning, no failing test, and no type error — just a boundary that quietly treats it as one of your own. The gap does not need a large catalogue to appear; it is easy to miss a type in a set of a dozen. If the classification drives anything you would not want silently wrong — a circuit breaker, a pager — it is worth a test that pins the set, or a habit of revisiting the list whenever an error type is added.
Ignoring the taxonomy
The taxonomy is a convenience, not a requirement. If the domain/infrastructure
split does not fit how you think about your errors, define every type with the
base Errata.Error and set the classifications you care about explicitly:
defmodule MyApp.OrderNotFound do
use Errata.Error,
default_message: "the requested order does not exist",
http_status: 404,
retryable: false
endEverything else in the library works unchanged: Errata.is_error/1,
http_status/1, code/1, severity/1, retryable?/1, log/2, report/2,
wrap/2, redaction, and aggregation are all defined over the classification
functions rather than over the kind. What you give up is the two kind guards, so
boundaries dispatch on something else instead — a status range, or a guard of
your own as shown above:
def call(conn, {:error, error}) when is_error(error) do
case Errata.http_status(error) do
status when status < 500 -> render_client_error(conn, status, error)
status -> render_server_error(conn, status, error)
end
endThis is a supported way to use the library, not a workaround. Errors defined this
way have kind :general, interoperate with errors that do use the taxonomy, and
lose nothing else.
Choosing between an error type and a reason
Errata errors carry both a type (the module) and an optional :reason atom,
and it is not always obvious which to reach for. As a rule of thumb:
- Use a distinct error type for each condition that callers may want to handle differently or that has its own meaning in the domain. The type is the primary identity of an error and the thing you pattern match on.
- Use the
:reasonfield to sub-classify within a single error type — to distinguish variations of the same error that share handling but differ in cause.
For example, a single PaymentDeclined domain error can use :reason to record
why the payment was declined, rather than defining a separate type for each
cause:
PaymentDeclined.create(reason: :insufficient_funds)
PaymentDeclined.create(reason: :fraud_suspected)Conversely, a :reason that merely restates the type name (such as
OrderNotFound.create(reason: :order_not_found)) adds no information and can be
omitted.
When a type's reasons form a known, closed set, you can declare them with the
:reasons option. Errata then rejects any reason outside the set (a nil,
unspecified reason is always allowed) and generates a reason/0 type enumerating
them, so the valid reasons are part of the type's documented contract:
defmodule MyApp.Orders.PaymentDeclined do
use Errata.DomainError,
reasons: [:insufficient_funds, :fraud_suspected, :card_expired]
end
PaymentDeclined.new(reason: :insufficient_funds) # ok
PaymentDeclined.new(reason: :mistyped) # ** (ArgumentError) invalid reason :mistyped ...Declaring :reasons buys three things. Errata raises on an undeclared reason when
the error is built, so a reason that drifts from the set cannot travel. It generates
a real reason/0 type enumerating the valid values —
@type reason() :: :insufficient_funds | :fraud_suspected | :card_expired— so the set is part of the type's documented contract and Dialyzer can check specs
written against it. And it makes the type safe to decode at a boundary: Errata.from_map/3
matches an incoming reason against the declared set, so nothing from the wire reaches
String.to_existing_atom/1 at all. That last one is the strongest argument for declaring
them; see Errors at a boundary.
The reason check itself is at runtime. An invalid reason written as a literal in source compiles without complaint and raises when that line runs:
defmodule Checked do
use Errata.DomainError, reasons: [:a]
def go, do: __MODULE__.new(reason: :nope) # compiles clean
end
Checked.go()
# ** (ArgumentError) invalid reason :nope for Checked. Declared reasons are [:a].If you also set :default_reason, it must be one of the declared :reasons — that one
is checked at compile time, since both are use options.
Why Errata?
It is common in Elixir and Erlang to signal failure with an error tuple of the
form {:error, reason}. All too often, though, the reason is a bare atom or
(worse) a string that carries no context: it may read clearly enough in the
surrounding code, but as a log message or error report — far from where the
error arose — it lacks the detail needed to interpret what actually happened.
Raising exceptions is a less common but still widespread alternative. Exceptions do carry some context, including a stacktrace, but they lack a common, uniform structure to build logging and error handling around.
Errata gives all errors a uniform structure and lets them be created with full contextual detail, including arbitrary metadata. That context is embedded in the error struct, so it propagates with the error whether the error is raised or returned as a value, and the error is JSON-encodable so it can be reported to an external service such as Sentry.
This pays off, in particular, in with expressions. When each step returns
{:ok, result} or {:error, reason} and the reason lacks context, the with
is forced to add an else clause to log or report every possible error
meaningfully. When each error is instead a structured type carrying its own
context, the with can omit the else clause entirely and let the error
propagate to a boundary — such as a Phoenix controller — where it is logged or
reported without any loss of the context needed to interpret it.
Chris Keathley discusses this point in depth in his blog post
Good and Bad Elixir, under
"Avoid else in with blocks".