Mert Özen Further With Every Line
Backend .NET Core Web API — #41

JSON, Content Negotiation and Formatters: JsonOptions, Enums, Nulls, XML, a CSV Output Formatter and JSON Patch

Mert Özen Nov 15, 2026 10 min 15 views
JSON, Content Negotiation and Formatters: JsonOptions, Enums, Nulls, XML, a CSV Output Formatter and JSON Patch

In ASP.NET Core, minimal APIs read JSON settings from ConfigureHttpJsonOptions and controllers from AddJsonOptions; unless you set both from one place, the same enum comes out as a string on one side and a number on the other. With real outputs we cover the web defaults, nulls and cycles, source generation, content negotiation, our own CSV formatter and safe JSON Patch.

Version note: The code in this post was built and run with .NET 10 (SDK 10.0.404) and Microsoft.AspNetCore.JsonPatch.SystemTextJson 10.0.15.

Guess First

In the previous post we saw "status":0 in the order JSON of Bookstore, the online bookstore used as the shared example across this site's series. To write the enum as a string we added a converter to the minimal API's JSON settings:

builder.Services.ConfigureHttpJsonOptions(options =>
    options.SerializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)));

The same app also has a controller that returns the same enum:

[HttpGet("api/mvc/order-status")]
public object Get() => new { Order = 1001, Status = OrderStatus.Shipped };

What does GET /api/mvc/order-status return: "status":"shipped" or "status":1?

In this post we look at the JSON side of responses and requests: System.Text.Json settings living in two separate places, the web defaults, enums, nulls and cycles, source generation, content negotiation in controllers, our own CSV formatter and JSON Patch. System.Text.Json itself, writing converters and polymorphism, is covered in detail in the System.Text.Json posts of the I/O in .NET series; here we care about the places where it plugs into ASP.NET Core.

Two Sides, Two JsonOptions

The answer to the guess:

$ curl localhost:5238/api/minimal/order-status
{"order":1001,"status":"shipped"}

$ curl localhost:5238/api/mvc/order-status
{"order":1001,"status":1}

Minimal APIs and MVC read their JSON settings from different places. ConfigureHttpJsonOptions sets Microsoft.AspNetCore.Http.Json.JsonOptions; minimal APIs, WriteAsJsonAsync and ReadFromJsonAsync use it. Controllers look at Microsoft.AspNetCore.Mvc.JsonOptions, which you set with AddControllers().AddJsonOptions(...). If an app has both, the safest approach is to collect the settings in one place and apply them to both sides:

public static class JsonDefaults
{
    // Minimal APIs and controllers take the same settings from here
    public static void Apply(JsonSerializerOptions options)
    {
        options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
        options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
        options.TypeInfoResolverChain.Insert(0, BookstoreJsonContext.Default);
    }
}

// Program.cs
builder.Services.ConfigureHttpJsonOptions(options => JsonDefaults.Apply(options.SerializerOptions));
builder.Services.AddControllers(options =>
    {
        options.ReturnHttpNotAcceptable = true;
        options.OutputFormatters.Add(new BookCsvOutputFormatter());
        options.FormatterMappings.SetMediaTypeMappingForFormat("csv", "text/csv");
    })
    .AddJsonOptions(options => JsonDefaults.Apply(options.JsonSerializerOptions))
    .AddXmlSerializerFormatters();

Since the two JsonOptions classes share a name, they are also easy to mix up in using lines; import the wrong namespace and write Configure<JsonOptions>, and the compiler won't complain, the setting simply goes to the other side. After these two lines the order JSON changed:

{"id":1001,"customer":"ada@example.com","lines":[{"isbn":"978-0441013593","title":"Dune","quantity":2,"unitPrice":450}],"total":900,"status":"placed"}

Web Defaults and Nulls

ASP.NET Core starts System.Text.Json with JsonSerializerDefaults.Web. That means three things: property names are written in camelCase, case is ignored when reading, and numbers can also be read from strings. The last one usually goes unnoticed. I actually created the order above by sending "quantity":"2", and the request went through without a problem. That's convenient for a form library on the JavaScript side that turns numbers into strings, but it also means the contract is loose. In the .NET 11 post we saw a similar request return 400 once NumberHandling was set to Strict.

DefaultIgnoreCondition = WhenWritingNull removes null-valued properties from the response. We added an optional Series field to books; only Dune has it:

$ curl "localhost:5238/api/books?pageSize=2"
[{"id":1,"isbn":"978-0441013593","title":"Dune","price":450,"category":"science-fiction","series":"Dune Chronicles"},{"id":2,"isbn":"978-0141439587","title":"Emma","price":280,"category":"classics"}]

