Elarion

Writing a transport

How to call into Elarion handlers from a new transport (gRPC, console, anything) with the full pipeline and all scoped services — including ICurrentUser — available.

Elarion ships three transports — HTTP [HttpEndpoint], JSON-RPC, and MCP — but they are all thin: each turns an inbound request into a handler invocation over the same transport-neutral seams. This page is for authoring a new one (gRPC, a CLI, a message-queue consumer, …).

A note on packages, since it trips people up: JSON-RPC and MCP are protocols (the JSON-RPC message format + dispatcher live in Elarion.JsonRpc, which has no ASP.NET dependency), while Elarion.AspNetCore is the host that binds them to the wire — it serves JSON-RPC over /rpc (and REST) on Kestrel. So Elarion.JsonRpc isn't "the HTTP server"; it's the protocol the host serves. A new transport is its own host: it may reuse a protocol package, or just invoke handlers directly (see below).

One bus, thin adapters

Underneath every dispatcher-based transport is a single transport-neutral named request/reply bus: the HandlerDispatcher in Elarion.Abstractions (namespace Elarion.Abstractions.Dispatch). It maps an operation name to a handler and invokes it through the full decorator pipeline — and it owns no serialization or wire format. JSON-RPC and MCP are thin adapters over that one bus:

  • JsonRpcDispatcher (Elarion.JsonRpc) is the JSON-RPC adapter; it serves only operations flagged HandlerTransports.JsonRpc.
  • McpDispatcher (Elarion.JsonRpc.Mcp) is the MCP adapter; it serves only operations flagged HandlerTransports.Mcp.

The generator builds one operation registry (RegisterHandlers) and both adapters resolve it, so a handler is "define once, choose surfaces via the Transports flag." (Previously there were two separate dispatcher instances; now it is one bus filtered by flag.) REST [HttpEndpoint] is a separate exposure model — route- and verb-based — and is not part of this bus.

A transport has four jobs:

  1. Capture identity — get the caller's ClaimsPrincipal (or build one) at the boundary.
  2. Create a seeded scope — a per-call DI scope with that identity (and any other per-call state) seeded.
  3. Invoke the handler — resolve IHandler<TRequest, Result<TResponse>> and call it; the full decorator pipeline (tracing, validation, authorization, resilience, caching) runs because that is what DI resolves.
  4. Map the result — translate Result<T> / AppError to your wire format.

The seams you build on

SeamPackageRole
IHandler<TRequest, TResponse>, Result<T>, AppError/ErrorKindElarion.Abstractionsthe handler contract and result/error model
HandlerDispatcherElarion.Abstractions (.Dispatch)the transport-neutral named request/reply bus (operation name → handler; no wire format)
ICurrentUser, IAuthorizerElarion.Abstractionsidentity + authorization (the decorator is auto-attached)
DispatchScopeContext, IDispatchScopeInitializer, IServiceProvider.CreateDispatchScope/SeedScopeElarion.Abstractions (.Dispatch)the per-call scope-seeding rail (transport-neutral)
HandlerInvoker.InvokeAsync<TRequest,TResponse>Elariontyped-direct "seed scope + invoke handler + dispose"
AddElarionClaimsCurrentUser / ClaimsPrincipalCurrentUserElarionclaims-based ICurrentUser, no ASP.NET dependency
IAppErrorTranslator<TError>Elarion.Abstractionsmap AppError to your wire error type

None of these require ASP.NET. A non-HTTP transport references Elarion (+ Elarion.Abstractions) only — not Elarion.AspNetCore.

