System.Text.Json Polymorphism with Swagger in .NET 7

Polymorphic JSON is not finished when System.Text.Json can serialize a derived type. The same discriminator contract must survive OpenAPI generation, server validation, client generation, and every serializer call that crosses a service boundary.
This article follows that contract through a .NET 7 application and shows where the surrounding tooling originally broke down. It is a historical snapshot from March 2023: use it to understand the integration mechanics, then verify current framework and library behavior before carrying the workarounds into a newer stack.
Polymorphic JSON Before .NET 7
The platform has been steadily bringing more functionality in-house.
Many NuGet packages that used to be staples of C# development now have counterparts in .NET itself. These alternatives are built in and require no additional dependency. The libraries live in the dotnet/runtime repository: dotnet/runtime/src/libraries.
JSON serialization is the clearest example of the platform replacing a popular open-source solution.
For years, developers moving data from Kafka into databases often reached for Json.NET, also known as Newtonsoft.Json or simply Newtonsoft. The library had almost 2.5 billion downloads at the time of writing. Before ASP.NET Core 3.0, the platform even supported it directly through various adapters.
As part of the work to improve the ASP.NET Core shared framework, Newtonsoft.Json (Json.NET) has been removed from the ASP.NET Core shared framework.
The default JSON serializer for ASP.NET Core is now System.Text.Json, which is new in .NET Core 3.0. Consider using System.Text.Json when possible. It's high-performance and doesn't require an additional library dependency. However, since System.Text.Json is new, it might currently be missing features that your app needs. For more information, see How to migrate from Newtonsoft.Json to System.Text.Json.
By the time of this article, System.Text.Json covered the everyday needs of working with JSON data. It was also more efficient than its third-party counterpart in both execution time and memory use.
Polymorphic Serialization with System.Text.Json
Polymorphic serialization was one of the major features missing from the first releases. Some gaps required custom code; others called for the usual collection of workarounds.
The two libraries handle inheritance hierarchies differently. Consider this minimal hierarchy:
abstract record Base;
record Derived(string Property) : Base;
Their default behavior is dramatically different:
Base baseObj = new Derived("String Property");
Console.WriteLine(Newtonsoft.Json.JsonConvert.SerializeObject(baseObj));
// {"Property":"String Property"}
Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(baseObj));
// {}
The difference comes from how each serializer gathers information about the type it is processing. The source code makes this clear.
Newtonsoft builds contracts on the fly from runtime information alone. Give it a Type instance, and it takes care of the rest:
protected virtual JsonContract CreateContract(Type objectType)
{
Type t = ReflectionUtils.EnsureNotByRefType(objectType);
if (IsJsonPrimitiveType(t))
return CreatePrimitiveContract(objectType);
t = ReflectionUtils.EnsureNotNullableType(t);
JsonContainerAttribute? containerAttribute = JsonTypeReflector.GetCachedAttribute<JsonContainerAttribute>(t);
if (containerAttribute is JsonObjectAttribute)
return CreateObjectContract(objectType);
if (containerAttribute is JsonArrayAttribute)
return CreateArrayContract(objectType);
if (containerAttribute is JsonDictionaryAttribute)
return CreateDictionaryContract(objectType);
if (t == typeof(JToken) || t.IsSubclassOf(typeof(JToken)))
return CreateLinqContract(objectType);
if (CollectionUtils.IsDictionaryType(t))
return CreateDictionaryContract(objectType);
if (typeof(IEnumerable).IsAssignableFrom(t))
return CreateArrayContract(objectType);
if (CanConvertToString(t))
return CreateStringContract(objectType);
return CreateObjectContract(objectType);
}
Take a look at CreateObjectContract when you have time. It is a particularly revealing example of reflection under the hood.
System.Text.Json takes a more involved route. It searches every converter available to it:
- Custom converters
- Converters specified by
JsonConverterAttribute - Built-in converters
- Converter factories
There are plenty of them; just browse this directory. Only after exhausting those options does it try to create a converter:
internal static JsonConverter GetConverterForType(
Type typeToConvert,
JsonSerializerOptions options,
bool resolveJsonConverterAttribute = true)
{
RootDefaultInstance(); // Ensure default converters are rooted.
// Priority 1: Attempt to get custom converter from the Converters list.
JsonConverter? converter = options.GetConverterFromList(typeToConvert);
// Priority 2: Attempt to get converter from [JsonConverter] on the type being converted.
if (resolveJsonConverterAttribute && converter == null)
{
JsonConverterAttribute? converterAttribute =
typeToConvert.GetUniqueCustomAttribute<JsonConverterAttribute>(inherit: false);
if (converterAttribute != null)
{
converter = GetConverterFromAttribute(
converterAttribute,
typeToConvert: typeToConvert,
memberInfo: null,
options);
}
}
// Priority 3: Query the built-in converters.
converter ??= GetBuiltInConverter(typeToConvert);
// Expand if factory converter & validate.
converter = options.ExpandConverterFactory(converter, typeToConvert);
if (!converter.TypeToConvert.IsInSubtypeRelationshipWith(typeToConvert))
{
ThrowHelper.ThrowInvalidOperationException_SerializationConverterNotCompatible(
converter.GetType(),
converter.TypeToConvert);
}
JsonSerializerOptions.CheckConverterNullabilityIsSameAsPropertyType(converter, typeToConvert);
return converter;
}
Why converters? Because they provide the type information used during serialization—but only when the type passed to the serializer matches the converter’s type:
if (converter.TypeToConvert == type)
{
// For performance, avoid doing a reflection-based instantiation
// if the converter type matches that of the declared type.
jsonTypeInfo = converter.CreateCustomJsonTypeInfo(options);
}
else
{
Type jsonTypeInfoType = typeof(CustomJsonTypeInfo<>).MakeGenericType(type);
jsonTypeInfo = (JsonTypeInfo)jsonTypeInfoType.CreateInstanceNoWrapExceptions(
parameterTypes: new Type[] { typeof(JsonConverter), typeof(JsonSerializerOptions) },
parameters: new object[] { converter, options })!;
}
The distinction is visible in the public APIs as well:
public static string Newtonsoft.Json.JsonConvert.SerializeObject(object? value);
public static string System.Text.Json.JsonSerializer.Serialize<TValue>(TValue value, JsonSerializerOptions? options = null);
Before .NET 7, you could make System.Text.Json behave like Newtonsoft in the example above by calling Serialize with object as the generic type argument—an approach the Microsoft documentation itself recommended:
System.Text.Json.JsonSerializer.Serialize<object>(baseObj);
That explicitly tells the serializer to use reflection-based instantiation. With .NET 7, this trick is no longer necessary, thanks to...
JsonDerivedTypeAttribute
The short version is that we can update the previous example to use the library’s new API:
[JsonDerivedType(typeof(Derived), typeDiscriminator: nameof(Derived))]
abstract record Base;
record Derived(string Property) : Base;
Base baseObj = new Derived("String Property");
Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(baseObj));
// {"$type":"Derived","Property":"String Property"}
For the long version, see the official Microsoft documentation. It explains the new attribute clearly and includes plenty of detailed examples.
The key point is that the resulting JSON now contains a discriminator field that identifies the object’s type. Polymorphic deserialization into a base-class instance therefore requires that field to be present.
There are also three details worth calling out:
The discriminator must appear at the start of the JSON object:
The type discriminator must be placed at the start of the JSON object, grouped together with other metadata properties like
$idand$ref.Use one discriminator type consistently:
While the API supports mixing and matching type discriminator configurations, it's not recommended. The general recommendation is to use either all string type discriminators, all int type discriminators, or no discriminators at all.
Polymorphic serialization works only when the serializer receives the root type of the hierarchy:
For polymorphic serialization to work, the type of the serialized value should be that of the polymorphic base type. This includes using the base type as the generic type parameter when serializing root-level values, as the declared type of serialized properties, or as the collection element in serialized collections.
So far, so good. But...
Why Swagger Must Understand the Type Discriminator
Imagine an application whose services communicate with one another. Some service contracts contain variants: depending on the payload, a message may represent one object, another object, or a third.
One way to model this is with composition. Each field represents one possible variant, but only one field is populated at a time.
Suppose a service accepts a message describing an animal. The animal may be a dog or a cat. A dog looks like this:
{
"dog": {
"bark": true
},
"cat": null
}
And a cat looks like this:
{
"dog": null,
"cat": {
"meow": true
}
}
Now add a horse, cow, pig, and many more:
{
"dog": ...,
"cat": ...,
"horse": ...,
"cow": ...,
"pig": ...
}
Every new field makes the object larger and adds proportional infrastructure code. This design is clearly not flexible.
For messages used only in internal data exchange, polymorphic contracts look appropriate. Inheritance and polymorphism are supported by OpenAPI 3, and the specification provides a dedicated Discriminator Object for identifying types.
Defining Polymorphic Contracts in .NET 7
Let’s define our animal hierarchy:
[JsonDerivedType(typeof(Cat), typeDiscriminator: nameof(Cat))]
[JsonDerivedType(typeof(Dog), typeDiscriminator: nameof(Dog))]
public abstract record Animal;
public record Cat : Animal
{
public bool Meow { get; set; } = true;
}
public record Dog : Animal
{
public bool Bark { get; set; } = true;
}
Then we add a simple controller with two endpoints: one returns data, and the other accepts it.
[ApiController]
[Route("[controller]")]
public class AnimalsController : Controller
{
[HttpGet]
public IEnumerable<Animal> GetAnimals() =>
new List<Animal> { new Dog(), new Cat() };
[HttpPost]
public void PostAnimal([FromBody] Animal animal, [FromServices] ILogger<AnimalsController> logger) =>
logger.LogInformation("{Animal}", animal.ToString());
}
Everything works. GET returns:
[
{
"$type": "Cat",
"meow": true
},
{
"$type": "Dog",
"bark": true
}
]
Sending a cat with POST:
{
"$type": "Cat",
"meow": true
}
Produces this log output:
info: PolymorphicContracts.AnimalsController[0]
Cat { Meow = True }
End of article? Not quite. Let’s inspect the generated Swagger UI:

