Mastering Polymorphic JSON Deserialization with System.Text.Json
When designing event-driven systems, command buses, or webhook receivers, we often find ourselves wrestling with a common challenge: flexible data contracts. The payload arriving over the wire might represent various distinct types, all sharing a common base, and our application needs to deserialize it into the correct concrete type. This is the essence of polymorphic deserialization, and while System.Text.Json has evolved, mastering its capabilities for these scenarios is critical for building robust and evolvable APIs.
I’ve seen countless systems stumble here, either by resorting to brittle manual parsing, complex if-else cascades, or, worse, inefficient serializations that bloat payloads. The modern .NET ecosystem, particularly with System.Text.Json as the default serializer, demands a more elegant and performant solution. With the prevalence of microservices, cloud-native deployments, and event sourcing, the ability to gracefully handle polymorphic data isn’t just a nice-to-have; it’s a foundational requirement for systems that need to adapt and scale without constant re-deploys.
The Problem: When a BaseType Isn’t Enough
Imagine an incoming stream of DomainEvent objects. We know they all derive from a common DomainEvent base class, but specifically, they could be OrderCreatedEvent, ProductStockUpdatedEvent, CustomerAccountDeletedEvent, and so on. Each concrete event type has its own distinct properties beyond what the base class provides.
public abstract class DomainEvent
{
public Guid EventId { get; init; } = Guid.NewGuid();
public DateTime Timestamp { get; init; } = DateTime.UtcNow;
public string EventType => GetType().Name; // A common, simple discriminator
}
public class OrderCreatedEvent : DomainEvent
{
public Guid OrderId { get; init; }
public Guid CustomerId { get; init; }
public decimal TotalAmount { get; init; }
public List<OrderItem> Items { get; init; } = new();
}
public class ProductStockUpdatedEvent : DomainEvent
{
public Guid ProductId { get; init; }
public int NewStockLevel { get; init; }
public int OldStockLevel { get; init; }
}
// ... other event types
When System.Text.Json attempts to deserialize a JSON payload into a DomainEvent (or a List<DomainEvent>), it only has the base type information. It doesn’t know which concrete derived type to instantiate. Without explicit guidance, it will instantiate DomainEvent itself (if it’s not abstract) or throw an exception, leading to data loss or deserialization failures because properties specific to OrderCreatedEvent (like OrderId or Items) won’t bind.
System.Text.Json’s Evolving Solution: Attributes and Converters
Initially, System.Text.Json lagged Newtonsoft.Json in out-of-the-box polymorphic support. This gap has largely closed with the introduction of JsonDerivedType and JsonPolymorphic attributes in .NET 7+. These attributes offer a declarative way to tell the serializer about your type hierarchy at compile time.
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type", UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.Fail)]
[JsonDerivedType(typeof(OrderCreatedEvent), typeDiscriminator: "OrderCreated")]
[JsonDerivedType(typeof(ProductStockUpdatedEvent), typeDiscriminator: "ProductStockUpdated")]
public abstract class DomainEvent
{
// ... base properties
}
This is excellent for internal APIs or tightly coupled systems where the entire hierarchy is known and controlled. The "$type" discriminator will be added to the JSON, and System.Text.Json uses it to determine the concrete type. However, for external systems, evolving APIs, or complex, non-trivial discrimination logic (e.g., based on multiple properties, external lookups, or versioning), a custom JsonConverter remains the most flexible and robust approach. And, frankly, for systems that aim for long-term stability and evolvability, I often prefer the explicit control a custom converter offers, even when attributes might suffice. It centralizes the logic and makes it easier to debug and extend.
Crafting a Robust Custom Converter
Let’s implement a custom JsonConverter<DomainEvent> to handle our event stream scenario. This converter will read a discriminator property (e.g., EventType) from the incoming JSON, then delegate to the standard JsonSerializer to deserialize the entire object into the correct concrete type.
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Nodes; // For System.Text.Json.Nodes.JsonObject, often helpful
// --- Domain Event Definitions ---
public abstract class DomainEvent
{
public Guid EventId { get; init; } = Guid.NewGuid();
public DateTime Timestamp { get; init; } = DateTime.UtcNow;
// Note: EventType property is usually derived from class name in real systems
// but here we might have it as part of the JSON for explicit discrimination.
}
public class OrderCreatedEvent : DomainEvent
{
public Guid OrderId { get; init; }
public Guid CustomerId { get; init; }
public decimal TotalAmount { get; init; }
public List<OrderItem> Items { get; init; } = new();
}
public class OrderItem
{
public Guid ProductId { get; init; }
public string ProductName { get; init; } = string.Empty;
public int Quantity { get; init; }
public decimal UnitPrice { get; init; }
}
public class ProductStockUpdatedEvent : DomainEvent
{
public Guid ProductId { get; init; }
public int NewStockLevel { get; init; }
public int OldStockLevel { get; init; }
}
public class CustomerAccountDeletedEvent : DomainEvent
{
public Guid CustomerId { get; init; }
public string Reason { get; init; } = string.Empty;
}
// --- Custom Polymorphic Converter ---
public class DomainEventConverter : JsonConverter<DomainEvent>
{
private const string EventTypePropertyName = "EventType"; // The discriminator property name in JSON
public override DomainEvent? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.StartObject)
{
throw new JsonException("Expected StartObject token.");
}
// Create a JsonObject to buffer the entire event payload.
// This allows us to read the discriminator property without advancing the main reader past it,
// so we can re-parse the whole object later.
using JsonDocument document = JsonDocument.ParseValue(ref reader);
var root = document.RootElement;
if (!root.TryGetProperty(EventTypePropertyName, out JsonElement eventTypeElement))
{
throw new JsonException($"Missing '{EventTypePropertyName}' discriminator property.");
}
var eventType = eventTypeElement.GetString();
if (string.IsNullOrWhiteSpace(eventType))
{
throw new JsonException($"'{EventTypePropertyName}' discriminator property cannot be empty.");
}
// Critical: Create new options to avoid infinite recursion.
// The new options must NOT include this converter again.
var newOptions = new JsonSerializerOptions(options); // Copy existing options
newOptions.Converters.Remove(this); // Remove *this* specific converter instance
DomainEvent? concreteEvent = eventType switch
{
nameof(OrderCreatedEvent) => root.Deserialize<OrderCreatedEvent>(newOptions),
nameof(ProductStockUpdatedEvent) => root.Deserialize<ProductStockUpdatedEvent>(newOptions),
nameof(CustomerAccountDeletedEvent) => root.Deserialize<CustomerAccountDeletedEvent>(newOptions),
_ => throw new JsonException($"Unknown event type: {eventType}")
};
if (concreteEvent == null)
{
throw new JsonException($"Failed to deserialize event of type {eventType}.");
}
return concreteEvent;
}
public override void Write(Utf8JsonWriter writer, DomainEvent value, JsonSerializerOptions options)
{
// For writing, we can simply serialize the concrete type.
// The default serializer will add its specific properties.
// We'll also explicitly add the EventType discriminator property.
writer.WriteStartObject();
// Write the discriminator property first
writer.WriteString(EventTypePropertyName, value.GetType().Name);
// Serialize the rest of the object.
// Again, use new options to prevent infinite recursion if `options` contains this converter.
var newOptions = new JsonSerializerOptions(options);
newOptions.Converters.Remove(this);
using (JsonDocument document = JsonSerializer.SerializeToDocument(value, value.GetType(), newOptions))
{
foreach (var property in document.RootElement.EnumerateObject())
{
// Avoid re-writing the discriminator if the underlying type already had it.
// This assumes `EventType` is not part of the base type's JSON representation
// or we want our explicit one to take precedence.
if (property.NameEquals(EventTypePropertyName)) continue;
property.WriteTo(writer);
}
}
writer.WriteEndObject();
}
}
// --- Integration into a Minimal API (or any .NET application) ---
// This would typically be in Program.cs for a web application.
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
// Configure System.Text.Json for the application.
// Add the custom converter to the list of converters.
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.Converters.Add(new DomainEventConverter());
options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.SerializerOptions.WriteIndented = true; // For readability in example
});
var app = builder.Build();
app.UseHttpsRedirection();
// Minimal API endpoint to receive and process events
app.MapPost("/events", async (DomainEvent eventPayload, ILogger<Program> logger) =>
{
// The eventPayload is already deserialized into the correct concrete type
// thanks to our custom converter.
logger.LogInformation("Received {EventType} event: {@EventPayload}", eventPayload.GetType().Name, eventPayload);
switch (eventPayload)
{
case OrderCreatedEvent oce:
logger.LogInformation("Processing new order {OrderId} for customer {CustomerId}", oce.OrderId, oce.CustomerId);
// Business logic for OrderCreatedEvent
break;
case ProductStockUpdatedEvent psue:
logger.LogInformation("Updating stock for product {ProductId} from {OldStock} to {NewStock}", psue.ProductId, psue.OldStockLevel, psue.NewStockLevel);
// Business logic for ProductStockUpdatedEvent
break;
case CustomerAccountDeletedEvent cade:
logger.LogInformation("Customer account {CustomerId} deleted for reason: {Reason}", cade.CustomerId, cade.Reason);
// Business logic for CustomerAccountDeletedEvent
break;
default:
logger.LogWarning("Unhandled event type: {EventType}", eventPayload.GetType().Name);
break;
}
return Results.Ok($"Event '{eventPayload.GetType().Name}' with ID '{eventPayload.EventId}' processed.");
})
.WithName("ReceiveDomainEvent")
.WithOpenApi(); // For Swagger/OpenAPI documentation
app.Run();
}
}
Deconstructing the Converter
This DomainEventConverter showcases several critical patterns:
- Reading the Discriminator: In the
Readmethod, we useJsonDocument.ParseValue(ref reader)to load the entire JSON payload into aJsonDocument. This is crucial because theUtf8JsonReaderis forward-only. By parsing it into aJsonDocument, we can then freely navigate itsRootElementto find ourEventTypediscriminator without consuming the reader’s state for the rest of the object. - Delegating Deserialization: Once the
EventTypeis identified, aswitchstatement (or a dictionary lookup for more types) maps it to the corresponding concrete type (OrderCreatedEvent,ProductStockUpdatedEvent, etc.). We then callroot.Deserialize<T>(newOptions)to letSystem.Text.Jsonhandle the actual deserialization of the full JSON payload into that specific concrete type. - Preventing Infinite Recursion: The most common pitfall in custom
JsonConverterimplementations is infinite recursion. If theJsonSerializer.Deserializecall insideReadorJsonSerializer.SerializeToDocumentcall insideWriteuses the sameJsonSerializerOptionsinstance that contains thisDomainEventConverter, it will try to use this converter again for the derived types, leading to a stack overflow. The solution is to create a newJsonSerializerOptionsinstance, copy the relevant settings from the original, and critically remove the current converter from itsConverterslist. - Writing Logic: The
Writemethod demonstrates how to ensure the discriminator property (EventType) is included in the serialized JSON. We explicitly write it first, then delegate the rest of the object’s serialization. We enumerate the properties of the serializedJsonDocumentto avoid re-writing the discriminator if the concrete type itself had it.
Why this approach matters
- Flexibility and Evolutability: New
DomainEventtypes can be added by simply extending theswitchstatement in the converter. No need to change existing consumers or attributes on base types. For very large hierarchies, consider aDictionary<string, Type>mapping event type strings toTypeinstances for performance and maintainability. - Explicit Control: You dictate exactly how types are identified and handled. This is invaluable when dealing with external systems, legacy data, or complex versioning strategies.
- Error Handling: The converter provides a central point for robust error handling. Missing discriminators, unknown types, or malformed JSON can be caught and logged precisely, preventing silent data loss.
- Performance: While buffering with
JsonDocumentadds a slight overhead compared to purely streaming withUtf8JsonReader, for typical event payloads, the performance impact is negligible.System.Text.Jsonitself is highly optimized. If extreme low-latency and zero-allocation parsing are critical, a more complex, entirely manualUtf8JsonReader-based converter might be considered, but the complexity skyrockets. For 99% of production scenarios,JsonDocumentis fine.
Pitfalls and Best Practices
- Overly Complex Discriminators: Keep your discriminator simple and consistent. A single string property is usually sufficient. Avoid multi-property discrimination if possible, as it significantly complicates converter logic.
- Discriminator Naming: Be explicit. Using
"$type"or"$event"makes it clear it’s metadata, not a business property. - Unknown Types Handling: Decide how to handle unknown types. Throwing an exception (as in the example) is safe and explicit. For more resilient systems, you might deserialize into an
UnknownEventtype with aJsonElementproperty to capture the raw payload, allowing for “dead-lettering” or forward-compatibility. - Performance Hotspots: While
JsonDocumentis fast, if you’re processing millions of events per second on a single thread, profiling might reveal the buffering as a bottleneck. Only then consider aUtf8JsonReader-only approach, but prepare for increased code complexity. - Testing: Custom converters are business logic. They need thorough unit tests to ensure they correctly handle valid and invalid JSON, known and unknown types, and edge cases.
Ultimately, mastering polymorphic JSON deserialization with System.Text.Json isn’t about memorizing attributes or specific API calls; it’s about understanding the core challenge of type resolution at runtime and knowing when to leverage System.Text.Json’s declarative features versus when to take explicit control with a custom JsonConverter. This capability is fundamental to building flexible, resilient, and evolvable .NET applications in today’s dynamic software landscape.