JSON-RPC
JSON-RPC is a first-class optional transport — mark a handler with [Handler] and Elarion takes it from dispatcher to typed TypeScript client.
JSON-RPC is a first-class, optional transport in Elarion. It lives outside the core framework package
(Elarion.JsonRpc plus Elarion.AspNetCore) so applications that don't need it pay nothing for it.
What makes it worthwhile is that the pipeline is end-to-end and typed, from a C# handler to a
generated TypeScript client — with no hand-written DTOs in between.
Why JSON-RPC?
Elarion's application model is a set of handlers — named use cases that take a request and return
a Result<T>. JSON-RPC is the transport that mirrors that model most directly: it exposes
operations, not resources. A method name like clients.create maps one-to-one to a handler. The
transport reflects your application instead of forcing a resource/CRUD shape on top of it.
That choice removes an entire category of API design work. With REST you decide, for every endpoint:
- the URL path and how to model nesting and collections,
- which HTTP verb fits (
GET/POST/PUT/PATCH/DELETE) and what that implies, - how to map an outcome onto a status code (and what
200vs201vs204vs409means here), - resource representations, content negotiation, and partial-update semantics.
With JSON-RPC, none of that is a decision. clients.create is a method that takes a Command and
returns a Result. Failures map once, centrally, from AppError.Kind
to a JSON-RPC error code — not per endpoint.
It feels like calling the handler directly
Because the generated TypeScript client is produced from the same schema the server dispatches, a frontend call is about as close as a network round-trip gets to invoking the C# handler — same parameter shape, same result type, validated by Zod:
// Frontend — the call mirrors the server-side GetClient handler
const client = await rpc.clients.get({ id })
// ^? { id: string; name: string } (typed from the C# Response)// Backend — the handler the call above resolves to
[Handler("clients.get")]
public sealed class GetClient(AppDbContext db)
: IHandler<GetClient.Query, Result<GetClient.Response>> { /* ... */ }The iteration loop is short: add a handler, mark it [Handler], regenerate the client, and call it
as a typed method. There is no route to design, no DTO to duplicate on the client, and no transport
plumbing to write.
Where it fits — and where it doesn't
This makes JSON-RPC an excellent fit for internal, first-party APIs where one team controls both the backend and the client (a web app talking to its own backend, a BFF, service-to-service calls). You trade REST's broad-ecosystem conventions for speed and a contract that tracks your code.
Reach for REST/HTTP instead when you have third-party or public consumers, need HTTP caching / CDN semantics, resourceful URLs, or the wider tooling ecosystem around them. Elarion supports this too: JSON-RPC is optional and outside the core package, and modules can expose ordinary Minimal API endpoints alongside — or instead of — JSON-RPC methods. You are not locked into one transport.
One bus, thin adapters
JSON-RPC is not a self-contained dispatcher. The transport-neutral core is a single named
request/reply bus — HandlerDispatcher (in Elarion.Abstractions, namespace
Elarion.Abstractions.Dispatch) — that maps an operation name to a handler and invokes it through the
full decorator pipeline. It owns no serialization or wire format.
JsonRpcDispatcher (in Elarion.JsonRpc) is the JSON-RPC adapter over that bus: it adds the
JSON-RPC envelope and serialization and serves only the operations flagged
HandlerTransports.JsonRpc. MCP is a second adapter over the same bus. The generator builds one
registry of handlers (RegisterHandlers) and both adapters resolve it — so a handler is define once,
choose surfaces via the Transports flag, never registered separately per transport.
The end-to-end pipeline
- Application handlers declare
[Handler("module.action")](the operation name is optional — see name inference). AppModuleDiscoveryGenerator(via[GenerateModuleBootstrapper]) emits the gated handler registration map (RegisterHandlers).AddElarionJsonRpcregisters theJsonRpcDispatcher, which reads the canonicalIElarionJsonSerializationoptions (the same serializer the whole app uses) and adapts the sharedHandlerDispatcherregistry to the JSON-RPC wire format.JsonRpcSchemaExporter(or the build-time package) exportsrpc-schema.jsonfrom the registered dispatcher — including the request DTOs' DataAnnotations constraints as JSON Schema keywords (see Validation).elarion-jsonrpc-client-generatorconverts that schema into TypeScript types, constraint-aware Zod params/result schemas, and a typed fetch client that pre-validates request params.- The frontend uses the generated client directly or wraps it in framework-specific helpers.
Each stage has a single owner, which keeps the reusable contract small:
Elarion.Abstractionsowns the transport-neutralHandlerDispatcherbus.Elarion.JsonRpcowns the JSON-RPC adapter, runtime dispatch, telemetry, and schema export.Elarion.AspNetCoreowns HTTP endpoint mapping and ASP.NET Core transport behavior.Elarion.AspNetCore.SchemaGenerationowns build-time schema export.elarion-jsonrpc-client-generatorowns schema-to-TypeScript generation.- Applications own server-function/auth/cache adapters around the generated client.
Marking a handler
Add [Handler] to a handler that follows the conventional shape:
[Handler("clients.create")]
public sealed class CreateClient
: IHandler<CreateClient.Command, Result<CreateClient.Response>> {
public sealed record Command(string Name);
public sealed record Response(Guid Id);
public ValueTask<Result<Response>> HandleAsync(Command command, CancellationToken ct) {
// ...
}
}The handler generator reads the request and response types from the
IHandler<TRequest, Result<TResponse>> implementation. Nesting the DTOs inside the handler (as above)
is a tidy convention, not a requirement — request and response types may be nested or top-level, and
their names carry no semantic weight.
The operation name is optional
The operation name is optional — [Handler] alone is valid. When omitted, the name is inferred by
convention as {module}.{operation}, where operation is the handler type name with a trailing
Handler/Command/Query/Request suffix removed, then camelCased. So in a Clients module,
CreateClient becomes clients.createClient:
[Handler] // inferred: clients.createClient
public sealed class CreateClient
: IHandler<CreateClient.Command, Result<CreateClient.Response>> { /* ... */ }An explicit name is recommended for stable public or wire contracts, since a type rename would otherwise change the operation name. Use the inferred name for internal handlers where the convenience is worth more than name stability.
The generated map registers each operation on the shared HandlerDispatcher registry with its
transport flags:
registry.Map<CreateClient.Command, CreateClient.Response>("clients.create", HandlerTransports.JsonRpc);In hand-written or test code you map directly onto a HandlerDispatcher with the convenience overloads
— dispatcher.Map<TRequest, TResponse>("name") resolves the handler from DI, while
dispatcher.MapDelegate<TRequest, TResponse>("name", fn) maps an inline delegate:
dispatcher.Map<CreateClient.Command, CreateClient.Response>("clients.create");
dispatcher.MapDelegate<Ping.Request, Ping.Response>("diagnostics.ping", (req, ct) => /* ... */);File payloads
ElarionFile — the in-memory small-file payload shared with the
HTTP transport — is a
first-class citizen here too, both directions. On every JSON surface it rides a fixed base64 envelope
(canonical, independent of the host's naming policy):
{ "contentType": "text/csv", "fileName": "clients.csv", "data": "aWQ7bmFtZQ==" }A Result<ElarionFile> handler returns the envelope as its result; an ElarionFile property in a
request DTO is an upload — the client sends the same envelope and the handler receives decoded bytes.
No registration needed: the type is seeded into the canonical serializer's framework context
(ADR-0039).
The exported schema marks the envelope with x-elarion-file, and the
generated TypeScript client turns that into a native
File — callers never see base64:
// Upload: pass a File (e.g. from an <input type="file">); the client encodes the envelope.
const receipt = await rpc.documents.import({ container: 'invoices', file: input.files[0] })
// Download: the result is a real File, ready for URL.createObjectURL or a save dialog.
const exported: File = await rpc.exports.get({ list: 'clients' })The trade against HTTP is buffering: base64 adds ~33% and the payload sits in memory on both ends. That is
the deliberate scope of ElarionFile — small files, up to a few megabytes. Past that, switch to the
staged-blob tier: upload into the pending area and pass the handler a
blob reference; export as a pending blob and return its reference for a streamed download.
Errors are mapped by the host
Each host provides the bridge from Elarion application results to JSON-RPC errors, so different
applications can map domain failures to transport codes in their own way. Handlers stay
transport-agnostic and return Result<T>; the host translates
AppError.Kind into JSON-RPC error codes.
In this section
OpenAPI
Bring the [HttpEndpoint] REST transport to schema/contract parity with JSON-RPC — an OpenAPI document, module tags, clean operation ids, ProblemDetails, and the Idempotency-Key contract, from Microsoft.AspNetCore.OpenApi.
Schema generation
Export rpc-schema.json automatically during dotnet build with the Elarion.AspNetCore.SchemaGeneration MSBuild package.