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 flaggedHandlerTransports.JsonRpc.McpDispatcher(Elarion.JsonRpc.Mcp) is the MCP adapter; it serves only operations flaggedHandlerTransports.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:
- Capture identity — get the caller's
ClaimsPrincipal(or build one) at the boundary. - Create a seeded scope — a per-call DI scope with that identity (and any other per-call state) seeded.
- 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. - Map the result — translate
Result<T>/AppErrorto your wire format.
The seams you build on
| Seam | Package | Role |
|---|---|---|
IHandler<TRequest, TResponse>, Result<T>, AppError/ErrorKind | Elarion.Abstractions | the handler contract and result/error model |
HandlerDispatcher | Elarion.Abstractions (.Dispatch) | the transport-neutral named request/reply bus (operation name → handler; no wire format) |
ICurrentUser, IAuthorizer | Elarion.Abstractions | identity + authorization (the decorator is auto-attached) |
DispatchScopeContext, IDispatchScopeInitializer, IServiceProvider.CreateDispatchScope/SeedScope | Elarion.Abstractions (.Dispatch) | the per-call scope-seeding rail (transport-neutral) |
HandlerInvoker.InvokeAsync<TRequest,TResponse> | Elarion | typed-direct "seed scope + invoke handler + dispose" |
AddElarionClaimsCurrentUser / ClaimsPrincipalCurrentUser | Elarion | claims-based ICurrentUser, no ASP.NET dependency |
IAppErrorTranslator<TError> | Elarion.Abstractions | map 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).
ErrorKind | JSON-RPC | HTTP | gRPC (suggested) | CLI exit (suggested) |
|---|---|---|---|---|
Validation | -32602 | 400 | InvalidArgument | 2 |
NotFound | -32001 | 404 | NotFound | 3 |
Conflict | -32002 | 409 | AlreadyExists | 4 |
Forbidden | -32003 | 403 | PermissionDenied | 5 |
Unauthorized | -32005 | 401 | Unauthenticated | 6 |
BusinessRule | -32004 | 422 | FailedPrecondition | 7 |
Internal | -32603 | 500 | Internal | 70 |
Authentication vs authorization
- Authentication is the transport's job: produce the
ClaimsPrincipaland seed it. Without it,ICurrentUseris anonymous and[Require*]handlers fail withUnauthorized. - Authorization is automatic: the generator attaches the
AuthorizationDecoratorto[Require*]handlers; it readsICurrentUserand 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 mappingCross-module communication
Direct, synchronous module-to-module calls go through a published [ModuleContract]; an analyzer keeps modules honest, and an optional generated typed in-process API lets a module call its own handlers by name.
Solution structure
Where entities, modules, and schema configuration belong relative to the module-boundary rule — keep entities in a shared-kernel namespace, treat configuration as part of the shared data layer (not feature-owned), and graduate to bounded contexts only when they earn their keep.