The per-call scope rail (don't hand-roll)

Dispatcher-based transports run each call in a fresh DI child scope for isolation (e.g. a clean DbContext per call). A child scope does not inherit the parent scope's scoped instances, so scoped state like ICurrentUser must be seeded into it. Capture boundary state into a DispatchScopeContext and create the scope with CreateDispatchScope — it runs every registered IDispatchScopeInitializer:

var context = new DispatchScopeContext();
context.Set<ClaimsPrincipal>(principal);                 // captured at your boundary

await using var scope = rootProvider.CreateDispatchScope(context);
// resolve + invoke inside `scope`

SeedScope(context) does the same against an existing scope (the HTTP middleware uses it to seed the request scope, where minimal-API handlers run without a child scope).

Seeding ICurrentUser off HTTP

ICurrentUser is transport-neutral. Register the claims-based implementation in core — no ASP.NET — and seed it with the principal you captured:

services.AddElarionClaimsCurrentUser(o => o.UserIdClaimType = "sub");  // claim mapping is optional
services.AddElarionAuthorization();                                    // enables [Require*]

That registers a CurrentUserScopeInitializer; once you put a ClaimsPrincipal in the DispatchScopeContext, ICurrentUser resolves inside the handler and [Require*] authorization is enforced. Authentication itself is your job: validate the caller and produce the ClaimsPrincipal (from a gRPC ServerCallContext, a CLI flag, a JWT, …). On an ASP.NET host, AddElarionCurrentUser() + UseElarionCurrentUser() do this for you.

Invoking the handler

Two equivalent paths; both run the full pipeline:

Typed-direct — when you know the static handler type (recommended for gRPC/CLI):

var result = await HandlerInvoker.InvokeAsync<CreateClientCommand, ClientDto>(
    rootProvider, command, context, ct);

Name-based — when you route dynamically by operation name (what JSON-RPC and MCP do, through the shared HandlerDispatcher bus). Map operations onto the bus, then dispatch by name. The generated registration emits one registry.Map<TRequest, TResponse>("operation", HandlerTransports.X) per handler; in hand-written or test code, map a DI-resolved handler with dispatcher.Map<TRequest, TResponse>("operation") or a delegate with dispatcher.MapDelegate<TRequest, TResponse>("operation", fn):

dispatcher.Map<CreateClientCommand, ClientDto>("clients.create");          // DI-resolved handler
dispatcher.MapDelegate<PingRequest, PongResponse>("ping", (req, ct) => …);  // inline delegate

var response = await dispatcher.DispatchAsync("clients.create", command, scope.ServiceProvider, ct);

The JSON-RPC and MCP adapters are layered over this same bus — they decode the wire message, dispatch by operation name, then encode the result — so a name-based transport you author reuses the identical mapping.

Mapping Result and AppError

A handler returns Result<T> — success or an AppError whose ErrorKind your transport maps to its wire error. Implement IAppErrorTranslator<TError> for your wire type (the JSON-RPC transport ships and uses IAppErrorTranslator<RpcError>; register your own to override its codes). Reference mappings: AppErrorMapper (JSON-RPC codes, Elarion) and HttpAppErrorMapper (HTTP status, Elarion.AspNetCore).

ErrorKindJSON-RPCHTTPgRPC (suggested)CLI exit (suggested)
Validation-32602400InvalidArgument2
NotFound-32001404NotFound3
Conflict-32002409AlreadyExists4
Forbidden-32003403PermissionDenied5
Unauthorized-32005401Unauthenticated6
BusinessRule-32004422FailedPrecondition7
Internal-32603500Internal70

Authentication vs authorization

  • Authentication is the transport's job: produce the ClaimsPrincipal and seed it. Without it, ICurrentUser is anonymous and [Require*] handlers fail with Unauthorized.
  • Authorization is automatic: the generator attaches the AuthorizationDecorator to [Require*] handlers; it reads ICurrentUser and runs per scope. You don't wire it per transport.

Packaging

A non-HTTP transport references Elarion and Elarion.Abstractions. Core is transport-agnostic — it does not reference any transport package. The named bus (HandlerDispatcher) lives in Elarion.Abstractions, so a name-based transport needs no transport package; you reference Elarion.JsonRpc only if you serve the JSON-RPC or MCP wire adapters, and Elarion.AspNetCore only if you are co-hosted in ASP.NET. The scope rail and HandlerInvoker need no transport package (rail in Elarion.Abstractions, HandlerInvoker in Elarion).

Worked example: a console transport

A complete transport with no ASP.NET — the smallest proof the seams compose:

using System.Security.Claims;
using Elarion;                          // HandlerInvoker
using Elarion.Abstractions.Dispatch;    // DispatchScopeContext
using Elarion.Identity;                 // AddElarionClaimsCurrentUser
using Elarion.Authorization;            // AddElarionAuthorization
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddElarion(configuration);          // generated module services (handlers, validators, …)
services.AddElarionClaimsCurrentUser();      // ICurrentUser, no ASP.NET
services.AddElarionAuthorization();          // [Require*] enforcement
await using var provider = services.BuildServiceProvider();

// Authenticate however the CLI does (here: a --user flag) and build the principal.
var principal = new ClaimsPrincipal(new ClaimsIdentity(
    [new Claim("sub", userArg)], authenticationType: "cli"));

var context = new DispatchScopeContext();
context.Set<ClaimsPrincipal>(principal);

var result = await HandlerInvoker.InvokeAsync<CreateClientCommand, ClientDto>(
    provider, new CreateClientCommand(/* parsed from argv */), context, CancellationToken.None);

return result.IsSuccess
    ? 0
    : (int)MapExitCode(result.Error.Kind);   // your ErrorKind -> exit-code mapping

On this page