Serialization
One canonical JsonSerializerOptions that every Elarion subsystem reads, composed from per-module source-generated JSON contexts for AOT-friendly serialization.
Elarion uses System.Text.Json source-generated metadata so serialization is AOT-friendly and follows
module boundaries. Each module contributes its own JsonSerializerContext, and the framework composes
them into one canonical JsonSerializerOptions that every subsystem — JSON-RPC, MCP, idempotency,
caching, the outbox, and settings — reads through the IElarionJsonSerialization accessor. You configure
the JSON type context once; you no longer build or thread JsonSerializerOptions into each subsystem.
See ADR-0023 for the design.
Per-module JSON contexts
Each module declares a source-generated context for its request/response and nested DTO types:
using System.Text.Json.Serialization;
namespace MyApp.Application.Modules.Clients;
[JsonSerializable(typeof(GetClient.Query))]
[JsonSerializable(typeof(GetClient.Response))]
[JsonSerializable(typeof(CreateClient.Command))]
[JsonSerializable(typeof(CreateClient.Response))]
public sealed partial class ClientsJsonContext : JsonSerializerContext;The module exposes it through GetJsonTypeInfoResolver():
public static IJsonTypeInfoResolver GetJsonTypeInfoResolver() => ClientsJsonContext.Default;The canonical serializer
IElarionJsonSerialization (in Elarion.Abstractions.Serialization) is the accessor for the one
framework-wide JsonSerializerOptions. It materializes the options once from a composed
ElarionJsonOptions and freezes them (MakeReadOnly()) on first use, then exposes Options,
GetTypeInfo<T>(), and GetTypeInfo(Type). Subsystems depend on this accessor — Elarion deliberately
registers no bare JsonSerializerOptions in DI, so it never collides with a host's own
(ASP.NET Core / MVC) registration.
The options are composed from contributions, in order (first-match-wins):
- Transport envelope contexts —
AddElarionJsonRpc/AddElarionMcpinsert their envelope context first. - Module DTO contexts — a module's generated
ConfigureDefaultServicescontributes its ownGetJsonTypeInfoResolver(), and the generatedAddElarion(configuration)contributes every enabled module's again (gated byModules:{Name}:Enabled). The same resolver instance composes into the chain once. - Host extras — anything you add via
ConfigureElarionJson. - The framework context —
ElarionFrameworkJsonContext, seeded automatically (see below). It is appended last unless a host contributed that same instance earlier, in which case it stays where the host put it — the chain composes each resolver instance once.
Because the module's context rides along with its own registration, composing a module is self-contained: a
test host (or any assembly that cannot reference the host's generated ElarionBootstrapper without inverting the
dependency) gets the module's JSON metadata by calling that module's registration — no hand-written
ConfigureElarionJson(o => o.TypeInfoResolvers.Add(MyModule.GetJsonTypeInfoResolver())). Contribute a stable
resolver instance (a generated context's .Default singleton is one) so the duplicate collapses; a freshly
constructed resolver per contribution is a distinct instance and lands in the chain repeatedly.
The transport serializes with these composed options — not with any JsonSerializerOptions a host
hands to ASP.NET Core / MVC. So a JSON-RPC error is written under the resolver chain above (JsonRpcJsonContext
envelope + the module contexts + the framework error context), independent of the host's own JSON setup.
Register your DTO/error-data types through ConfigureElarionJson (or a module context), not on a host-built
JsonSerializerOptions.
Framework types (ElarionFrameworkJsonContext)
Some framework-owned types must be serializable but are not statically reachable from your
[JsonSerializable] roots, so no module/host context would ever register them. The canonical case is a payload
behind a polymorphic object slot: AppError.Data is typed object, so when a failed Result is serialized
(for example the JSON-RPC error object) System.Text.Json dispatches on the runtime type and needs a
JsonTypeInfo for each concrete payload — which the source generator never pulls into a module context because
the object breaks static reachability.
ElarionFrameworkJsonContext holds these types (currently ValidationErrorData, produced by
AppError.Validation(message, errors), plus the idempotency StoredResult replay envelope, the AppError it
carries, and ElarionFile) and is seeded into the canonical resolver chain automatically — so a
validation failure serializes under source generation with no per-app registration and no reflection, even on
an AOT-strict host. Payloads you attach yourself (via AppError.Validation(message, data) /
AppError.BusinessRule(...)) are your own types, so they still belong in your module/host context.
So a typical host composes nothing by hand:
builder.Services.AddElarion(builder.Configuration); // contributes module contexts + registers the accessor
builder.Services.AddElarionJsonRpc(ElarionBootstrapper.RegisterHandlers);
builder.Services.AddElarionMcp(
builder.Configuration.GetMcpMetadata(),
ElarionBootstrapper.RegisterHandlers,
o => o.ServerName = "MyApp");Customizing the options
Use ConfigureElarionJson to change naming/knobs or add an extra resolver — contributions accumulate
across calls and layers:
builder.Services.ConfigureElarionJson(o => {
o.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; // default is CamelCase
o.TypeInfoResolvers.Add(SomeExtraContext.Default);
});ElarionJsonOptions also exposes PropertyNameCaseInsensitive, DefaultIgnoreCondition, a
PostConfigure escape hatch (converters, a custom encoder), and EnableReflectionFallback (below).
Resolution is first-match-wins, and transport envelope contexts insert themselves at the front of
TypeInfoResolvers when they register — so an entry the host adds there can never beat them for a type
they also cover. To override how such a type serializes, use OverrideTypeInfoResolvers, which is
composed ahead of every contributed resolver:
builder.Services.ConfigureElarionJson(o =>
o.OverrideTypeInfoResolvers.Add(MyOverridesJsonContext.Default));Schema export reads the same accessor, so the exported JSON-RPC schema matches exactly what the server serializes.
AOT considerations
The canonical options are AOT-strict by default: no reflection-based DefaultJsonTypeInfoResolver
is added, matching the project-wide JsonSerializerIsReflectionEnabledByDefault=false. A type missing
from every source-generated context therefore throws at runtime, surfacing a forgotten
[JsonSerializable(...)] instead of silently reflecting (and being trimmed away under AOT).
Register every command, query, response, and nested DTO type explicitly with
[JsonSerializable(...)]. If you knowingly want a reflection fallback for development, opt in with
ConfigureElarionJson(o => o.EnableReflectionFallback = true) — but leave it off for AOT/trimmed builds.