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 contextsAddElarionJsonRpc / AddElarionMcp insert their envelope context first.
  2. Module DTO contexts — the generated AddElarion(configuration) contributes every enabled module's GetJsonTypeInfoResolver() automatically (gated by Modules:{Name}:Enabled).
  3. Host extras — anything you add via ConfigureElarionJson.
  4. The framework contextElarionFrameworkJsonContext, 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.

On this page