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 — the generated
AddElarion(configuration)contributes every enabled module'sGetJsonTypeInfoResolver()automatically (gated byModules:{Name}:Enabled). - Host extras — anything you add via
ConfigureElarionJson. - The framework context —
ElarionFrameworkJsonContext, always seeded last (see below).
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)) 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.