Elarion

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 200 vs 201 vs 204 vs 409 means 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 busHandlerDispatcher (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

  1. Application handlers declare [Handler("module.action")] (the operation name is optional — see name inference).
  2. AppModuleDiscoveryGenerator (via [GenerateModuleBootstrapper]) emits the gated handler registration map (RegisterHandlers).
  3. AddElarionJsonRpc registers the JsonRpcDispatcher, which reads the canonical IElarionJsonSerialization options (the same serializer the whole app uses) and adapts the shared HandlerDispatcher registry to the JSON-RPC wire format.
  4. JsonRpcSchemaExporter (or the build-time package) exports rpc-schema.json from the registered dispatcher — including the request DTOs' DataAnnotations constraints as JSON Schema keywords (see Validation).
  5. elarion-jsonrpc-client-generator converts that schema into TypeScript types, constraint-aware Zod params/result schemas, and a typed fetch client that pre-validates request params.
  6. 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.Abstractions owns the transport-neutral HandlerDispatcher bus.
  • Elarion.JsonRpc owns the JSON-RPC adapter, runtime dispatch, telemetry, and schema export.
  • Elarion.AspNetCore owns HTTP endpoint mapping and ASP.NET Core transport behavior.
  • Elarion.AspNetCore.SchemaGeneration owns build-time schema export.
  • elarion-jsonrpc-client-generator owns 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

On this page