Errata is an Elixir library that promotes a consistent and structured approach to error handling.
Errata provides support for defining custom structured error types, which can either be returned as error values or raised as exceptions.
Errata errors are named, structured types that represent error conditions in an application or library. Being named types means that errors have a unique and meaningful name within a particular context. Being structured types means that errors have a well-defined, consistent structure identifying the nature of the error, and can also have arbitrary contextual information attached to them for logging or debugging purposes.
Errata errors fall into one of three general classifications:
- Domain Errors represent error conditions within a problem domain or bounded context. These are business process violations or other errors in the problem domain, and therefore domain errors should be included as part of the Ubiquitous Language of the domain.
- Infrastructure Errors represent errors that can occur at an infrastructure level but which are not part of the problem domain. Infrastructure errors include such things as network timeouts, database connection failures, filesystem errors, etc.
- General Errors represent errors that do not fall into either of the above categories. General errors can include errors that emanate from library code (as opposed to application code) or any other sort of error condition for which there is no need to distinguish based on classification.
Errata makes it easy to define custom error types for applications or libraries. The following examples demonstrate how to define domain errors, infrastructure errors, and general errors.
defmodule MyApp.SomeContext.MyDomainError do
# Define a custom domain error in some context.
use Errata.DomainError
end
defmodule MyApp.SomeContext.MyInfrastructureError do
# Define a custom infrastructure error in some context.
use Errata.InfrastructureError
end
defmodule MyApp.SomeContext.MyError do
# Define a custom error in some context.
use Errata.Error
end
Each of these custom error types will define an exception struct that conforms
to the t:Errata.error/0
type and will implement the Errata.Error
behaviour.
Additionally, implementations for the String.Chars
protocol and the
Jason.Encoder
protocol are provided so that these errors can be converted to
string form or encoded as JSON automatically.
Since Errata errors are just regular Elixir exceptions with a well-defined structure, they can be raised just like any other type of exception.
raise MyApp.SomeContext.MyDomainError, reason: :invalid_data, context: %{foo: "bar"}
Errata errors can be created by using the new/1
or create/1
functions for
the error type. Once created, they can be used as return values from functions,
preferably wrapped in an error tuple. This approach allows for creating errors
with full contextual details at the site of the error, while separating the
handling of errors by letting them propagate up the call stack and logging
or reporting the errors at the boundaries of the system.
Using the new/1
function creates an error with the provided data, but does
not include any data in the :env
field of the error struct:
iex> alias MyApp.SomeContext.MyError
iex> MyError.new(reason: :invalid_data, context: %{foo: "bar"})
%MyError{reason: :invalid_data, context: %{foo: "bar"}, env: nil}
Using the create/1
macro, on the other hand, fills in the :env
field of
the error struct with information from the current __ENV__
struct and
the current stacktrace. To use the create/1
macro, you must first
require/2
the error module:
iex> require MyApp.SomeContext.MyError, as: MyError
iex> error = MyError.create(reason: :invalid_data, context: %{foo: "bar"})
iex> error.reason == :invalid_data
true
iex> error.context == %{foo: "bar"}
true
iex> match?(%Errata.Env{stacktrace: stacktrace} when is_list(stacktrace), error.env)
true
Whether new/1
or create/1
is used to create the error, it is preferable to
wrap the error in a tuple when returning it as a value from functions:
{:error, MyError.new(reason: :invalid_data)}
{:error, MyError.create(reason: :invalid_data)}
Since error types defined using Errata are standard Elixir exceptions, they can
be handled in the same way as any other exception. Specifically, they can be
used in the rescue
clause of a try
block or function, and the
Kernel.is_exception/1
guard will always return true
for them. Additionally,
Errata provides custom guards for handling Errata error types specifically:
Errata.is_error/1
Errata.is_domain_error/1
Errata.is_infrastructure_error/1
To use these custom guards, import
or require
the Errata
module.
The following example demonstrates handling Errata errors both as raised exceptions and as error values returned from functions.
defmodule MyApp.SomeContext do
# require the Errata module to use the custom guards
require Errata
def handle_errata_error_as_exception do
try do
function_that_raises_errata_error!()
rescue
e in [MyApp.SomeContext.MyDomainError] ->
# Errata errors can be rescued by their specific type
handle_my_domain_error(e)
e when Errata.is_error(e) ->
# Or they can be rescued using one of the custom guards defined in the
# Errata module
handle_errata_error(e)
e ->
# Regular exceptions may be handled separately if desired
handle_other_error(e)
end
end
def handle_errata_error_as_value do
case function_that_returns_errata_error_as_value() do
{:ok, result} ->
handle_ok_result(result)
{:error, %MyApp.SomeContext.MyDomainError{} = error} ->
# Errata errors can be pattern matched by their specific type
handle_my_domain_error(error)
{:error, error} when Errata.is_error(error) ->
# Or they can be identified using one of the custom guards defined in
# the Errata module
handle_errata_error(e)
{:error, reason} ->
# Other errors may be handled separately if desired
handle_other_error(reason)
end
end
end
It is common practice in Elixir (and Erlang) to use error tuples of the form
{:error, reason}
as return values from functions to indicate an error
condition. However, the reason
value that is used as the second element
in the tuple is often a simple value such as an atom or (worse) a string, and
does not typically include sufficient context for interpreting the error.
While these simple reason
values are often sufficient for human readers of
the code when viewed in context, they do not provide enough context to be
interpreted by code or when viewed as log messages or error reports, where
the context of where and when the error was originally detected and created
is not readily apparent.
It is a less common but still widespread practice to raise exceptions for errors, instead of or in addition to returning error values from functions. Although exceptions do include some contextual information (including, in particular, a stacktrace), they lack a common, uniform structure that can be used for logging and error handling in general.
Errata, on the other hand, defines a uniform structure that all errors share, and allows errors to be created with full contextual details, including arbitrary context metadata. This full context is embedded into the error struct so that it propagates with the error, whether the error is raised as an exception or returned as an error value from a function. Errata errors are also JSON-encodable so that they can be easily published to external issue tracking systems such as Sentry, for example.
Consider the common pattern pattern of using a with
expression with a series
of function calls, each of which is expected to return a tuple either of the
form {:ok, result}
or {:error, reason}
. If the error reason
does not
contain sufficient contextual detail about the nature and cause of the error,
then the with
expression is forced to handle all possible error values in an
else
clause, in order to log or report the error in a meaningful way.
If, instead, the error reason
for each error is a structured type with full
context, the with
expression can omit the else
clause altogether and allow
the error to propagate to callers. Since the error includes sufficient context
it can be logged or reported to an issue tracking system at the boundaries,
such as a Phoenix controller or a bounded context, without losing the context
necessary for interpreting or debugging the error.
Chris Keathley provides an in-depth discussion of this point in his blog post
Good and Bad Elixir, in
the section "Avoid else
in with
blocks".
errata
can be installed by adding it to your list of dependencies in mix.exs
:
def deps do
[
{:errata, "~> 0.8.1"}
]
end
Documentation is generated with ExDoc and published on HexDocs and be found at https://hexdocs.pm/errata/index.html.