JSON Serialization Patterns
Configuring System.Text.Json once, in one place, instead of passing ad-hoc JsonSerializerOptions around every call site — plus the converter, source-generation, and polymorphism patterns worth knowing about.
One shared options instance
Define the options once as a static readonly field and reuse it everywhere — every JsonSerializerOptions instance caches reflection metadata internally, so creating a fresh one per call is wasteful and, worse, easy to configure inconsistently across the codebase.
public static class JsonDefaults
{
public static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(), new UtcDateTimeJsonConverter() },
PropertyNameCaseInsensitive = true
};
}
// usage anywhere outside ASP.NET's own pipeline (which has its own AddJsonOptions hook — see below)
var json = JsonSerializer.Serialize(resource, JsonDefaults.Options);
var parsed = JsonSerializer.Deserialize<ResourceDto>(json, JsonDefaults.Options);
In ASP.NET Core itself, the equivalent is configuring it once via AddJsonOptions — see the custom UTC converter registered the same way, alongside JsonStringEnumConverter so enums serialize as readable strings ("Admin") instead of raw numbers.
builder.Services.AddControllers().AddJsonOptions(options =>
{
options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
options.JsonSerializerOptions.Converters.Add(new UtcDateTimeJsonConverter());
});
Polymorphic serialization
When a property's runtime type varies (a base Notification with EmailNotification/SmsNotification subtypes, say), JsonDerivedType attributes tell the serializer to write and read a discriminator field automatically instead of losing the subtype's extra fields.
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(EmailNotification), "email")]
[JsonDerivedType(typeof(SmsNotification), "sms")]
public abstract class Notification
{
public string Message { get; set; } = "";
}
public class EmailNotification : Notification
{
public string Subject { get; set; } = "";
}
public class SmsNotification : Notification
{
public string PhoneNumber { get; set; } = "";
}
// serializes EmailNotification as: { "type": "email", "message": "...", "subject": "..." }
// deserializing that JSON back to Notification correctly produces an EmailNotification instance
Source-generated serialization
Reflection-based serialization is the default and is fine for most apps. Source generation trades a bit of setup for faster startup and serialization with zero reflection — worth it for a console tool (see Building a CLI Tool) that needs to start instantly, or anything trimmed/published as Native AOT, where reflection-based serialization doesn't work at all.
[JsonSerializable(typeof(ResourceDto))]
[JsonSerializable(typeof(List<ResourceDto>))]
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
public partial class AppJsonContext : JsonSerializerContext { }
// usage — note the context passed instead of JsonSerializerOptions
var json = JsonSerializer.Serialize(resource, AppJsonContext.Default.ResourceDto);
var parsed = JsonSerializer.Deserialize(json, AppJsonContext.Default.ResourceDto);
Circular references
An EF Core navigation property that points back to its parent (MatchParticipant.Match.Participants containing the same participant) throws by default. ReferenceHandler.IgnoreCycles breaks the cycle by omitting the back-reference — usually the right call for API responses, where you want a DTO shape anyway rather than the raw entity graph (see Mapping Extensions for the DTO-mapping alternative, which sidesteps this entirely).
// quick fix: silences the exception, but still risks leaking entity internals
options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
// the real fix: never serialize EF entities directly — map to a DTO first
return Ok(ApiResponse<MatchParticipantDto>.Ok(participant.ToDto()));
Rule of thumb: reach for source generation andReferenceHandlertweaks only when you've hit the specific problem they solve (AOT, or an unavoidable navigation cycle). For everyday API work, a sharedJsonSerializerOptionsplus mapping entities to DTOs before they're ever handed to the serializer avoids most JSON surprises entirely.