Offside

Concepts

Português · Back to docs

Six ideas carry the whole library.

Error

A domain failure described by data: a stable Code, an ErrorKind, interpolation Arguments, and an optional Field. It is not an exception and carries no stack trace.

var error = Error.NotFound("order", 42);
// Code      = "not_found"
// Kind      = ErrorKind.NotFound
// Arguments = { resource: "order", id: 42 }
// Field     = null

Error is immutable and compares by value, so it is safe to cache, compare in tests, and pass around freely:

Error.NotFound("order", 42) == Error.NotFound("order", 42);   // true

Instances come only from the static factories — the constructor is internal. That is what guarantees every error in the system has a known shape and a code that a catalog can resolve.

ErrorKind

The closed set of failure species. A kind decides two things: the HTTP status code and the severity rank used to pick a winner when a result carries several errors.

Business rules do not invent kinds. They reuse one and supply their own code:

Error.Custom("order.already_shipped", ErrorKind.Conflict, new { orderId });

This is the central design trade. A closed kind set means the transport mapping is total — every error the domain can produce already has a defined status code, with no registry to maintain and no default branch to forget. An open code space means the domain can be as specific as it likes. See the ASP.NET Core guide for the full kind → status table.

Result and Result<T>

The outcome of an operation: success (with a value, for Result<T>) or failure carrying one or more errors. This is how domain and application code report failure — returning, not throwing.

Result<Order> found  = Result<Order>.Success(order);
Result<Order> missing = Result<Order>.Failure(Error.NotFound("order", id));
Result       done    = Result.Success();

Both are readonly structs, so there is no allocation on the success path. Two consequences worth knowing:

Reading a value is explicit — Value throws on a failed result, so use TryGetValue, Match, or check IsSuccess first.

Primary error

When a result carries several errors, one of them drives the response: the error of the most severe kind, with ties broken in favour of the first error in the result. It supplies the Problem Details title and detail, and its kind supplies the HTTP status.

The other errors are not discarded — every one appears in the errors array. A form that fails validation on three fields returns 400 and reports all three.

Message catalog

A JSON file per culture mapping Code → message template. Metadata stays in C#; only text is translated.

{ "not_found": "{resource} '{id}' was not found." }

Tokens are filled from Error.Arguments. Resolution walks from the requested culture to its parent to the invariant catalog, so pt-BR falls back to pt and then to the default. Details in Messages and cultures.

Escape hatch

Error.ToException() produces a DomainException carrying the errors, for boundaries whose signature you do not control — a constructor, or an interface you did not write.

if (quantity <= 0)
    throw Error.Validation("quantity", attemptedValue: quantity).ToException();

This is the exception, not the rule. Ordinary business failures return a Result. Reaching for ToException routinely gives up the property that makes the whole approach worth it: that a method signature tells you it can fail.

Why not exceptions

Exceptions are control flow for the unexpected. A missing order, a duplicate email, an expired token — none of those are unexpected; they are outcomes the caller is supposed to handle. Modelling them as return values means:

ErrorKind.Unexpected still exists for genuine faults — and it is the one kind whose detail is never shown to the client. See 500 handling.