Emma has no series at all. The payload shrinks, but the client loses the difference between "field missing" and "field null". On the Angular side book.series === undefined and book.series === null give different results; talk to the client team before turning this on. For a single property there's also the [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] attribute.

Reference Cycles

This is the most common error when you return EF Core entities directly. When I returned two classes linking an author and their books from a minimal API, I got a bodiless 500 in Production, and the log said (shortened):

System.Text.Json.JsonException: A possible object cycle was detected. This can either be due to a cycle or if the object depth is larger than the maximum allowed depth of 64. Consider using ReferenceHandler.Preserve on JsonSerializerOptions to support cycles. Path: $.Books.Author.Books.Author.Books.Author...

I tried two fixes. ReferenceHandler.IgnoreCycles sets the reference that closes the cycle to null; since WhenWritingNull is on, author disappeared completely. ReferenceHandler.Preserve adds $id and $ref metadata:

IgnoreCycles: {"name":"Frank Herbert","books":[{"title":"Dune"}]}
Preserve:     {"$id":"1","name":"Frank Herbert","books":{"$id":"2","$values":[{"$id":"3","title":"Dune","author":{"$ref":"1"}}]}}

Preserve affects every response in the app: even the book list turned into {"$id":"1","$values":[...]} and broke the Angular client. Both hide the problem; the real fix, as we discussed in the DTO post, is to return a DTO without the cycle instead of the entity.

Wiring Source Generation into the API

By default System.Text.Json inspects types with reflection at run time. The source generator produces the serialization code at compile time instead; it shortens startup and is required for Native AOT:

// Serializer generated at compile time; this code is used instead of reflection
[JsonSerializable(typeof(IEnumerable<Book>))]
[JsonSerializable(typeof(OrderDto))]
[JsonSerializable(typeof(OrderDto[]))]
public partial class BookstoreJsonContext : JsonSerializerContext;

The TypeInfoResolverChain.Insert(0, BookstoreJsonContext.Default) line in JsonDefaults.Apply puts the context at the head of the chain: generated code for these types, and the reflection-based resolver further down the chain for everything else. The status of OrderDto, which we registered in the context, still came out as "placed"; converters and the naming policy are read from the options, so source-generated types were written with the same settings. If you're heading for Native AOT, no type may fall through to reflection in the chain; CreateSlimBuilder and the AOT rules are in the .NET 8 post, the details of source generation in the second System.Text.Json post of the I/O series.

Content Negotiation and [Produces]

Minimal APIs have no content negotiation: return an object and you get JSON. If you want a different format depending on the Accept header, you need the formatter infrastructure of controllers. We touched on it briefly in the status codes post; now let's look at it through an export endpoint:

[ApiController]
[Route("api/books/export")]
public class BookExportController(BookCatalog catalog) : ControllerBase
{
    [HttpGet]
    [HttpGet("~/api/books/export.{format}")]
    [FormatFilter]
    [Produces("application/json", "text/csv")]
    public IEnumerable<Book> Get() => catalog.All.OrderBy(b => b.Title);
}

MVC tries the media types in the Accept header in order and picks the first output formatter that can write one of them. [Produces] says this endpoint produces only JSON and CSV; it narrows the choice and also shows up in OpenAPI. Without ReturnHttpNotAcceptable = true, MVC would quietly fall back to JSON when an unsupported Accept arrived; with it on, it returns 406:

$ curl -i localhost:5238/api/books/export -H "Accept: application/pdf"
HTTP/1.1 406 Not Acceptable
Content-Length: 0

$ curl -i localhost:5238/api/books/export -H "Accept: application/xml"
HTTP/1.1 406 Not Acceptable
Content-Type: application/problem+xml; charset=utf-8

<problem xmlns="urn:ietf:rfc:7807"><status>406</status><title>Not Acceptable</title><type>https://tools.ietf.org/html/rfc9110#section-15.5.7</type><traceId>00-1819b16ee31567f60cd8ec08d702258d-30428eba5f90d0b8-00</traceId></problem>

The second output is interesting: the endpoint doesn't produce XML, but since an XML formatter is registered in the app, the ProblemDetails body of the 406 was written as XML. For requests coming from a browser, MVC ignores Accept by default (RespectBrowserAcceptHeader is false): a request I sent from curl with the text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 header that browsers send got JSON, not 406; when MVC sees */*, it treats the request as coming from a browser. On the request side [Consumes] does the same job: an unsupported Content-Type gets a 415.

XML and Records

