| name | shiny-serialization |
| description | Generate and configure Shiny.Extensions.Serialization for .NET - centralized AOT-safe JSON serializer with source-generated context registration and collection wrappers |
| auto_invoke | true |
| triggers | ["ISerializer","Shiny.Json","AddJsonSerialization","AddJsonContext","ConfigureJsonSerializer","ShinyJsonContext","ShinyJsonInclude","Shiny.Extensions.Serialization"] |
Shiny Serialization Skill
You are an expert in Shiny.Extensions.Serialization, a .NET library providing a centralized, AOT-safe JSON serializer with source-generated chaining of JsonSerializerContexts and collection wrappers.
When to Use This Skill
Invoke this skill when the user wants to:
- Replace ad-hoc
JsonSerializer.Serialize/Deserialize calls with a centralized ISerializer
- Make multiple library-owned
JsonSerializerContexts compose without manual services.AddJsonContext(...) calls
- Add AOT-safe
List<T>, T[], or IEnumerable<T> support for a type whose element-level JsonTypeInfo is already source-generated (the "inline [JsonConverter] works, but List<T> throws" case)
- Provide a static serializer accessor that's usable before DI exists (e.g. mobile cold-start through
Shiny.Stores)
Library Overview
Documentation: https://shinylib.net/serialization/
Repository: https://github.com/shinyorg/extensions
Package: Shiny.Extensions.Serialization
Shiny.Extensions.Serialization ships a runtime + Roslyn source generator. The runtime owns one shared JsonSerializerOptions whose TypeInfoResolverChain collects every contributed JsonSerializerContext and custom resolver. The generator emits [ModuleInitializer]s that auto-register user contexts at load time, and AOT-safe collection wrappers for opted-in types.
Core Pieces
| API | Purpose |
|---|
Shiny.Json.Default | The shared ISerializer. Self-bootstrapping. Returned to DI by AddJsonSerialization(). |
Shiny.Json.AddContext(JsonSerializerContext) | Register a context into the shared chain. Called by generated module inits. |
Shiny.Json.AddResolver(IJsonTypeInfoResolver) | Register any custom resolver into the chain. |
Shiny.Json.Configure(Action<JsonSerializerOptions>) | Mutate options before the serializer is first used. |
Shiny.Json.Reset() / Shiny.Json.CreateTestScope(...) | Test isolation — rebuild from the registered set on next access. |
services.AddJsonSerialization() | DI: register ISerializer resolving to Shiny.Json.Default. |
services.AddJsonContext(context) | DI shortcut: Json.AddContext + AddJsonSerialization. |
services.ConfigureJsonSerializer(cfg) | DI shortcut: Json.Configure + AddJsonSerialization. |
[Shiny.ShinyJsonContext] | On a user-declared JsonSerializerContext partial → generator emits a [ModuleInitializer] calling Shiny.Json.AddContext. |
[Shiny.ShinyJsonInclude] (type-level) | Opt a type into AOT-safe collection wrappers (List<T>, T[], IEnumerable<T>, IReadOnlyList<T>, IList<T>, ICollection<T>, IAsyncEnumerable<T>). |
[assembly: Shiny.ShinyJsonInclude(typeof(T))] | Same opt-in but for foreign types you don't own. |
Setup
services.AddJsonSerialization();
That's it for DI. The generator does the rest by reading [ShinyJsonContext]/[ShinyJsonInclude] markers in your source.
Auto-registering a hand-written context
The recommended path. Decorate any normal STJ source-generator context with [Shiny.ShinyJsonContext]:
using System.Text.Json.Serialization;
using Shiny;
[ShinyJsonContext]
[JsonSerializable(typeof(MyDto))]
[JsonSerializable(typeof(MyOtherDto))]
internal partial class MyAppJsonContext : JsonSerializerContext;
The Shiny generator emits (in a hidden file):
[ModuleInitializer]
internal static void Init() => global::Shiny.Json.AddContext(MyAppJsonContext.Default);
The context registers before Main runs, so the static Shiny.Json.Default and any DI-resolved ISerializer both see the types. You do not need services.AddJsonContext(MyAppJsonContext.Default) anywhere.
Adding collection support
If an element type has JsonTypeInfo (from some context — auto-registered, hand-written + AddJsonContext, or anything else in the chain) but List<T>/T[] throw "no metadata" under AOT, mark the element type:
[ShinyJsonInclude]
public partial class MyDto
{
public string Name { get; set; } = "";
}
Or, for a foreign type you don't own:
[assembly: Shiny.ShinyJsonInclude(typeof(SomeExternal.Vendor.Payload))]
The generator emits an IJsonTypeInfoResolver providing JsonTypeInfo<List<MyDto>>, JsonTypeInfo<MyDto[]>, JsonTypeInfo<IEnumerable<MyDto>>, JsonTypeInfo<IReadOnlyList<MyDto>>, JsonTypeInfo<IList<MyDto>>, JsonTypeInfo<ICollection<MyDto>>, and JsonTypeInfo<IAsyncEnumerable<MyDto>>. Each one lazy-resolves the element JsonTypeInfo<MyDto> from the chain at runtime — composes with any context that supplies it.
Composing with Inline JsonConverter<T>
This is the original mediator pain point. A type carrying [JsonConverter(typeof(MyConverter))] serializes fine in isolation, but List<MyType> throws under AOT because STJ has no JsonTypeInfo<List<MyType>>. Fix:
[ShinyJsonInclude]
[JsonConverter(typeof(BoxedIntConverter))]
public partial class BoxedInt { public int Value { get; set; } }
Now BoxedInt is serialized through the inline converter (bare number, not an object) AND List<BoxedInt>, BoxedInt[], etc. go through the generator-emitted collection wrappers. The wrappers lazy-fetch the element JsonTypeInfo which carries the inline converter — composition works.
DI Patterns
public class MyService(ISerializer serializer)
{
public string Save(MyDto d) => serializer.Serialize(d);
public MyDto Load(string j) => serializer.Deserialize<MyDto>(j);
}
services.ConfigureJsonSerializer(opts => opts.WriteIndented = false);
services.AddJsonContext(ThirdPartyJsonContext.Default);
Static Patterns
var json = Shiny.Json.Default.Serialize(new MyDto { Name = "x" });
var back = Shiny.Json.Default.Deserialize<MyDto>(json);
Shiny.Json.Configure(o => o.WriteIndented = false);
Shiny.Json.AddContext(SomeOtherContext.Default);
Diagnostics
| ID | Severity | Meaning |
|---|
SJSON002 | Error | [ShinyJsonInclude] applied to an unbound generic — use a closed constructed type. |
SJSON003 | Warning | [ShinyJsonInclude] was applied to type T, but no [JsonSerializable(typeof(T))] is declared on any JsonSerializerContext in this compilation. Generated collection wrappers will return null at runtime and serialization will throw — add the [JsonSerializable] to a registered context (or accept the warning if the element comes from a different assembly). |
Replacing the Serializer Wholesale
For non-JSON payloads (MessagePack, MemoryPack, etc.), swap the whole ISerializer:
services.AddSerializer(new MyMessagePackSerializer());
services.AddSerializer<MyConfiguredSerializer>();
host.Services.UseSerializer();
UseSerializer() mirrors UseShinyStores() — needed when the custom ISerializer takes DI dependencies.
Tests
[Collection("ShinyJson")]
public class MyTests
{
[Fact]
public void Foo()
{
using var scope = Shiny.Json.CreateTestScope(
extraResolvers: [ExtraContext.Default],
extraConfigure: o => o.WriteIndented = false
);
}
}
Tests touching Shiny.Json must share an xUnit collection ([Collection("ShinyJson")]) because the registry is process-static.
Best Practices
[ShinyJsonContext] over manual AddJsonContext — module-init registration avoids "I forgot to call services.AddJsonContext(...) in this code path" bugs (which is exactly the latent bug we just patched in Shiny Locations GPS).
[ShinyJsonInclude] per element type, not per collection — never use [assembly: ShinyJsonInclude(typeof(List<Foo>))]. The generator already emits all the standard collection shapes from the element type.
- Pair
[ShinyJsonInclude] with a [JsonSerializable(typeof(T))] in the same assembly to silence SJSON003. If the element is registered in another assembly, suppress or accept the warning.
- Don't put
[ShinyJsonContext] on a context owned by another package — decorate types in your own code, not someone else's. For 3rd-party contexts you don't own, fall back to services.AddJsonContext(ThirdPartyContext.Default).
Shiny.Json.Configure runs before first use only. Once Default has been touched, JsonSerializerOptions freezes. Configure calls after that mutate the same options instance and may throw on certain properties — call early or wrap in CreateTestScope.