Strict and Safe Deserialisation

Reject unknown, missing, duplicate and null-where-forbidden JSON properties so request bodies match the contract the code assumes.

Syntaxjson.UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow; json.RespectNullableAnnotations = true; json.RespectRequiredConstructorParameters = true; json.AllowDuplicateProperties = false;

The default reader is forgiving. It skips unknown properties, gives missing ones their default value, lets null into a non-nullable string, and when a property appears twice the last one wins. Each of these turns a client bug into wrong data instead of a 400: a typo such as quanity creates an order line with quantity 0, and {"quantity":2,"Quantity":200} may be read differently by a gateway that keeps the first value and by your API that keeps the last.

The strictness switches

SettingRejectsNotes
UnmappedMemberHandling.Disallowproperties with no matching memberalso per type with [JsonUnmappedMemberHandling]
RespectRequiredConstructorParametersmissing constructor parametersa parameter with a default value stays optional
required members, [JsonRequired]missing propertiesalways enforced, no option needed
RespectNullableAnnotationsnull for a non-nullable reference typealso throws when writing such a null; collection elements are not checked
AllowDuplicateProperties = falsethe same property twicecase-insensitive matching makes id and ID duplicates
NumberHandling = Strict"42" for a numberundoes the web default
MaxDepthnesting deeper than the limit0 means the default, 64

JsonSerializerOptions.Strict sets UnmappedMemberHandling.Disallow, RespectRequiredConstructorParameters, RespectNullableAnnotations and AllowDuplicateProperties = false, but it is case-sensitive and has no camelCase policy, so in an API copy the individual settings onto the web options instead of using it as-is.

How the failure reaches the client

Every rejection is a JsonException, and a minimal API turns it into an empty 400. To tell the client what was wrong, set RouteHandlerOptions.ThrowOnBadRequest so binding failures arrive as BadHttpRequestException in every environment, and write a ProblemDetails from an IExceptionHandler. The exception message names your .NET type; for a public API send JsonException.Path and a fixed text instead, and keep the message for the server log. The switch turns every client binding error, such as a missing query value or a wrong Content-Type, into an exception, so the handler must answer every BadHttpRequestException with its StatusCode; one that falls through is logged at Error with a stack trace, and any client can fill your error logs and alerts with its own 4xx mistakes.

Rolling it out

Strictness on day one is cheap. Switching it on for a live API breaks every client that sends an extra field, such as an old mobile release still sending a property you removed. Measure first: add a [JsonExtensionData] dictionary to the input DTOs, log or count the unknown keys it captures, contact those clients, and only then remove it and disallow unmapped members. Apply this to what your API reads. Clients your service uses to call other APIs should stay lenient, so a provider adding a field does not take you down.

What strictness is not

It checks shape, not meaning. Quantity -5, an unknown SKU or a 10,000-character name still pass; that is validation, done with AddValidation() and DataAnnotations or your own checks. Size is a separate limit too: Kestrel accepts bodies up to 30,000,000 bytes by default, and a lower MaxDepth only bounds nesting.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Return 400 for {"quanity":2} on POST /orders instead of creating an order line with quantity 0.
  • Stop a request smuggling {"amount":1,"Amount":1000} past a gateway that reads the first value while the API reads the last.
  • Count which mobile app versions still send a removed field before switching POST /customers to reject unknown properties.

More examples

Strict input DTOs, lenient everything else

<code>required</code> and <code>[JsonRequired]</code> are enforced by the serializer whatever the options say. Without <code>[JsonRequired]</code>, a missing <code>acceptedTerms</code> would bind as false and look like a refusal instead of a client bug. The type-level attribute is a good first step on an existing API: strict where the contract is new, untouched elsewhere.

Example · csharp
using System.Text.Json.Serialization;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapPost("/customers", (RegisterCustomer body) =>
    TypedResults.Created($"/customers/{Guid.NewGuid()}", body));
app.Run();

// Only this input type rejects unknown properties; the global options stay at the defaults.
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed class RegisterCustomer
{
    public required string Email { get; init; }
    public required string FullName { get; init; }

    [JsonRequired]
    public bool AcceptedTerms { get; init; }

    public string? Company { get; init; }
}

Counting unknown properties before you forbid them

An extension-data dictionary takes priority over a global <code>Disallow</code>, so unknown keys are captured rather than rejected; combining it with the type-level <code>[JsonUnmappedMemberHandling(Disallow)]</code> attribute throws <code>InvalidOperationException</code> instead. A dashboard per retired field tells you which clients still send stale properties before you start returning 400. The tag values come from the request body, so they are limited to a known list plus <code>other</code>, at most 10 keys per request are counted, and the log line is Debug: otherwise a client sending thousands of random keys explodes metric cardinality and floods the logs.

Example · csharp
using System.Diagnostics.Metrics;
using System.Text.Json;
using System.Text.Json.Serialization;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var meter = app.Services.GetRequiredService<IMeterFactory>().Create("Shop.Api.Contracts");
var unknownFields = meter.CreateCounter<long>("shop.json.unknown_fields");

// Field names come from the request body. Only names you removed on purpose become tag
// values; anything else is "other", so random keys cannot create unbounded time series.
string[] retiredFields = ["couponCode", "shippingSpeed", "customerEmail"];

app.MapPost("/orders", (CreateOrder order, ILogger<CreateOrder> logger) =>
{
    foreach (var name in (order.Unknown?.Keys ?? Enumerable.Empty<string>()).Take(10))
    {
        var field = retiredFields.Contains(name, StringComparer.OrdinalIgnoreCase) ? name : "other";
        unknownFields.Add(1, new KeyValuePair<string, object?>("field", field));
        logger.LogDebug("POST /orders received unknown field {Field}", field);
    }
    return TypedResults.Accepted("/orders/5001");
});
app.Run();

public sealed class CreateOrder
{
    public required int CustomerId { get; init; }
    public required string Sku { get; init; }
    public required int Quantity { get; init; }

    // Captures every property with no matching member. Remove it when the counter stays at zero.
    [JsonExtensionData]
    public Dictionary<string, JsonElement>? Unknown { get; set; }
}

Pinning the strict contract in a unit test

A unit test against the same options the app registers is a fast guard against someone relaxing a setting or adding a constructor parameter without a default. The fourth case shows that duplicate detection follows the matching rules: with case-insensitive names, <code>quantity</code> and <code>QUANTITY</code> are the same property.

Example · csharp
using System.Text.Json;
using System.Text.Json.Serialization;
using Xunit;

public class StrictJsonTests
{
    private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
    {
        UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
        RespectNullableAnnotations = true,
        RespectRequiredConstructorParameters = true,
        AllowDuplicateProperties = false,
    };

    [Theory]
    [InlineData("""{"customerId":42,"sku":"KB-104","quanity":2}""")]
    [InlineData("""{"customerId":42,"sku":"KB-104"}""")]
    [InlineData("""{"customerId":42,"sku":null,"quantity":2}""")]
    [InlineData("""{"customerId":42,"sku":"KB-104","quantity":2,"QUANTITY":3}""")]
    public void Rejects_bodies_that_break_the_contract(string json)
    {
        Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<CreateOrder>(json, Options));
    }

    [Fact]
    public void Optional_parameter_may_be_omitted()
    {
        var order = JsonSerializer.Deserialize<CreateOrder>("""{"customerId":42,"sku":"KB-104","quantity":2}""", Options);

        Assert.Null(order!.GiftNote);
    }
}

public record CreateOrder(int CustomerId, string Sku, int Quantity, string? GiftNote = null);

Discussion

  • Be the first to comment on this lesson.