AddXmlSerializerFormatters() writes XML with XmlSerializer, and that serializer has an old rule: it needs a parameterless constructor. When I asked for the Book record as XML from an action without [Produces], I got a 406, and the log had the reason:

warn: Microsoft.AspNetCore.Mvc.Formatters.XmlSerializerOutputFormatter[2]
      An error occurred while trying to create an XmlSerializer for the type 'Microsoft.AspNetCore.Mvc.Formatters.Xml.DelegatingEnumerable`2[...]'.
      System.InvalidOperationException: Bookstore.Api.Catalog.Book cannot be serialized because it does not have a parameterless constructor.

Positional records don't follow that rule. If you really need XML, write a separate export DTO with a parameterless constructor and settable properties, or try the DataContract route with AddXmlDataContractSerializerFormatters().

Your Own CSV Formatter

Institutional customers want to open the catalog list in Excel. To return CSV from the same endpoint we write a formatter that derives from TextOutputFormatter:

public class BookCsvOutputFormatter : TextOutputFormatter
{
    public BookCsvOutputFormatter()
    {
        SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/csv"));
        SupportedEncodings.Add(Encoding.UTF8);
    }

    // We can only write book lists; other types move on to the next formatter
    protected override bool CanWriteType(Type? type) =>
        typeof(IEnumerable<Book>).IsAssignableFrom(type);

    public override async Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
    {
        StringBuilder csv = new();
        csv.AppendLine("isbn,title,price,category");
        foreach (Book book in (IEnumerable<Book>)context.Object!)
        {
            csv.AppendLine(string.Join(',',
                book.Isbn,
                Escape(book.Title),
                book.Price.ToString(CultureInfo.InvariantCulture),
                book.Category));
        }

        context.HttpContext.Response.Headers.ContentDisposition = "attachment; filename=books.csv";
        await context.HttpContext.Response.WriteAsync(csv.ToString(), selectedEncoding);
    }

    private static string Escape(string value) =>
        value.Contains(',') || value.Contains('"') ? $"\"{value.Replace("\"", "\"\"")}\"" : value;
}
$ curl -i localhost:5238/api/books/export -H "Accept: text/csv"
HTTP/1.1 200 OK
Content-Type: text/csv; charset=utf-8
Content-Disposition: attachment; filename=books.csv

isbn,title,price,category
978-0441013593,Dune,450,science-fiction
978-0141439587,Emma,280,classics
978-0141439549,Middlemarch,390,classics
978-0547928227,The Hobbit,320,science-fiction

$ curl localhost:5238/api/books/export.csv
isbn,title,price,category
978-0441013593,Dune,450,science-fiction
...

Without an Accept header the same endpoint returned JSON. Thanks to [FormatFilter] and SetMediaTypeMappingForFormat("csv", "text/csv"), a URL such as export.csv also stands in for Accept; handy for a link that can't send headers. Writing the price with InvariantCulture is a must here: in Turkish culture a value like 450,5 collides with the CSV separator. CanWriteType matters too: the formatter writes only book lists and leaves other types, such as ProblemDetails, to the next formatter. On the minimal API side you'd meet the same need with your own IResult, as in the previous post.

JSON Patch: Only Allowed Paths

In the .NET 10 post we saw the System.Text.Json-based JSON Patch package in a controller. This time we write it in a minimal API, following the recommendations in Learn's security section. Learn is clear: the JSON Patch standard has inherent risks, and ASP.NET Core doesn't try to mitigate them for you. We did three things: we apply the patch to a small DTO instead of the entity, we limit the paths that can change with a list, and we validate the result after applying it:

public class BookPatch
{
    [Range(1, 10000)]
    public decimal Price { get; set; }

    public string Title { get; set; } = "";
}

public static class BookPatchEndpoint
{
    // Paths the client may change; Title is in the DTO but not here
    private static readonly string[] AllowedPaths = ["/price"];

    public static Results<Ok<Book>, NotFound, ValidationProblem> Patch(
        Isbn isbn, JsonPatchDocument<BookPatch> patch, BookCatalog catalog)
    {
        if (catalog.Find(isbn) is not { } book)
        {
            return TypedResults.NotFound();
        }

        Dictionary<string, string[]> errors = [];
        foreach (Operation<BookPatch> operation in patch.Operations)
        {
            if (operation.OperationType != OperationType.Replace
                || !AllowedPaths.Contains(operation.path, StringComparer.OrdinalIgnoreCase))
            {
                errors[operation.path] = [$"'{operation.op}' on '{operation.path}' is not allowed."];
            }
        }
        if (errors.Count > 0)
        {
            return TypedResults.ValidationProblem(errors);
        }

        BookPatch target = new() { Price = book.Price, Title = book.Title };
        patch.ApplyTo(target, error => errors[error.Operation.path] = [error.ErrorMessage]);

        List<ValidationResult> results = [];
        Validator.TryValidateObject(target, new ValidationContext(target), results, validateAllProperties: true);
        foreach (ValidationResult result in results)
        {
            errors[result.MemberNames.First()] = [result.ErrorMessage!];
        }
        if (errors.Count > 0)
        {
            return TypedResults.ValidationProblem(errors);
        }

        Book updated = book with { Price = target.Price };
        catalog.Replace(updated);
        return TypedResults.Ok(updated);
    }
}

