Error Handling
Error is a closed discriminated union of typed records that lets you return failures as values and pattern-match them at the boundary, without falling back to exceptions for normal control flow.
Patterns Index
| Goal | Use | See |
|---|---|---|
| Return a typed failure from a function | Result.Fail<T>(new Error.X(payload) { Detail = "..." }) |
Creating errors |
| Fail but still persist staged work (worker handler) | Result.FailAfterCommit<T>(error) |
Persisting failure state from a worker handler |
| Build a single-violation 422 from a property name | Error.InvalidInput.ForField(field: "email", code: ValidationCodes.ValueNotEmpty, detail: "...") |
Validation failures |
Require nullable values without !, preserving typed composition |
Result.EnsureNotNull(value, "field", detail) / task.EnsureNotNullAsync(...) |
Required values |
| Build a single-violation 422 from an object-level rule | Error.InvalidInput.ForRule(code: "password.confirmation-mismatch", detail: "...") |
Validation failures |
| Aggregate per-field and cross-field violations | new Error.InvalidInput(Fields: ..., Rules: ...) |
Validation failures |
| Branch on the closed catalog at a boundary | result.Match(value => ..., error => error switch { Error.NotFound nf => ..., ... }) |
Pattern matching |
| Log without changing the result | result.TapOnFailure(error => logger.LogWarning(...)) |
Propagating errors |
| Translate one failure case into another | result.MapOnFailure(error => error switch { ... }) |
Propagating errors |
| Capture a thrown exception as a typed error | Result.Try(() => ..., ex => new Error.X(...)) |
Capturing exceptions |
| Merge multiple failures into one | Result.Combine(r1, r2, r3) or left.Combine(right) on Error |
Composition |
Map an Error to an HTTP / Problem Details response |
result.ToHttpResponse(...) (in Trellis.Asp) |
Boundary mapping |
Use this guide when
- You want expected failures (validation, not found, conflict, forbidden) to be values your callers can branch on, not exceptions.
- You need compile-time exhaustiveness across every failure case at a boundary.
- You are aggregating multiple validation failures and want to keep them all instead of throwing the first one away.
- You need a stable wire vocabulary (
Kind,Code, payload) that survives refactors and renders consistently as Problem Details.
Surface at a glance
Error is an abstract record with 12 nested sealed record cases. The base type has a private constructor, so the catalog is closed and switch over an Error reference is exhaustive at the language level.
| Category | Cases |
|---|---|
| Input validation | InvalidInput, InvariantViolation |
| Resource access | NotFound, Gone, Forbidden, AuthenticationRequired |
| State | Conflict |
| Availability | Unavailable, RateLimited |
| Unexpected | Unexpected |
| Transport envelope | TransportFault |
| Composition | Aggregate |
Every case carries a strongly-typed payload, an init-only Detail property, and a structured (never Exception) Cause chain. Full per-case constructor signatures, payload notes, and the supporting types (ResourceRef, InputPointer, FieldViolation, RuleViolation, RetryAdvice, ITransportFault, EquatableArray<T>) live in trellis-api-core.md → Error.
Note
Unexpected(code, faultId?) covers both unhandled faults (set FaultId to correlate with telemetry) and internal invariant violations ("shouldn't happen"). The optional FaultId surfaces as a faultId problem-details extension at the ASP boundary when set. Use the Code == FaultCodes.NotImplemented convention if you need the boundary to map to HTTP 501; the default maps to 500.
Installation
dotnet add package Trellis.Core
Quick start
Return a typed failure, then pattern-match at the boundary.
using Trellis;
public sealed record User(string Id, string Email);
public static class UserService
{
public static Result<User> GetUser(string id) =>
id == "42"
? Result.Ok(new User("42", "ada@example.com"))
: Result.Fail<User>(new Error.NotFound(ResourceRef.For<User>(id)) { Detail = $"User {id} not found" });
}
var message = UserService.GetUser("42").Match(
onSuccess: user => $"200 OK: {user.Email}",
onFailure: error => error switch
{
Error.NotFound nf => $"404 Not Found: {nf.Detail}",
Error.Forbidden f => $"403 Forbidden: {f.PolicyId}",
_ => $"500 Internal: {error.Kind}",
});
Creating errors
There are no static factory methods on the base Error type. Every call site names the case it produces:
new Error.NotFound(ResourceRef.For<Order>("42")) { Detail = "Order 42 not found" }
Detail is an init-only property on the base record; set it via object-initializer when you want to override the boundary renderer's default human-readable text. Kind is the stable domain slug and Code is the per-instance machine identifier exposed by that case.
For the common single-payload shapes, the resource-bearing cases (NotFound, Gone, Conflict, Forbidden, InvariantViolation) and InvalidInput also expose case-scoped convenience factories that bundle the typed payload while still naming the case — they only remove the ResourceRef / InputPointer construction ceremony:
Error.NotFound.For<Order>(id: id, detail: "Order not found");
Error.Conflict.ForReason(FaultCodes.DuplicateKey, detail: "Email in use");
Error.Forbidden.ForPolicy("orders.write", detail: "Admin required");
Error.InvariantViolation.ForReason("order.cross-aggregate-rule");
Error.InvalidInput.ForField(ValidationCodes.StringEmail, "email", detail: "Bad email");
Factories put code first and detail last. Validation, conflict, invariant, and policy codes must be nonblank; invalid codes throw argument exceptions even through constructors or with assignments. Application-specific codes remain supported. NotFound and Gone still allow omission (id: id); blank optional factory codes retain ValidationCodes.Unspecified. Explicit resources use For(code, ResourceRef.For("Order", id), detail: ...), not a string-resource overload. See the complete factory signatures.
| Pattern | Example |
|---|---|
| Resource not found | new Error.NotFound(ResourceRef.For<Order>(id)) { Detail = $"Order {id} not found" } |
| State conflict | new Error.Conflict(Resource: ResourceRef.For<User>(userId), Code: FaultCodes.DuplicateKey) { Detail = "Email is already in use" } |
| Domain rule conflict (no resource) | new Error.Conflict(Resource: null, Code: "order.cancel-after-ship") { Detail = "Cannot cancel after shipment" } |
| Authentication missing | new Error.AuthenticationRequired() |
| Authenticated but not allowed | new Error.Forbidden("orders.write") { Detail = "Administrator role required" } |
| Soft-deleted resource | new Error.Gone(ResourceRef.For<Document>(id)) |
| Wrong content type | new Error.TransportFault(new HttpError.UnsupportedMediaType(EquatableArray.Create("application/json"))) |
| Body too large | new Error.TransportFault(new HttpError.ContentTooLarge(10 * 1024 * 1024)) |
| Method not supported | new Error.TransportFault(new HttpError.MethodNotAllowed(EquatableArray.Create("GET", "POST"))) |
| Rate limited | new Error.RateLimited(new RetryAdvice(After: TimeSpan.FromSeconds(30))) |
| Dependency unavailable | new Error.Unavailable(new RetryAdvice(After: TimeSpan.FromSeconds(120))) { Code = "payment-gateway.offline" } |
| Unhandled fault | new Error.Unexpected("crm.timeout", faultId) |
| Aggregate invariant | new Error.InvariantViolation("order.cross-aggregate-rule", ResourceRef.For<Order>(orderId)) |
Tip
Reach for new Error.Conflict(Resource: resource, Code: "domain.violation") when state blocks an otherwise-valid request. Error.InvalidInput is for input the caller can fix. Error.InvariantViolation is for domain rules that aren't bound to a specific request field (e.g. a cross-aggregate rule, an internal precondition).
Validation failures
Error.InvalidInput carries EquatableArray<FieldViolation> Fields and EquatableArray<RuleViolation> Rules. For single-violation cases prefer the static factories on the case itself; the boundary renderer surfaces a single field-violation's Detail as the response detail directly.
| Factory | Use when |
|---|---|
Error.InvalidInput.ForField(code, field: propertyName, args: args, detail: detail) |
Single property failure, escaped via InputPointer.ForProperty; null/empty targets the root. args and detail are optional. |
Error.InvalidInput.ForField(code, field: pointer, args: args, detail: detail) |
An existing InputPointer, preserving nested/array path and input location. |
Error.InvalidInput.Required(fieldName, detail) |
Standard ValidationCodes.ValueNotNull violation; null/empty field targets the root. |
Error.InvalidInput.Required(pointer, detail) |
The same required-field violation, preserving the pointer's path and input location. |
Error.InvalidInput.ForRule(code, fields: fields, args: args, detail: detail) |
Object-level or cross-field invariant. Optional related pointers are defensively copied in order; args and detail are also optional. |
new Error.InvalidInput(EquatableArray<FieldViolation> Fields, EquatableArray<RuleViolation> Rules = default) |
Aggregate multiple per-field and/or cross-field violations. |
using System.Collections.Immutable;
using Trellis;
var single = Error.InvalidInput.ForField(field: "email", code: ValidationCodes.ValueNotEmpty, detail: "Email is required");
var indexedField = InputPointer.ForBody("/items").AppendIndex(2).AppendProperty("quantity");
var indexed = Error.InvalidInput.ForField(field: indexedField, code: ValidationCodes.ValueGreaterThan,
detail: "Quantity must be greater than zero.");
var crossField = Error.InvalidInput.ForRule("password.confirmation-mismatch",
fields: [InputPointer.ForBody("/password"), InputPointer.ForBody("/confirmation")],
detail: "Password and confirmation differ.");
var multiField = new Error.InvalidInput(EquatableArray.Create(
new FieldViolation(InputPointer.ForProperty("email"), ValidationCodes.ValueNotEmpty) { Detail = "Email is required" },
new FieldViolation(InputPointer.ForProperty("password"), ValidationCodes.StringMinLength,
ValidationArgs.Of("minLength", 8)) { Detail = "Password must be at least 8 characters" },
new FieldViolation(InputPointer.ForProperty("age"), ValidationCodes.ValueGreaterThanOrEqual,
ValidationArgs.Of("comparisonValue", 18)) { Detail = "Must be 18 or older" }));
var crossField = new Error.InvalidInput(
Fields: EquatableArray<FieldViolation>.Empty,
Rules: EquatableArray.Create(new RuleViolation(
"password.confirmation-mismatch",
Fields: EquatableArray.Create(
InputPointer.ForProperty("password"),
InputPointer.ForProperty("passwordConfirmation")))
{ Detail = "Passwords must match" }));
AppendProperty appends a literal name and escapes ~ and /; it does not treat a
leading slash as another pointer. AppendIndex rejects negative indexes. Both retain the
parent pointer's input location, so the example points to /items/2/quantity in the body.
Required values
Result.EnsureNotNull guards a nullable reference or nullable struct and returns
Result<T>, not Result<Unit>. Typed Combine/Map receives the values directly:
public sealed record CreateItemCommand(string Name, int Category)
{
public static Result<CreateItemCommand> TryCreate(string? name, int? category) =>
Result.EnsureNotNull(name, "name", "Name is required.")
.Combine(Result.EnsureNotNull(category, "category", "Category is required."))
.Map((name, category) => new CreateItemCommand(name, category));
}
Both missing fields are reported, and no ! is needed. The guard checks only null,
not blank strings or default scalar values; add those rules separately when required.
Field/detail forms construct no error or violation (and record no validation failure)
on success. Custom failures can use Error or a lazy Func<Error>.
String field names may be null/empty to target the root. Full JSON Pointers are
validated only when the value is missing, so a malformed pointer throws
ArgumentException on that path but is unused on success.
For a nullable-returning query, use task.EnsureNotNullAsync(...) on Task<T?> /
ValueTask<T?>; source faults/cancellation propagate and factories run only for a
successful source completion with null. Factories must be non-null and are validated
before awaiting. Core's nullable ToResult APIs and universal no-argument lift are
removed; use Result.Ok(value) only for deliberate success wrapping.
Maybe<T>.ToResult(error/factory) remains the ordinary-absence bridge.
For conditional requiredness or collection-element rules, use
Error.InvalidInput.Required("kind", "Kind is required for this item.").
Its pointer form preserves input location; use
Result.EnsureNotNull(value, () => Error.InvalidInput.Required(pointer, detail))
for a lazy location-aware guard.
Composite validators can construct FieldViolation.Required("kind", detail) or
FieldViolation.Required(InputPointer.ForBody("/items").AppendIndex(2).AppendProperty("kind"), detail).
Both use ValidationCodes.ValueNotNull, no structured args, and the supplied detail;
the pointer form retains its input location. This does not change HTTP mappings.
Pattern matching
Use Match to fold a Result<T> into a value, or read result.Error (which is Error? and never throws) to perform side effects. The C# compiler verifies exhaustiveness against the closed catalog — adding a new case to Error lights up every switch that does not handle it.
var message = LoadUser("42").Match(
onSuccess: user => $"Found {user.Email}",
onFailure: error => error switch
{
Error.InvalidInput uc => $"Bad input: {uc.GetDisplayMessage()}",
Error.NotFound nf => $"Missing {nf.Resource.Type} {nf.Resource.Id}",
Error.Forbidden f => $"Not allowed by {f.PolicyId}",
_ => $"Fallback: {error.Kind}",
});
Match has async overloads for Task<Result<T>> and ValueTask<Result<T>> (MatchAsync) and tuple overloads for arities 2–9. Full signatures: trellis-api-core.md → Match family.
Kind vs Code
Kindis the stable, low-cardinality domain slug used for telemetry (e.g."invalid-input","invariant-violation","not-found"). It is unaffected by payload values.Codedefaults to the sentinelValidationCodes.Unspecified("error.unspecified") — never toKind. It is stored once on the base with aninitaccessor, so any case can name a reason the same way:new Error.NotFound(resource) { Code = "order.archived" }. Cases whose reason is required —Conflict,Forbidden(wherePolicyIdreads the same value),InvariantViolation, andUnexpected(which surfacesFaultIdseparately as a problem-details extension when set) — take it as a positional constructor parameter, so the compiler refuses one that says nothing.Codeis what every boundary publishes, so a kind never reaches a consumer disguised as a reason.
At the ASP boundary, the wire kind can differ from the domain Kind for backward compatibility — most notably Error.InvalidInput / Error.InvariantViolation map to on-wire kind = "unprocessable-content".
Propagating errors
| Operator | Effect |
|---|---|
TapOnFailure(action) |
Side effect (log/metric) on failure; result passes through unchanged. |
MapOnFailure(error => newError) |
Translate one failure case into another (e.g. when crossing layers). |
RecoverOnFailure(error => Result<T>) |
Replace a failure with a fallback Result (success or different failure). |
var result = LoadFromCrm(id)
.TapOnFailure(error => logger.LogWarning("CRM call failed: {Kind} {Code}", error.Kind, error.Code))
.MapOnFailure(error => error switch
{
Error.Unexpected => new Error.Unavailable(new RetryAdvice(After: TimeSpan.FromSeconds(60)))
{ Code = "crm.offline", Detail = "Customer service is temporarily unavailable" },
_ => error,
});
static Result<string> GetFromCache() => Result.Fail<string>(new Error.NotFound(ResourceRef.For<string>("user:42")));
static Result<string> GetFromDatabase() => Result.Ok("Ada Lovelace");
var withFallback = GetFromCache().RecoverOnFailure(_ => GetFromDatabase());
All three operators have Async variants on Task<Result<T>> and ValueTask<Result<T>>. See trellis-api-core.md → Tap, Map, Recover families.
Capturing exceptions
Expected failures should be regular Error values. For code that genuinely throws, Result.Try and Result.TryAsync bridge the gap. Without a custom map, the default mapping wraps the exception as new Error.Unexpected("unhandled-exception", faultId) with a generated FaultId.
using System.IO;
using Trellis;
static Result<string> LoadText(string path) =>
Result.Try(() => File.ReadAllText(path));
static Task<Result<string>> LoadTextAsync(string path) =>
Result.TryAsync(() => File.ReadAllTextAsync(path));
static Result<string> LoadConfig(string path) =>
Result.Try(
() => File.ReadAllText(path),
exception => exception switch
{
FileNotFoundException => new Error.NotFound(ResourceRef.For<FileInfo>(path)) { Detail = $"{path} was not found" },
UnauthorizedAccessException => new Error.Forbidden("file.read") { Detail = "Access denied" },
_ => new Error.Unexpected("unhandled-exception", Guid.NewGuid().ToString("N")) { Detail = "An unexpected error occurred while loading the file." },
});
Result.Try and Result.TryAsync also have parameterless-work overloads that return Result<Unit> for void/Task work — useful for command handlers, which return Result<Unit>.
Composition
Combine merges multiple Result<T> failures into one. Two distinct shapes:
| Input failures | Result |
|---|---|
All Error.InvalidInput |
One merged Error.InvalidInput with concatenated Fields and Rules. |
| Heterogeneous (mixed cases) | One Error.Aggregate wrapping the children. Nested aggregates are flattened at construction. |
var emailErr = Result.Fail(Error.InvalidInput.ForField(field: "email", code: ValidationCodes.ValueNotEmpty));
var passwordErr = Result.Fail(Error.InvalidInput.ForField(field: "password", code: ValidationCodes.ValueNotEmpty));
var ageErr = Result.Fail(Error.InvalidInput.ForField(field: "age", code: ValidationCodes.ValueNotEmpty));
var combined = Result.Combine(emailErr, passwordErr, ageErr);
// combined.Error is one Error.InvalidInput with three Fields entries.
Error.Combine(left, right) (extension on Error?) does the same merging at the error level; passing a null left returns right.
Error? acc = null;
acc = acc.Combine(new Error.NotFound(ResourceRef.For<Order>("1")));
acc = acc.Combine(new Error.Forbidden("orders.write"));
// acc is Error.Aggregate { Errors = [NotFound, Forbidden] }
Error.Aggregate exposes three constructor overloads (EquatableArray<Error>, IEnumerable<Error>, params Error[]); all three throw ArgumentException if no errors are supplied, and all flatten nested aggregates.
For collection validation, use TraverseAll to accumulate failures from every item.
Its indexed overload keeps input positions available for precise error pointers:
string?[] inputs = ["Ada", null, "Grace"];
var names = inputs.TraverseAll((name, index) =>
Result.EnsureNotNull(name, () => Error.InvalidInput.ForField(
field: InputPointer.Root.AppendProperty("names").AppendIndex(index),
code: ValidationCodes.ValueNotNull, detail: "Name is required.")));
// Failure identifies /names/1; every input is examined.
For asynchronous validation, use
TraverseAllAsync((item, index, ct) => ValidateAsync(item, index, ct), cancellationToken),
where the validator is application-owned. Task and ValueTask selectors are awaited sequentially;
the call-site token is optional, but indexed async selectors always take three parameters.
This avoids ambiguity with the existing (item, cancellationToken) selector. Use the
fail-fast Traverse family instead when later operations must not run after a failure.
Boundary mapping
Map an Error to an HTTP / Problem Details response with result.ToHttpResponse(...) from Trellis.Asp. The boundary decides HTTP status, companion headers, and the problem-details type / kind. That wire kind can differ from the domain Kind for backward compatibility — Error.InvalidInput.Kind is invalid-input, but the on-wire kind is unprocessable-content. For Result<Unit> the success branch emits 204 No Content.
using Trellis;
using Trellis.Asp;
public static IResult Get(string id, IUserService users) =>
users.GetUser(id).ToHttpResponse();
Full mapping rules and per-case behaviour live in:
- API reference:
trellis-api-asp.md→HttpResponseExtensions - Article:
asp-tohttpresponse.md→ Per-call error mapping
Practical guidance
- Pick the case the caller can act on.
InvalidInputwhen the request data can be fixed;Conflictwhen state or a business rule blocks an otherwise-valid request;InvariantViolationwhen the rule is real but not bound to a request field;NotFoundwhen the resource does not exist;Gonefor soft-deleted resources. - Use
Unexpected(code, faultId?)for true surprises. SetFaultIdwhen you need telemetry correlation; useCode == FaultCodes.NotImplementedonly when you intentionally want HTTP 501 at the boundary. - Set
Detailonly when it adds information. Boundary renderers compute a usable default fromKind/Codeand the typed payload; overrideDetailonly when you have something more specific to say. - Prefer
Match(or a property pattern onresult.Error) at boundaries. Inside pipelines, preferBind/Map/Ensureover inspectingErrordirectly. - Use
TapOnFailurefor logging and metrics. It does not mutate the result. - Use
MapOnFailurewhen crossing layers. Translating a low-levelUnexpectedfrom a dependency into a domain-meaningfulUnavailableis a typical use. - Use
Combineto preserve every failure. This is the difference between "first error wins" and "tell the caller everything that's wrong." - Equality. Two errors compare equal when
Kind, payload, andDetailmatch.Causeis excluded from equality so the same surface failure raised from different code paths still compares equal.
InvalidInput vs InvariantViolation
InvalidInput carries field/rule violations bound to inbound request fields (per-field validation). InvariantViolation carries (Code, ResourceRef?) for domain rules that aren't tied to a request field — for example a cross-aggregate invariant or an internal precondition. Both map to HTTP 422 at the ASP boundary, but the wire code differs so consumers can tell domain-rule failures apart from field-validation failures.
Migration from v2.x
| Removed | Use instead |
|---|---|
Error.BadRequest |
Error.InvalidInput.ForRule(code: ...) (or .ForField(...) if anchored to a request field) |
Error.UnprocessableContent |
Error.InvalidInput |
Error.Unauthorized |
Error.AuthenticationRequired |
Error.TooManyRequests |
Error.RateLimited |
Error.ServiceUnavailable |
Error.Unavailable |
Error.InternalServerError |
Error.Unexpected |
Error.NotImplemented |
Error.Unexpected with Code == FaultCodes.NotImplemented |
Error.MethodNotAllowed / NotAcceptable / UnsupportedMediaType / RangeNotSatisfiable / ContentTooLarge / PreconditionFailed / PreconditionRequired |
Error.TransportFault(new HttpError.X(...)) |
For the full upgrade narrative, including telemetry-slug changes and AuthChallenge removal, see the project CHANGELOG.
Cross-references
- API surface (full per-case table, supporting types,
Result.Try/TryAsync, Combine family, Match/Tap/Map/Recover families):trellis-api-core.md - HTTP response mapping for
Result<T>and standaloneError:trellis-api-asp.md,asp-tohttpresponse.md - FluentValidation →
Error.InvalidInputadapter:integration-fluentvalidation.md - HTTP client failure mapping (turning upstream statuses into typed
Errorcases):integration-http.md - Mediator pipeline behaviours that operate on
Result/Error:integration-mediator.md