Overview
JSON-RPC is a first-class optional transport — mark a handler with [RpcMethod] 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
[RpcMethod("clients.get")]
public sealed class GetClient(IAppDbContext db)
: IHandler<GetClient.Query, Result<GetClient.Response>> { /* ... */ }The iteration loop is short: add a handler, mark it [RpcMethod], 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.
The end-to-end pipeline
- Application handlers declare
[RpcMethod("module.action")]. AppModuleDiscoveryGenerator(via[GenerateModuleBootstrapper]) emits the gated dispatcher registration map.- The host configures a
JsonRpcDispatcherwith the sameJsonSerializerOptionsused at runtime. JsonRpcSchemaExporter(or the build-time package) exportsrpc-schema.jsonfrom the registered dispatcher.elarion-jsonrpc-client-generatorconverts that schema into TypeScript types, Zod result schemas, and a typed fetch client.- 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.JsonRpcowns 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 [RpcMethod] to a handler that follows the conventional shape:
[RpcMethod("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 RPC generator expects:
- a nested request type named
CommandorQuery, - a nested response type named
Response, - an
IHandler<TRequest, Result<TResponse>>implementation.
The generated map emits typed dispatcher calls:
dispatcher.MapHandler<CreateClient.Command, CreateClient.Response>("clients.create");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.