// Program.cs
app.MapPatch("/api/books/{isbn}", BookPatchEndpoint.Patch)
    .RequireAuthorization(policy => policy.RequireRole("StoreStaff"));

Only store staff can change the price; I generated the token with dotnet user-jwts create --name grace@example.com --role StoreStaff:

$ curl -X PATCH localhost:5238/api/books/978-0441013593 -H "Authorization: Bearer $STAFF_TOKEN" -H "Content-Type: application/json-patch+json" -d '[{"op":"replace","path":"/price","value":420}]'
{"id":1,"isbn":"978-0441013593","title":"Dune","price":420,"category":"science-fiction","series":"Dune Chronicles"}

$ curl -X PATCH ... -d '[{"op":"replace","path":"/title","value":"Dune Messiah"},{"op":"remove","path":"/price"}]'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"/title":["'replace' on '/title' is not allowed."],"/price":["'remove' on '/price' is not allowed."]},"traceId":"00-5272def1ddc0c86c0cdc5df50620b288-43637c9562a91fef-00"}

$ curl -X PATCH ... -d '[{"op":"replace","path":"/price","value":0}]'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Price":["The field Price must be between 1 and 10000."]},"traceId":"00-509a3f16a1e2af6a6c5d893ed1205ac4-96eff70a3193f4c9-00"}

The same request with the token of Ada, who has the Customer role, returned 403. When I sent it with Content-Type: application/json, I got a 415; for a JsonPatchDocument<T> parameter a minimal API accepts only application/json-patch+json. Title was rejected although it is in the DTO, because it isn't in the list; that's the simplest way to protect fields the client must not touch, such as Id or CreatedBy. Learn also warns about memory amplification through many copy operations; since we reject everything except replace, that door is closed for us.

Before .NET 10 the road went through Newtonsoft: the Microsoft.AspNetCore.Mvc.NewtonsoftJson package and AddNewtonsoftJson(). That call moves not just JSON Patch but all JSON reading and writing in MVC to Newtonsoft; that's why Learn showed a way for older versions that inserts only NewtonsoftJsonPatchInputFormatter at the head of the input formatter list and leaves the rest on System.Text.Json. If you see that code in an old project, that's the reason; in new code the System.Text.Json package is enough, unless you need to patch dynamic types such as ExpandoObject.

Common Pitfalls

First, minimal APIs and controllers running with different JSON settings. We saw it in Guess First: the same enum is a string on one side and a number on the other. Collect the settings in one method and apply them in both places.

Second, exposing an enum as a number and breaking it later. Once "status":0 is published, insert a new value in the middle of the enum (say Packed between Placed and Shipped) and Shipped becomes 2, so old clients show the order wrongly. Switching from numbers to strings is also a breaking change for clients. Decide in the first version; if you choose strings, treat the names of the enum members as part of the contract too.

Third, turning on ReferenceHandler.Preserve "to make the cycle error go away". The shape of every response changes and the client has to deal with $values. Break the cycle with a DTO.

Fourth, applying JSON Patch directly to an entity. Without a path list and a DTO, the client can try to change /id or /customerId; the checks are entirely up to you.

A Small Experiment

★☆☆ Delete the WhenWritingNull line from JsonDefaults.Apply and confirm that "series":null appears for Emma in the /api/books output. Then add [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] to the Series property alone.

★★☆ Generalize BookCsvOutputFormatter so it also works for IEnumerable<OrderDto>, and write a controller action that returns the order list as CSV. Watch which formatter gets picked as you change the Accept header.

★★★ Allow the test operation in the JSON Patch endpoint: when the client sends [{"op":"test","path":"/price","value":420},{"op":"replace","path":"/price","value":399}] and the price isn't 420, the change must not be applied. Think about how this can serve as an optimistic concurrency tool.

The same middleware chain sits in front of every endpoint in this post, but we haven't yet looked at what's missing from it. In the next post we add the built-in middleware one by one: response compression, request decompression, request timeouts, status code pages, URL rewrite and header propagation.