Elarion

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):

  1. Transport envelope contexts — AddElarionJsonRpc / AddElarionMcp insert their envelope context first.
  2. Module DTO contexts — a module's generated ConfigureDefaultServices contributes its own GetJsonTypeInfoResolver(), and the generated AddElarion(configuration) contributes every enabled module's again (gated by Modules:{Name}:Enabled). The same resolver instance composes into the chain once.
  3. Host extras — anything you add via ConfigureElarionJson.
  4. 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.

On this page