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 + payloadBecause 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
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). Maps to HTTP 401. |
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. |
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:
| Transport | Wire shape |
|---|---|
| JSON-RPC | {"code": -32603, "message": "Internal error", "data": {"code": "internal", "reference": "4bf92f3577b34da6a3ce929d0e0e4736"}} |
| MCP | an error tool result with the message, and {"code": "internal", "reference": "…"} as structured content (with IncludeErrorDetails, the default) |
| HTTP | 500 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(...).
Handlers
Handlers are Elarion's primary use-case unit — a request in, a Result out, with no transport concerns.
Modules
A module is an application boundary marked with [AppModule]. Its handlers, services, validation metadata, scheduled jobs, and event consumers are discovered and registered automatically, and feature-gated as one unit.