csharp-json-source-generation
Skill2 starsChanged 42 days agoMITView on GitHub
Use when writing or reviewing C#/.NET code that serializes or deserializes JSON — declaring a System.Text.Json JsonSerializerContext, choosing GenerationMode and naming/ignore options, wiring a context into JsonSerializerOptions/HttpClient/Refit, polymorphism and enums-as-strings with source gen, and catching accidental reflection-based serialization before it breaks trimming or Native AOT.
In this repository56 other files it gives its agents.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- benchmarkingSkill
- commit-messagesSkill
- csharp-15-syntaxSkill
- csharp-aot-trimmingSkill
- csharp-api-designSkill
- csharp-assembly-inspectionSkill
- csharp-asyncSkill
- csharp-collections-modernSkill
- csharp-concurrency-primitivesSkill
- csharp-data-layoutSkill
- csharp-debuggingSkill
- csharp-discardsSkill
- csharp-disposable-patternsSkill
- csharp-docsSkill
- csharp-error-handlingSkill
- csharp-extension-membersSkill
- csharp-generics-modernSkill
- csharp-guard-clausesSkill
- csharp-language-versionsSkill
- csharp-logging-diagnosticsSkill
- csharp-lspSkill
- csharp-modern-typesSkill
- csharp-nullabilitySkill
- csharp-nullable-migrationSkill
- csharp-nunitSkill
- csharp-pattern-matchingSkill
- csharp-performanceSkill
- csharp-polyfillsSkill
- csharp-source-generatorsSkill
- csharp-static-lambdasSkill
- csharp-string-handlingSkill
- csharp-tunitSkill
- csharp-verificationSkill
- dispatching-subagentsSkill
- dotnet-11-librariesSkill
- dotnet-analyzersSkill
- dotnet-central-package-managementSkill
- dotnet-file-based-appsSkill
- dotnet-msbuild-usingsSkill
- dotnet-multi-targetingSkill
- dotnet-project-hygieneSkill
- dotnet-runSkill
- dotnet-runtime-asyncSkill
- dotnet-slnxSkill
- dotnet-test-platformsSkill
- executing-plansSkill
- performance-bounded-deduplicationSkill
- roslyn-analyzer-performanceSkill
- roslyn-discoverySkill
- roslyn-duplicate-detectionSkill
- roslyn-guard-rewritersSkill
- roslyn-rewritersSkill
- subagent-driven-developmentSkill
- tddSkill
The file
We split this skill into the rules it gives an agent
4 rules. About 0 are common, 0 are distinctive, and 4 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈0
Distinctive · estimate
This author's own choices. Worth a look.
4
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 51
Prefer one context per feature or bounded area (an OrdersJsonContext, a PaymentsJsonContext) rather than one giant context for the whole …
- Very specific: line 77
Don't hand-pick Serialization-only unless you've profiled and confirmed the type qualifies and you never deserialize it through this …
- Very specific: line 82
Prefer the JsonTypeInfo<T> overloads — they're the only form the analyzer treats as fully AOT-safe:
- Very specific: line 229
JsonNode/JsonDocument: when you only need to poke at part of a payload (read one field, don't know or care about the full shape), reach …
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
System.Text.Json source generation
Reflection-based JsonSerializer.Serialize<T>(value) inspects T's properties at runtime. That
inspection needs metadata trimming can remove and, on Native AOT, code generation the runtime
can't do. A JsonSerializerContext moves that inspection to compile time: the compiler emits the
property list, converters, and read/write code for each type you register, so the serializer never
reflects. See csharp-aot-trimming for how this fits the broader reflection-elimination story, and
csharp-source-generators if you're writing a generator rather than consuming one.
Declare a context
internal sealed record OrderLine(string Sku, int Quantity, decimal UnitPrice);
internal sealed record Order(int Id, string Customer, OrderStatus Status, List<OrderLine> Lines);
internal enum OrderStatus { Pending, Shipped, Delivered }
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
[JsonSerializable(typeof(Order))]
[JsonSerializable(typeof(List<Order>))]
internal partial class AppJson : JsonSerializerContext;- One
[JsonSerializable]per root type you actually serialize or deserialize, including containers:OrderandList<Order>are separate roots, and registering one does not register the other. Register every closed generic shape a method actually sends or reads (Order[],List<Order>,Order?, …). - A property typed
objectneeds its runtime type registered too — the generator can't infer what you'll assign at runtime. partial class Foo : JsonSerializerContext— the compiler fills in the body. Keep itinternalunless another assembly needs the generatedTypeInfoproperties directly.- Always set
JsonSourceGenerationOptions(JsonSerializerDefaults.Web)(or your own naming policy) on every context. A context with no options expects exact-case PascalCase property names. A service sending{"id":5,"customer":"Ada"}into a plain context silently deserializes into an object of default values — no exception, wrong data. AnOrder(int Id, string Customer)read through an unconfigured context against{"id":2,"name":"Grace"}-shaped JSON producesId=0, Name=null; addingJsonSerializerDefaults.Webfixes it. This is the single most common source-generation bug in the wild. - Other useful
[JsonSourceGenerationOptions]members:PropertyNamingPolicy(or theJsonSerializerDefaults.Webshortcut),DefaultIgnoreCondition(e.g.JsonIgnoreCondition.WhenWritingNull),UseStringEnumConverter(blanket enum-as-string policy),WriteIndented,Convertersvia[JsonConverter]on the type/property instead.
One context per assembly/feature vs many
Prefer one context per feature or bounded area (an OrdersJsonContext, a PaymentsJsonContext)
rather than one giant context for the whole app, and rather than one context per type. A context is
just a partial class — grouping related root types keeps [JsonSerializable] lists reviewable and
keeps unrelated features from needing a rebuild when one type's shape changes. Combine multiple
contexts at the JsonSerializerOptions level (below) rather than duplicating [JsonSerializable]
entries across contexts.
GenerationMode: metadata vs fast-path
[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Serialization)]
[JsonSerializable(typeof(Order))]
internal partial class SerializeOnlyJson : JsonSerializerContext;Metadata(default component) generatesJsonTypeInfothe general serializer engine drives — supports every feature (streaming, polymorphism,JsonTypeInfo<T>introspection).Serialization("fast path") additionally emits a directUtf8JsonWriter-drivingSerializemethod for types simple enough to qualify (no custom converters, no unsupported converters on members, not indented-with-non-default-writer in every case) — faster, less indirection, but serialization-only and not used for async serialization of large/streamed payloads (it falls back to metadata mode there; small payloads that fit the write buffer still use fast path).Default(the type-level and context-level default) generates both; the runtime picks fast path automatically for serialize calls when eligible. SetGenerationModeper type via[JsonSerializable(typeof(T), GenerationMode = ...)]when only one type in a context needs the restriction; it overrides the context-level attribute for that type.- Don't hand-pick
Serialization-only unless you've profiled and confirmed the type qualifies and you never deserialize it through this context —Metadatais the safe default.
Using the context
Prefer the JsonTypeInfo<T> overloads — they're the only form the analyzer treats as fully
AOT-safe:
var json = JsonSerializer.Serialize(order, AppJson.Default.Order);
var back = JsonSerializer.Deserialize(json, AppJson.Default.Order);JsonSerializer.Serialize(order, options) — even with options.TypeInfoResolver set to a context —
still produces IL2026/IL3050 warnings, because that overload is annotated
[RequiresUnreferencedCode]/[RequiresDynamicCode] unconditionally (the analyzer can't see through
options to know a source-generated resolver is attached). Serialize(order, AppJson.Default.Order)
produces zero warnings. Use the JsonTypeInfo<T> form at call sites where you can; fall back to
options only when the type is chosen dynamically.
Wiring a context into JsonSerializerOptions
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
TypeInfoResolver = AppJson.Default,
};Build JsonSerializerOptions once and reuse it — constructing a new instance per call throws
away the cached property/converter metadata and, worse, invites someone to reach for the
reflection-based constructor overload out of habit. Call options.MakeReadOnly() after
configuring it: it locks the instance — a WriteIndented mutation after MakeReadOnly() throws
InvalidOperationException — so a later contributor can't silently change shared behavior, and
it's required before the options can be reused as a TypeInfoResolver source safely across
threads.
Combining multiple contexts
var options = new JsonSerializerOptions
{
TypeInfoResolver = JsonTypeInfoResolver.Combine(OrdersJson.Default, PaymentsJson.Default),
};
options.TypeInfoResolverChain.Add(SharedJson.Default); // append
options.TypeInfoResolverChain.Insert(0, PriorityJson.Default); // prependTypeInfoResolverChain and TypeInfoResolver are two views of the same list — editing one is
reflected by the other. Resolvers are queried in order; the first non-null result wins, so put the
more specific/override context earlier.
Catch accidental reflection
<PropertyGroup>
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
</PropertyGroup>Set this in any project meant to stay AOT/trim-safe, even before you publish trimmed — it turns a
forgotten reflection-based Serialize/Deserialize call into an immediate
InvalidOperationException in ordinary CI/test runs instead of a silent trap that only surfaces
after a trimmed publish. With the property set, a bare JsonSerializer.Serialize(new Widget(1))
(no context, no resolver) throws "Reflection-based serialization has been disabled for this
application" at the first call, on a normal dotnet run — no publish step needed.
PublishTrimmed/PublishAot set this to false automatically if you don't set it yourself: an
IsAotCompatible=true project publishing -p:PublishAot=true runs with
JsonSerializer.IsReflectionEnabledByDefault == false and produces correct output with no IL
warnings at build.
IsAotCompatible=true (see csharp-aot-trimming) is what surfaces IL2026/IL3050 at build time
for any reflection-based JSON call still in the project — treat those as the thing to fix, not
suppress; the fix is almost always "pass the JsonTypeInfo<T>/context instead."
HttpClient
using var content = JsonContent.Create(order, AppJson.Default.Order);
var order = await client.GetFromJsonAsync("orders/5", AppJson.Default.Order);Both GetFromJsonAsync/PostAsJsonAsync/JsonContent.Create take a JsonTypeInfo<T> or a
JsonSerializerContext overload — use them instead of the generic <T> overloads so the call is
AOT-safe. GetFromJsonAsync(url, Context.Default.T) round-trips correctly; the same call through a
context missing JsonSerializerDefaults.Web silently produces a zeroed-out record (the naming trap
above, reproduced over HTTP).
Refit
Refit's generated clients (RestService.ForGenerated<T>) take a context the same way:
IOrdersApi api = RestService.ForGenerated<IOrdersApi>(client, OrdersJson.Default);- Refit does not write
[JsonSerializable]entries for you — its client generator and STJ's source generator run independently and don't see each other's output. List every request/reply type (and list/array shapes) on your context yourself. - Passing a context turns reflection-based JSON off for that client: a type the context doesn't
list throws
NotSupportedExceptionnaming the missing type — fix by adding it, not by reaching forallowReflectionFallback: true(that flag exists for staged migration of a large app and is explicitly not trim/AOT-safe). - To combine your own
JsonSerializerOptions(naming policy, custom converters) with a context:RefitSettings settings = new(new SystemTextJsonContentSerializer(options)); settings.UseJsonContext(context);orRestService.ForGenerated<T>(client, context, settings)— your settings win; the context only supplies metadata for types your options' resolver doesn't already know.SystemTextJsonContentSerializer.ForContext(context)builds a serializer straight from a context with no reflection fallback. - A Refit interface method can take a
JsonTypeInfo<T>parameter (matched byTagainst the body or reply type) so a client built without a context still gets per-call generated metadata; Refit's generator enforces this at build time (RF014if the parameter can't be matched). - Refit's own documentation (Serialization > JSON, and AOT) on the ReactiveUI website has the full walkthrough.
Polymorphism, enums, records
[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")]
[JsonDerivedType(typeof(CourierShipment), "courier")]
[JsonDerivedType(typeof(PickupShipment), "pickup")]
internal abstract record Shipment(string Reference);
internal sealed record CourierShipment(string Reference, string TrackingNumber) : Shipment(Reference);
internal sealed record PickupShipment(string Reference, string Store) : Shipment(Reference);Declare polymorphism with [JsonPolymorphic]/[JsonDerivedType] attributes, not a runtime contract
modifier (DefaultJsonTypeInfoResolver.Modifiers) — a contract modifier relies on
DefaultJsonTypeInfoResolver, which is reflection-based and unsafe under Native AOT. Serializing a
CourierShipment through the base Shipment root writes the kind discriminator and deserializes
back to the concrete CourierShipment type via JsonSerializer.Deserialize(json,
AppJson.Default.Shipment).
- Enums as strings: either
[JsonConverter(typeof(JsonStringEnumConverter<TEnum>))]on the enum (use the generic form — the non-genericJsonStringEnumConverterisn't Native AOT-supported), orUseStringEnumConverter = truein[JsonSourceGenerationOptions]for a blanket policy across the context. Custom names per member:[JsonStringEnumMemberName](.NET 9+). - Records/init/required: source generation fully supports positional records,
initsetters, andrequiredmembers — arecordwithrequired int X { get; init; }members serializes and deserializes correctly through a generated context with no extra attributes needed. requiredmeans "must be in the JSON". Deserialization throwsJsonExceptionwhen arequiredproperty is missing from the document, even if its type is nullable. Don't addrequiredto satisfy the nullable compiler: when in doubt, make reference-typed members nullable and handle missing values where each type is consumed. Conversely, a non-nullablestringquietly accepts a JSONnullunless you setRespectNullableAnnotations = true(on[JsonSourceGenerationOptions]orJsonSerializerOptions), which makes it throw. Seecsharp-nullable-migration.- Custom converters: a
JsonConverter<T>you write by hand works unchanged under source generation — apply it with[JsonConverter]on the type/property, or add it toJsonSourceGenerationOptions.Converters/the options'Converterslist. The generator emits code that calls your converter; it doesn't need to understand its internals. JsonNode/JsonDocument: when you only need to poke at part of a payload (read one field, don't know or care about the full shape), reach forJsonNode/JsonDocumentinstead of a type — neither needs aJsonSerializerContextentry, and both stay reflection-free.- Anonymous types can't be source-generated — there's no type declaration for
[JsonSerializable]to point at. Declare a real (eveninternal sealed record) type instead. This also matters fordotnet run app.csfile-based apps: they run with reflection-based serialization disabled by default (seedotnet-file-based-apps), so declare the record(s) and the context in the same file rather than reaching for an anonymous object.
.NET 11 additions
JsonNamingPolicy.PascalCase— a new built-in policy alongside camelCase/snake_case/kebab-case.JsonNamingPolicy.PascalCase.ConvertName("helloWorld")→"HelloWorld".- Per-member naming policy overrides via
[JsonNamingPolicy]on an individual property/class, independent of the context/options-levelPropertyNamingPolicy. JsonSerializerOptions.GetTypeInfo<T>()/TryGetTypeInfo<T>(out JsonTypeInfo<T>)— generic overloads that returnJsonTypeInfo<T>directly, no manual downcast from the non-genericGetTypeInfo(Type). Works against aCombined resolver.- Union type serialization (
JsonTypeInfoKind.Union,JsonUnionAttribute,JsonUnionTypeStructuralClassifier) for C# union types (a C# 15 preview language feature) — both the reflection-based serializer and source generator support it; union types require the preview language feature enabled. JsonSerializerOptions.InferClosedTypePolymorphism— infers polymorphic metadata for a closed C# hierarchy without requiring[JsonDerivedType]on every case; explicit attributes still win when present.
These are additive — everything earlier in this skill is unchanged on .NET 11.
Other reflection → source-gen swaps
| Reflection-based API | Source-generated replacement | Skill |
Regex compiled/cached at runtime | [GeneratedRegex] | csharp-aot-trimming |
ILogger.Log(LogLevel, ...) boxing overloads | [LoggerMessage] | csharp-logging-diagnostics, csharp-aot-trimming |
[DllImport] / Marshal.GetDelegateForFunctionPointer | [LibraryImport] | csharp-aot-trimming |
IConfiguration.Get<T>()/Bind via reflection | Microsoft.Extensions.Configuration.Binder.SourceGeneration (EnableConfigurationBindingGenerator) | csharp-aot-trimming |
Hand-rolled IValidatableObject/reflection-based options validation | [OptionsValidator] | dotnet-analyzers |
| Ad-hoc reflection over a type's shape | A custom IIncrementalGenerator | csharp-source-generators |
Review checklist
- Every root type actually sent/received is on a context, including collection/array/generic shapes — not just the element type
- Context has
JsonSourceGenerationOptions(JsonSerializerDefaults.Web)(or an explicit naming policy) — never an unconfigured context talking to camelCase JSON - Call sites use
Context.Default.T(JsonTypeInfo<T>) where the type is statically known;options.TypeInfoResolveronly where it must be chosen dynamically JsonSerializerOptionsinstances are built once (static readonly) andMakeReadOnly()d, never constructed per callJsonSerializerIsReflectionEnabledByDefault=falseset in any project meant to stay trim/AOT-safe, so a stray reflection call fails loudly in CI, not silently after publish- Polymorphism declared with
[JsonPolymorphic]/[JsonDerivedType]attributes, not aDefaultJsonTypeInfoResolvercontract modifier, if Native AOT is a target - Enums-as-strings uses the generic
JsonStringEnumConverter<TEnum>(orUseStringEnumConverter), never the non-genericJsonStringEnumConverter - No anonymous type reaches
JsonSerializer— declared record/class instead IsAotCompatible/IL2026/IL3050warnings addressed at the call site (pass aJsonTypeInfo<T>/context), not suppressed- Refit clients pass their context to every creation/registration overload
(
ForGenerated,AddRefitGeneratedClient,RefitSettings.ForJsonContext) rather than relying onallowReflectionFallback: true
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