Disappointment awaits...
This documentation is not enough for anyone to understand how to call the API. Maybe the UI generator is at fault. Let’s inspect schemas:
{
"schemas": {
"Animal": {
"type": "object",
"additionalProperties": false
}
}
}
The generated schema does not match reality. Swagger failed to recognize the inheritance and polymorphism in the documented contracts. Let’s configure it:
builder.Services.ConfigureSwaggerGen(opt => opt.UseOneOfForPolymorphism());
The regenerated schemas look like this:
{
"schemas": {
"Animal": {
"type": "object",
"additionalProperties": false
},
"Cat": {
"type": "object",
"allOf": [
{
"$ref": "#/components/schemas/Animal"
}
],
"properties": {
"meow": {
"type": "boolean"
}
},
"additionalProperties": false
},
"Dog": {
"type": "object",
"allOf": [
{
"$ref": "#/components/schemas/Animal"
}
],
"properties": {
"bark": {
"type": "boolean"
}
},
"additionalProperties": false
}
}
}
Better: the derived types are present. But every schema is missing the $type discriminator, which should be marked as required. Swagger does not see it because the serializer adds it later to the JSON string; it is not a member of the .NET type. Yet the documentation says this should not happen:
If UseAllOfForInheritance or UseOneOfForPolymorphism is enabled, and your serializer supports (and has enabled) emitting/accepting a discriminator property, then Swashbuckle will automatically generate the corresponding discriminator metadata on base schema definitions.
Let’s select the discriminator metadata manually:
builder.Services.ConfigureSwaggerGen(opt =>
{
opt.UseOneOfForPolymorphism();
opt.SelectDiscriminatorNameUsing(_ => "$type");
opt.SelectDiscriminatorValueUsing(subType => subType.BaseType!
.GetCustomAttributes<JsonDerivedTypeAttribute>()
.FirstOrDefault(x => x.DerivedType == subType)?
.TypeDiscriminator!.ToString());
});
The schemas now include the required $type property and the mapping:
{
"schemas": {
"Animal": {
"required": [
"$type"
],
"type": "object",
"properties": {
"$type": {
"type": "string"
}
},
"additionalProperties": false,
"discriminator": {
"propertyName": "$type",
"mapping": {
"Cat": "#/components/schemas/Cat",
"Dog": "#/components/schemas/Dog"
}
}
},
"Cat": {
"type": "object",
"allOf": [
{
"$ref": "#/components/schemas/Animal"
}
],
"properties": {
"meow": {
"type": "boolean"
}
},
"additionalProperties": false
},
"Dog": {
"type": "object",
"allOf": [
{
"$ref": "#/components/schemas/Animal"
}
],
"properties": {
"bark": {
"type": "boolean"
}
},
"additionalProperties": false
}
}
}
Another step forward, but still not the result we want. First, the library contract forces discriminator values to strings even though they may be numbers. Second, it does not expose the allowed values—not even as an enum—or the corresponding default for each derived type. The library cannot help us any further, so it is time to build our own workaround.
Generating Polymorphic Swagger Schemas
The idea is to scan the application for hierarchies that use polymorphic JSON serialization, then pass the collected information to a custom ISchemaFilter implementation that enriches the generated schemas.
The algorithm is straightforward:
- Obtain the list of assemblies that contain contracts—for example, configure it in
appsettings.jsonand bind it throughIOptions<>. - Scan those assemblies for types decorated with
[JsonDerivedType]. These are the hierarchy roots. Because the attribute explicitly lists derived types and discriminators, no additional discovery is necessary; store the information for later. - Use the collected hierarchy data to enrich the documentation in a new
ISchemaFilter. - Generate the enriched schema.
To make this approach practical, the hierarchies must follow a few rules:
- Use discriminators consistently: either all strings or all numbers.
- The hierarchy root must not be an interface; use an
abstract classorabstract record. - Derive directly from the base class. In other words, keep the hierarchy one level deep.
Here is a hierarchy that violates every rule:
[JsonDerivedType(typeof(DerivedFirst), typeDiscriminator: 1)]
[JsonDerivedType(typeof(DerivedSecond), typeDiscriminator: "2")]
interface IBase
{
}
record DerivedFirst : IBase;
record DerivedSecond : DerivedFirst;
After spending some time on the implementation, I got the desired schema: the base discriminator has an enum, and every derived schema has its own required discriminator with a default value.
{
"schemas": {
"Animal": {
"required": [
"$type"
],
"type": "object",
"properties": {
"$type": {
"enum": [
"Cat",
"Dog"
],
"type": "string"
}
},
"additionalProperties": false,
"discriminator": {
"propertyName": "$type",
"mapping": {
"Cat": "#components/schemas/Cat",
"Dog": "#components/schemas/Dog"
}
}
},
"Cat": {
"required": [
"$type"
],
"type": "object",
"allOf": [
{
"$ref": "#/components/schemas/Animal"
}
],
"properties": {
"meow": {
"type": "boolean"
},
"$type": {
"type": "string",
"default": "Cat"
}
},
"additionalProperties": false
},
"Dog": {
"required": [
"$type"
],
"type": "object",
"allOf": [
{
"$ref": "#/components/schemas/Animal"
}
],
"properties": {
"bark": {
"type": "boolean"
},
"$type": {
"type": "string",
"default": "Dog"
}
},
"additionalProperties": false
}
}
}
Two more boundaries need the same contract: validation and generated clients.
Validating Polymorphic Request Models
FluentValidation provides abstract, polymorphic, and strongly typed APIs for routine validation code in .NET, even if the wiring is implicit in the [UsedImplicitly] sense.
It handles our scenario too. Continue writing validators for the concrete types, then register them like this—no wrapper required:
public class AnimalValidator : AbstractValidator<Animal>
{
public AnimalValidator()
{
RuleFor(x => x)
.SetInheritanceValidator(v => v
.Add(new CatValidator())
.Add(new DogValidator())
);
}
}
Polymorphic Contracts in Generated API Clients
Suppose you adopt polymorphic contracts in several services and now need to communicate with those services extensively. Nobody wants to hand-write every integration, so teams turn to generated or declarative clients.
Remember that polymorphic serialization requires passing the base type to the serializer:
JsonSerializer.Serialize<BaseType>(derivedTypeObj);
Libraries such as Refit, RestEase, and RestSharp often pass the wrong type—sometimes even object—when serializing a request such as POST. Service-side deserialization then fails and the client receives a 500 response:
System.NotSupportedException: Deserialization of types without a parameterless constructor, a singular parameterized constructor, or a parameterized constructor annotated with 'JsonConstructorAttribute' is not supported.
The override point differs by library. Refit, for example, provides IHttpContentSerializer. Implement the interface and supply the implementation when creating the client:
var refitSettings = new RefitSettings
{
ContentSerializer = new PolymorphicSerializer(
new SystemTextJsonContentSerializer(),
SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()
)
};
internal class PolymorphicSerializer : IHttpContentSerializer
{
private readonly IHttpContentSerializer _defaultSerializer;
private readonly JsonSerializerOptions _serializerOptions;
public PolymorphicSerializer(
IHttpContentSerializer defaultSerializer,
JsonSerializerOptions serializerOptions) =>
(_defaultSerializer, _serializerOptions) =
(defaultSerializer, serializerOptions);
public HttpContent ToHttpContent<T>(T item) =>
JsonContent.Create(item, item!.GetType().BaseType!, options: _serializerOptions);
public Task<T?> FromHttpContentAsync<T>(HttpContent content, CancellationToken cancellationToken = default) =>
_defaultSerializer.FromHttpContentAsync<T?>(content, cancellationToken);
public string? GetFieldNameForProperty(PropertyInfo propertyInfo) =>
_defaultSerializer.GetFieldNameForProperty(propertyInfo);
}
Takeaways
In the .NET 7 ecosystem described here, polymorphic serialization worked before the complete interservice toolchain did. A production contract therefore needed explicit checks at each boundary:
- Serialize and deserialize through the declared base type.
- Verify that OpenAPI contains the discriminator and every derived schema.
- Exercise generated or declarative clients against the real server contract.
- Register validation for each concrete type.
- Keep the discriminator stable once external consumers depend on it.
I built a NuGet package while writing the article to solve the Swagger problem:
- Package: www.nuget.org/packages/PolymorphicContracts.TypeDiscriminatorSwaggerSetup
- Repository: github.com/Stepami/swagger-discriminator-setup
- Demo project behind this article: github.com/StefanioHabrArticles/polymorphic-contracts
Related .NET Guides
- AutoFixture and AutoData: Reduce .NET Test Setup
- .NET Dependency Injection: Selecting Implementations by Consumer
Follow StepOne on GitHub for open-source C#/.NET projects, compiler experiments, production-focused examples, and new releases.




