Elarion

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>>.

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 Failure

Result (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.

Result
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.

Unit
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:

IResultLike
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:

AppError
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.

FactoryKindNotes
AppError.Validation(string message, string? code = null, object? data = null)Validation
AppError.Validation(string message, IReadOnlyList<string> errors, string? code = null)ValidationWraps errors in ValidationErrorData as Data.
AppError.Validation(string message, IReadOnlyDictionary<string, string[]> fieldErrors, string? code = null)ValidationWraps 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)anyThe 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:

ValidationErrorData
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.

ErrorKindDefault code
Validationvalidation
NotFoundnot_found
Conflictconflict
Unauthorizedunauthorized
Forbiddenforbidden
BusinessRulebusiness_rule
Internalinternal

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>> { ... }
ConstructorDeclares
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 hasImplied errors
validatable request (with Elarion.Validation)validation with ValidationErrorData
[Require*] attributeunauthorized, 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 onDeclares 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] classevery 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 AppliesTo predicate 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.

TransportCodeTyped payload
JSON-RPCerror.data.codeerror.data.data (omitted when none)
HTTPProblemDetails member codeProblemDetails member data (a validation payload is the standard errors map instead)
gRPCelarion-error-code trailernot carried (the kind goes in elarion-error-kind)
MCPstructured content codestructured 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).

JSON-RPC error
{ "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.

ErrorKindMeaning
ValidationInvalid input or constraint violation.
NotFoundThe requested resource does not exist.
ConflictThe operation conflicts with existing state (duplicate, concurrent modification).
UnauthorizedThe caller is not authenticated (missing or invalid credentials).
ForbiddenThe caller is authenticated but not authorized to perform this operation.
BusinessRuleA domain business rule was violated.
InternalAn 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 }.

ErrorKindJSON-RPC codeNote
Validation-32602Spec "Invalid params".
NotFound-32001Server range.
Conflict-32002Server range.
Forbidden-32003Server range.
BusinessRule-32004Server range.
Unauthorized-32005Server range.
Internal-32603Spec "Internal error".
(default)-32603Falls 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.

ErrorKindHTTP status
Validation400 Bad Request
NotFound404 Not Found
Conflict409 Conflict
Unauthorized401 Unauthorized
Forbidden403 Forbidden
BusinessRule422 Unprocessable Entity
Internal500 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.

On this page