[TIP] From Custom Converters to Attributes: Polymorphic Serialization Made Easy in .NET


Introduction

Over the past few days, I’ve been deep in the trenches working with serialization of complex objects in .NET. Like many of us, my go-to method was to create custom JsonConverter classes—the usual routine. But then I stumbled upon something surprising: new polymorphic serialization features introduced in .NET 8. Naturally, curiosity kicked in, and I started digging deeper.

This post is the result of that exploration—a practical breakdown of what’s new, how it compares to the old way, and why you’ll want to start using it right away.


From .NET Core 3.0 to .NET 7, developers who needed to send class hierarchies over JSON had to roll up their sleeves and write custom JsonConverter<T> implementations. This meant dealing with reflection, switch statements, and managing a converter for every base type you had (Microsoft Learn).

Community feedback kept asking for a better way—«just mark the class and forget it». Finally, in .NET 8, Microsoft delivered: two new attributes—JsonPolymorphic and JsonDerivedType—that automatically generate the dispatch table at build-time and eliminate the need for custom converter code (Microsoft Learn).

Before we dive into the shiny new world, let’s quickly recap what life looked like before, and why this improvement is a game changer.


1. The Old Way: Custom Converters Everywhere

1.1 A Typical Converter for Requests

Here’s what you’d typically have to write to deserialize polymorphic objects:

public sealed class NotificationSettingsRequestConverter
: JsonConverter<NotificationSettingsRequest>
{
public override NotificationSettingsRequest Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
using var doc = JsonDocument.ParseValue(ref reader);

if (!doc.RootElement.TryGetProperty("type", out var kind))
throw new JsonException("Missing discriminator property");

var raw = doc.RootElement.GetRawText();

return kind.GetString() switch
{
"Email" => JsonSerializer.Deserialize<EmailSettingsRequest>(raw, options)!,
"Sms" => JsonSerializer.Deserialize<SmsSettingsRequest>(raw, options)!,
"Push" => JsonSerializer.Deserialize<PushSettingsRequest>(raw, options)!,
_ => throw new JsonException($"Unsupported type: {kind}")
};
}

public override void Write(
Utf8JsonWriter writer,
NotificationSettingsRequest value,
JsonSerializerOptions options) =>
JsonSerializer.Serialize(writer, (object)value, value.GetType(), options);
}

And you’d have to register it on every property like this:

public record CreateNotificationRequest(
string Name,
[property: JsonConverter(typeof(NotificationSettingsRequestConverter))]
NotificationSettingsRequest Settings);

1.2 Another Converter for Responses

When responses required different shaping—for example, adding extra fields like id or createdAt—you’d either duplicate logic in a new converter or bloat your existing one with extra switch blocks. Painful, error-prone, and lacking OpenAPI support.

1.3 Pain Points

Verbose & error-prone: Every new subtype required you to modify the converter.

Limited tooling: Swagger/Swashbuckle couldn’t detect the hierarchy unless you added custom filters.

Performance hit: Reflection inside the switch slowed things down and defeated the purpose of source generation.

Brittle JSON contracts: You had to manually ensure that the "type" discriminator existed and was correctly positioned in the JSON object.


2. The New Way: Attributes for Clean Polymorphism

2.1 Declare the Model Once

With the new approach, your model becomes clean and self-descriptive:

[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ChannelType { Email, Sms, Push }

[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(EmailSettingsRequest), nameof(ChannelType.Email))]
[JsonDerivedType(typeof(SmsSettingsRequest), nameof(ChannelType.Sms))]
[JsonDerivedType(typeof(PushSettingsRequest), nameof(ChannelType.Push))]

public abstract class NotificationSettingsRequest
{
public abstract ChannelType Type { get; }
}

public sealed class EmailSettingsRequest : NotificationSettingsRequest
{
public override ChannelType Type => ChannelType.Email;
public required string Address { get; set; }
public bool Html { get; set; }
}

public sealed class SmsSettingsRequest : NotificationSettingsRequest
{
public override ChannelType Type => ChannelType.Sms;
public required string PhoneNumber { get; set; }
}

public sealed class PushSettingsRequest : NotificationSettingsRequest
{
public override ChannelType Type => ChannelType.Push;
public required string DeviceToken { get; set; }
}

✅ No converter code, no reflection—just metadata.


2.2 Configure ASP.NET Core Once

builder.Services.AddControllers().AddJsonOptions(o =>
{
o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
o.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;

// For newer versions, you can relax the “discriminator first” rule:
// o.JsonSerializerOptions.AllowOutOfOrderMetadataProperties = true;
});

🔍 The AllowOutOfOrderMetadataProperties property lets you relax the rule that the discriminator must appear first in the JSON object (available starting from .NET 9).


2.3 The Client JSON Stays Simple

{
"name": "Weekly report",
"type": "Email",
"settings": {
"type": "Email",
"address": "user@example.com",
"html": true
}
}

The same object is returned from the API with additional fields like id or createdAt. No need for duplicate converters—the runtime handles it seamlessly.


3. Pitfalls & Quick Fixes

SymptomCauseFix
"must specify a type discriminator""type" is missing, misspelled, or not the first property (in .NET 8)Ensure "type" exists, is spelled correctly, and appears first in the object — or enable AllowOutOfOrderMetadataProperties (in .NET 9)
Deserializes to base classBase class is not abstract, or "type" value is not recognizedMake the base class abstract to prevent fallback; ensure [JsonDerivedType] values match JSON exactly (they are case-sensitive)
Enum shows numbers (0, 1, …)Missing JsonStringEnumConverterAdd JsonStringEnumConverter globally or annotate the enum with [JsonConverter(typeof(JsonStringEnumConverter))]

4. Conclusions: Why the Attribute Model Wins

Zero boilerplate: Two attributes replace dozens (or hundreds) of converter lines.

More robust: Abstract base classes and built-in checks fail fast on invalid payloads .

Better documentation: OpenAPI/Swagger automatically shows your hierarchy, so clients understand your API without extra work.

High performance: Source-generated tables match or outperform hand-written switch logic.

Future-proof: Ongoing improvements (out-of-order metadata, enum flexibility) with no breaking changes.

In short: ditch the converters, mark your classes, and focus on building features. This is polymorphic serialization the way it should have always been.


Happy coding !

References

Deja un comentario

Este sitio utiliza Akismet para reducir el spam. Conoce cómo se procesan los datos de tus comentarios.