Result & error model
Lookup reference for Result<T>, Result, Unit, AppError, ErrorKind, error codes, and [ProducesError], with the wire shape of an error on every transport.
A lookup reference for the result and error types. For the conceptual model and how these flow through handlers and decorators, see Results & errors.
All four core types live in Elarion.Abstractions. The two mappers live in the transport packages:
Elarion.JsonRpc.AppErrorMapper (JSON-RPC) and Elarion.AspNetCore.HttpAppErrorMapper (HTTP).
Result<T>
Elarion.Abstractions.Result<T> — a readonly struct wrapping either a success value or an
AppError. It implements IResultLike, IResultError, and IResultFailureFactory<Result<T>>.
public readonly struct Result<T> : IResultLike, IResultError, IResultFailureFactory<Result<T>> {
public bool IsSuccess { get; }
public T Value { get; } // valid only when IsSuccess is true
public AppError Error { get; } // valid only when IsSuccess is false
public static Result<T> Success(T value);
public static Result<T> Failure(AppError error);
public static implicit operator Result<T>(T value); // success
public static implicit operator Result<T>(AppError error); // failure
}The implicit conversions let a handler return a response value or an AppError directly:
return new Response(project.Id); // implicit Success
return AppError.NotFound($"Project {id} not found"); // implicit FailureResult (non-generic)
Elarion.Abstractions.Result — the no-value companion for operations that produce no response,
returned by the IHandler<T> convenience interface. It converts to Result<Unit> so it never
enters the generic dispatch/decorator pipeline.
public readonly struct Result : IResultLike, IResultError, IResultFailureFactory<Result> {
public bool IsSuccess { get; }
public AppError Error { get; } // valid only when IsSuccess is false
public static Result Success();
public static Result Failure(AppError error);
public static implicit operator Result(AppError error); // failure
public Result<Unit> ToResultUnit();
public static implicit operator Result<Unit>(Result result);
}Unit
Elarion.Abstractions.Results.Unit — a readonly struct with exactly one value, used as the "no
value" payload for Result<Unit>. All Unit values are equal.
public readonly struct Unit : IEquatable<Unit> {
public static Unit Value => default; // the single value
// == always true, != always false, ToString() => "()"
}Unit lives in the dedicated Elarion.Abstractions.Results namespace — not the root
Elarion.Abstractions namespace that handlers import for IHandler/Result/AppError. Unit is
a common domain noun (units of measure, org units, rental units), so keeping it out of the
always-imported namespace avoids CS0104 ambiguity with a domain Unit type. You rarely name
Unit directly — prefer the IHandler<T> sugar and Result.Success(). When you do, add
using Elarion.Abstractions.Results;, and if a domain Unit is also in scope, alias one of them:
using ResultUnit = Elarion.Abstractions.Results.Unit; // or: using DomainUnit = MyApp.Domain.Unit;IResultLike
Elarion.Abstractions.IResultLike — the non-generic seam decorators use to inspect success/failure
without knowing T:
public interface IResultLike {
bool IsSuccess { get; }
}Both Result<T> and Result implement it, so a decorator can branch on the outcome:
if (response is IResultLike { IsSuccess: true }) {
await transaction.CommitAsync(ct);
} else {
await transaction.RollbackAsync(ct);
}AppError
Elarion.Abstractions.AppError — a transport-agnostic sealed record describing the kind of
failure, not how a protocol should report it:
public sealed record AppError {
public required ErrorKind Kind { get; init; }
public required string Message { get; init; }
public string Code { get; init; } // always present; the kind's default code when none was set
public object? Data { get; init; } // typed payload for this Code
// Shared singleton for the generic internal-failure case.
public static readonly AppError InternalError; // Kind = Internal, Message = "Internal error"
}Code is the contract clients branch on; Message is prose. An error built without a specific code carries its
kind's default code (see Error codes).
Factory methods
Every factory takes the same optional code and data, so any kind can carry a specific code and a typed payload.
| Factory | Kind | Notes |
|---|---|---|
AppError.Validation(string message, string? code = null, object? data = null) | Validation | |
AppError.Validation(string message, IReadOnlyList<string> errors, string? code = null) | Validation | Wraps errors in ValidationErrorData as Data. |
AppError.Validation(string message, IReadOnlyDictionary<string, string[]> fieldErrors, string? code = null) | Validation | Wraps messages keyed by wire-named field path (e.g. "address.street"; empty key = not field-specific) in ValidationErrorData, deriving the flat Errors list by flattening in ordinal key order. |
AppError.NotFound / Conflict / Unauthorized / Forbidden / BusinessRule / Internal (string message, string? code = null, object? data = null) | the matching kind | |
AppError.Create(ErrorKind kind, string message, string? code = null, object? data = null) | any | The general factory the others call. |
AppError.Validation("Invalid command.", new[] { "Name is required.", "Email is invalid." });
AppError.NotFound($"Client {id} was not found.");
AppError.Conflict("The seat is taken.", code: "seat.taken", data: new SeatTaken(seatId));An invalid code throws ArgumentException at the call site. Two errors are equal when kind, message, effective code and
payload are equal, so an explicit default code equals an omitted one.
ValidationErrorData is the payload shape for both structured overloads:
public sealed record ValidationErrorData {
public required IReadOnlyList<string> Errors { get; init; }
// Keyed by wire-named field path per the canonical JSON naming policy (e.g. "address.street",
// "deliveries[1].street"); null when the error was built from a flat message list.
public IReadOnlyDictionary<string, string[]>? FieldErrors { get; init; }
}The framework ValidationDecorator produces the field-keyed shape; the
HTTP mapper surfaces FieldErrors as the RFC 7807 errors extension, and the JSON-RPC error carries
the whole payload in error.data.data under the code validation.
Error codes
Elarion.Abstractions.ErrorCodes holds the default code of every ErrorKind and the codes the pipeline itself produces.
A code is lower-case ASCII letters, digits and underscores in dot-separated segments, never renamed or reused once
shipped.
ErrorKind | Default code |
|---|---|
Validation | validation |
NotFound | not_found |
Conflict | conflict |
Unauthorized | unauthorized |
Forbidden | forbidden |
BusinessRule | business_rule |
Internal | internal |
Pipeline codes: idempotency.key_required (Validation), idempotency.in_progress (Conflict),
idempotency.key_reused (BusinessRule). Package codes: paging.sort_key_unknown (Validation, from
SortMap.Validate), settings.pinned, settings.concurrency_conflict. Every framework answer carries its specific
code, so a client never matches the message text. ErrorCodes.IsValid(code) checks the format.
[ProducesError] — the declared error contract
A handler declares the failures it can produce, so the schema export and the generated clients describe them (ADR-0080).
[Handler]
[ProducesError("token.malformed", ErrorKind.Validation, typeof(TokenProblem))] // specific code + typed payload
[ProducesError(ErrorKind.NotFound)] // kind-default code, no payload
public sealed class RedeemToken(...) : IHandler<RedeemToken.Command, Result<RedeemToken.Response>> { ... }| Constructor | Declares |
|---|---|
ProducesError(ErrorKind kind) | the kind's default code, no payload |
ProducesError(ErrorKind kind, Type dataType) | the kind's default code with a payload |
ProducesError(string code, ErrorKind kind) | a specific code, no payload |
ProducesError(string code, ErrorKind kind, Type dataType) | a specific code with a payload |
The generator adds the failures the framework attaches where they are statically knowable and publishes the merged
list (sorted by code) with the operation, in the assembly manifest and the HandlerDispatcher registration:
| Handler has | Implied errors |
|---|---|
validatable request (with Elarion.Validation) | validation with ValidationErrorData |
[Require*] attribute | unauthorized, forbidden |
[FeatureGate] | not_found |
[Idempotent] | idempotency.key_required, idempotency.in_progress, idempotency.key_reused |
Declaring errors for many operations
A failure that many handlers share does not need an attribute on each of them. [ProducesError] also goes on the
places that cover a group of operations, and the generator merges those declarations into every operation in scope:
| Placed on | Declares the error for |
|---|---|
| the handler class (or a base class) | that operation |
a decorator in the handler's resolved [DecoratorList] | every operation the decorator wraps |
the [AppModule] class | every operation in the module |
the assembly ([assembly: ProducesError(...)]) | every operation declared in that assembly |
// Kind-default codes the handlers of this assembly return, declared once.
[assembly: ProducesError(ErrorKind.NotFound)]
[assembly: ProducesError(ErrorKind.Conflict)]
// A failure the decorator adds to every command it wraps, with its payload.
[ProducesError("db.constraint", ErrorKind.Conflict, typeof(ConstraintViolation))]
public sealed class DbConstraintDecorator<TRequest, TResponse>(IHandler<TRequest, TResponse> inner)
: IHandler<TRequest, TResponse> where TRequest : ICommand { ... }- Precedence. When several places declare the same code, the one closest to the handler wins: the handler's own declaration, then the framework's implied errors (the table above), then the decorators in pipeline order (outermost first), then the module, then the assembly. A default never changes a code the handler declares itself, so a handler can add a typed payload to a kind-default code the assembly declares.
- Decorators. The generator resolves the decorator list the same way the pipeline does (handler, then module,
then assembly pipeline attribute), and skips a decorator whose generic constraints exclude the handler. An
AppliesTopredicate is evaluated at runtime, so a conditional decorator's errors are declared for every operation it can wrap. The framework's own decorators (validation, authorization, feature gates, idempotency) are covered by the implied errors. - Scope. An assembly declaration covers the operations declared in that assembly only, not the operations of referenced module assemblies. Each assembly publishes its merged contracts in its manifest, so a host sees them without declaring anything again.
- Trade-off. A default is a promise that an operation may fail with that code. An operation in scope that never returns it carries a variant in the schema and the client union that its callers never see. Declare a default on the narrowest scope that fits: the module rather than the assembly, a decorator rather than its handlers.
Diagnostics: ELERR001 (invalid code), ELERR002 (one code declared twice on the same handler, decorator, module or
assembly with different kinds or payloads). Whether a handler body returns an undeclared code is not statically
knowable, so it is checked at runtime in the Development environment: IErrorContractMonitor logs each distinct
undeclared code (or kind mismatch) once as a warning. A code that a decorator, module or assembly declares counts as
declared and is not reported. internal failures and the default unauthorized/forbidden codes (which
authorization defaults can attach without a per-handler attribute) are always admitted. Production does no checking.
Wire shape
Every transport carries the code, and the typed payload where the transport has a place for it.
| Transport | Code | Typed payload |
|---|---|---|
| JSON-RPC | error.data.code | error.data.data (omitted when none) |
| HTTP | ProblemDetails member code | ProblemDetails member data (a validation payload is the standard errors map instead) |
| gRPC | elarion-error-code trailer | not carried (the kind goes in elarion-error-kind) |
| MCP | structured content code | structured content data |
An exception that escaped the handler pipeline additionally carries its support reference: error.data.reference
on JSON-RPC, structured content reference on MCP, the ProblemDetails member reference on HTTP (see
Unexpected errors).
{ "code": -32002, "message": "The seat is taken.", "data": { "code": "seat.taken", "data": { "seat": 4 } } }The numeric JSON-RPC error.code stays the coarse kind mapping below. Protocol-level failures carry parse_error,
invalid_request, method_not_found, invalid_params and internal. An [Idempotent] replay returns the stored
AppError, so the code and payload are identical to the original response. The payload is serialized by its runtime
type, so it must be registered in a source-generated JsonSerializerContext.
ErrorKind
Elarion.Abstractions.ErrorKind — the semantic category. The transport layer maps it to
protocol-specific codes.
ErrorKind | Meaning |
|---|---|
Validation | Invalid input or constraint violation. |
NotFound | The requested resource does not exist. |
Conflict | The operation conflicts with existing state (duplicate, concurrent modification). |
Unauthorized | The caller is not authenticated (missing or invalid credentials). |
Forbidden | The caller is authenticated but not authorized to perform this operation. |
BusinessRule | A domain business rule was violated. |
Internal | An unexpected internal error occurred. |
ErrorKind mappings
Kind is the seam between application semantics and protocol codes. Each host owns its mapping; the
tables below are the framework defaults. The unknown/default branch of both mappers falls through to
the Internal row.
JSON-RPC error code
From Elarion.JsonRpc.AppErrorMapper.MapToCode(ErrorKind). Protocol-level kinds reuse the JSON-RPC 2.0 spec
codes; the remaining application kinds use the reserved server range (-32000 to -32099).
AppErrorMapper.ToRpcError(AppError) wraps the code with the error's Message and { code, data }.
ErrorKind | JSON-RPC code | Note |
|---|---|---|
Validation | -32602 | Spec "Invalid params". |
NotFound | -32001 | Server range. |
Conflict | -32002 | Server range. |
Forbidden | -32003 | Server range. |
BusinessRule | -32004 | Server range. |
Unauthorized | -32005 | Server range. |
Internal | -32603 | Spec "Internal error". |
| (default) | -32603 | Falls back to internal error. |
HTTP status code
From Elarion.AspNetCore.HttpAppErrorMapper.MapToStatusCode(ErrorKind), used by ElarionHttpResults
when translating a failed Result<T> into an RFC 7807 ProblemDetails response.
ErrorKind | HTTP status |
|---|---|
Validation | 400 Bad Request |
NotFound | 404 Not Found |
Conflict | 409 Conflict |
Unauthorized | 401 Unauthorized |
Forbidden | 403 Forbidden |
BusinessRule | 422 Unprocessable Entity |
Internal | 500 Internal Server Error |
| (default) | 500 Internal Server Error |
The JSON-RPC table is the default applied when an operation is mapped onto the dispatcher (e.g.
dispatcher.Map<TRequest, TResponse>("x") or dispatcher.MapDelegate<TRequest, TResponse>("x", fn)).
Applications that need different codes supply their own mapping.