Elarion

Results & errors

Result<T> is a lightweight success-or-failure return type, and AppError is the transport-agnostic failure model.

Handlers return outcomes as values, not exceptions. Result<T> carries either a success value or an AppError, and the host maps that error to whatever the transport requires.

Result<T>

Result<T> is a readonly struct with a success value or an AppError:

public readonly struct Result<T> {
    public bool IsSuccess { get; }
    public T Value { get; }
    public AppError Error { get; }

    public static Result<T> Success(T value);
    public static Result<T> Failure(AppError error);
}

Implicit conversions let a handler return either a response or an error directly — no wrapping ceremony:

return new Response(project.Id);                       // implicit Success
return AppError.Validation("Name is required.");       // implicit Failure
return AppError.Conflict("Invoice number is already reserved.");
AppError.Conflict("The seat is taken.", code: "seat.taken", data: new SeatTaken(4)); // specific code + payload

Because it is a struct with ValueTask-friendly ergonomics, the success path stays allocation-light.

AppError

AppError is a transport-agnostic record describing what kind of failure occurred, not how a particular 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: a stable, machine-readable identifier
    public object? Data { get; init; }  // typed payload for that code
}

Message is prose; clients branch on Code. Each kind has a default code (not_found, conflict, validation, ...) and any factory takes a specific one: AppError.Conflict("Seat taken.", code: "seat.taken", data: new SeatTaken(4)). A handler declares the failures it can produce with [ProducesError("seat.taken", ErrorKind.Conflict, typeof(SeatTaken))]; the declaration flows into the JSON-RPC schema and the generated TypeScript client, which types error.code and error.data per method. See the reference. Failures that many handlers share are declared once: on the assembly or the [AppModule] class for kind-default codes such as not_found, or on the decorator that adds them (see Declaring errors for many operations).

Error kinds

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). Maps to HTTP 401.
ForbiddenThe caller is authenticated but not authorized to perform this operation.
BusinessRuleA domain business rule was violated.
InternalAn unexpected internal error occurred.

Factory methods

Each kind has a factory; validation supports an optional structured payload:

AppError.Validation("Name is required.");
AppError.Validation("Invalid command.", new[] { "Name is required.", "Email is invalid." });
AppError.Validation("Invalid command.", new Dictionary<string, string[]> {   // field-keyed: renders as
    ["name"] = ["Name is required."],                                        // RFC 7807 `errors` over HTTP
});
AppError.NotFound($"Client {id} was not found.");
AppError.Conflict("Invoice number is already reserved.");
AppError.Unauthorized("Authentication required.");
AppError.Forbidden("You cannot modify this resource.");
AppError.BusinessRule("Credit limit exceeded.", data: limitInfo);
AppError.Internal("Failed to render the document.");

AppError.InternalError is a shared instance for the generic internal-failure case.

Mapping to a transport

Kind is the seam between application semantics and protocol codes. Each host owns the mapping, so different deployments can translate the same domain failure differently:

// The framework default: AppError → JSON-RPC error code (Elarion.JsonRpc.AppErrorMapper)
var code = error.Kind switch {
    ErrorKind.Validation   => -32602,        // spec "Invalid params"
    ErrorKind.NotFound     => -32001,        // server range
    ErrorKind.Conflict     => -32002,        // server range
    ErrorKind.Forbidden    => -32003,        // server range
    ErrorKind.BusinessRule => -32004,        // server range
    ErrorKind.Unauthorized => -32005,        // server range
    _                      => -32603,        // spec "Internal error"
};

This keeps handlers free of transport details: the same AppError.NotFound(...) becomes a JSON-RPC error in one host and an HTTP 404 in another. For the complete, authoritative tables — both the JSON-RPC codes and the HTTP status mapping — see the Result & error model reference.

Unexpected errors

An exception is not a Result: it is a bug or an outage, and the caller gets one framework-owned answer for it. Every transport — JSON-RPC, MCP, and generated [HttpEndpoint] routes — catches an exception that escaped the handler pipeline outside every decorator, so a throw from the authorization, validation, audit or observability decorator is answered exactly like a throw from the handler. The answer carries the stable code internal, a message, and a reference the user can quote to support:

TransportWire shape
JSON-RPC{"code": -32603, "message": "Internal error", "data": {"code": "internal", "reference": "4bf92f3577b34da6a3ce929d0e0e4736"}}
MCPan error tool result with the message, and {"code": "internal", "reference": "…"} as structured content (with IncludeErrorDetails, the default)
HTTP500 ProblemDetails with the message as detail, "code": "internal" and "reference": "…"

The reference is never part of the message, and the transport logs the exception at error level together with it, so support finds the log line (and the trace) from what the user reports. By default the message is Internal error and the reference is the current trace id; configure both once for every transport:

builder.Services.ConfigureElarionUnexpectedErrors(o => {
    // Shown to the user; never the exception text unless you choose to expose it.
    o.Message = context => "Something went wrong. Please contact support and quote the reference.";
    // The default is Activity.Current's trace id (a fresh id when no trace is active); return null to send none.
    o.Reference = context => Activity.Current?.TraceId.ToHexString();
});

Each hook receives an UnexpectedErrorContext — the Exception, the Transport (jsonrpc, mcp, http), the Operation being served, and the call's scoped Services, so a hook can resolve a localizer. A hook that throws falls back to the default for its part: the error path always answers. No application decorator, exception handler or pipeline-order test is needed for this. Clients branch on code: "internal" and show reference; the generated TypeScript RpcError exposes it as error.reference.

Generated HTTP routes can hand an exception to the host instead — the developer exception page in Development, or an IExceptionHandler that maps an exception type to its own status:

builder.Services.ConfigureElarionUnexpectedErrors(o =>
    o.LeaveToHost = context => builder.Environment.IsDevelopment() || context.Exception is DbUpdateConcurrencyException);

JSON-RPC and MCP always answer: a batch entry or a tool call has no host handler to fall back to.

Results in pipelines

Decorators can inspect a Result<T> through the non-generic IResultLike interface — for example a transaction decorator that commits on success and rolls back on failure:

if (response is IResultLike { IsSuccess: true }) {
    await transaction.CommitAsync(ct);
} else {
    await transaction.RollbackAsync(ct);
}

See Decorator pipelines for the full pattern, including how a validation decorator turns failures into Result<T>.Failure(...).

On this page