# 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](https://github.com/dotnet/runtime/tree/main/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`](https://www.newtonsoft.com/json), 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.

[Microsoft eventually decided](https://learn.microsoft.com/en-us/aspnet/core/migration/22-to-30?tabs=visual-studio#newtonsoftjson-jsonnet-support):

> 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:

```csharp
abstract record Base;

record Derived(string Property) : Base;
```

Their default behavior is dramatically different:

```csharp
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:

```csharp
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](https://github.com/dotnet/runtime/tree/main/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters). Only after exhausting those options does it try to **create** a converter:

```csharp
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:

```csharp
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:

```csharp
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:

```csharp
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:

```csharp
[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](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/polymorphism?pivots=dotnet-7-0). 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:

1. 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 `$id` and `$ref`.

2. 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.

3. 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:

```json
{
    "dog": {
        "bark": true
    },
    "cat": null
}
```

And a cat looks like this:

```json
{
    "dog": null,
    "cat": {
        "meow": true
    }
}
```

Now add a horse, cow, pig, and many more:

```text
{
    "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](https://swagger.io/docs/specification/data-models/inheritance-and-polymorphism/), and the specification provides a dedicated [Discriminator Object](https://swagger.io/specification/#discriminator-object) for identifying types.

## Defining Polymorphic Contracts in .NET 7

Let’s define our animal hierarchy:

```csharp
[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.

```csharp
[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:

```json
[
    {
        "$type": "Cat",
        "meow": true
    },
    {
        "$type": "Dog",
        "bark": true
    }
]
```

Sending a cat with `POST`:

```json
{
    "$type": "Cat",
    "meow": true
}
```

Produces this log output:

```text
info: PolymorphicContracts.AnimalsController[0]
      Cat { Meow = True }
```

End of article? Not quite. Let’s inspect the generated Swagger UI:

![swagger screenshot](https://habrastorage.org/r/w1560/webt/fg/wo/-i/fgwo-iowuuvc-wwfpaaulvy_b58.png)

_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`:

```json
{
    "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:

```csharp
builder.Services.ConfigureSwaggerGen(opt => opt.UseOneOfForPolymorphism());
```

The regenerated schemas look like this:

```json
{
    "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](https://github.com/domaindrivendev/Swashbuckle.AspNetCore/blob/master/README.md#describing-discriminators) 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:

```csharp
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:

```json
{
    "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:

1. Obtain the list of assemblies that contain contracts—for example, configure it in `appsettings.json` and bind it through `IOptions<>`.
2. 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.
3. Use the collected hierarchy data to enrich the documentation in a new `ISchemaFilter`.
4. 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 class` or `abstract record`.
- Derive directly from the base class. In other words, keep the hierarchy one level deep.

Here is a hierarchy that violates **every rule**:

```csharp
[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.

```json
{
    "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:

```csharp
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:

```csharp
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:

```csharp
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](https://www.nuget.org/packages/PolymorphicContracts.TypeDiscriminatorSwaggerSetup)
- Repository: [github.com/Stepami/swagger-discriminator-setup](https://github.com/Stepami/swagger-discriminator-setup)
- Demo project behind this article: [github.com/StefanioHabrArticles/polymorphic-contracts](https://github.com/StefanioHabrArticles/polymorphic-contracts)

---

## Related .NET Guides

- [AutoFixture and AutoData: Reduce .NET Test Setup](https://steponeit.hashnode.dev/autofixture-and-autodata-reduce-net-test-setup)
- [.NET Dependency Injection: Selecting Implementations by Consumer](https://steponeit.hashnode.dev/polymorphic-dependency-injection-in-net-the-complete-guide)

Follow [StepOne on GitHub](https://github.com/Stepami) for open-source C#/.NET projects, compiler experiments, production-focused examples, and new releases.
