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.

JsonDefaults.cs
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.

Program.cs
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.

Notification.cs
[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.

AppJsonContext.cs
[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 vs. the real fix
// 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 and ReferenceHandler tweaks only when you've hit the specific problem they solve (AOT, or an unavoidable navigation cycle). For everyday API work, a shared JsonSerializerOptions plus mapping entities to DTOs before they're ever handed to the serializer avoids most JSON surprises entirely.