Content Negotiation and Formatters in C# Web Apps

Content negotiation is the mechanism that enables a web API to serve different representations of the same resource based on the client's preferences. In C# and ASP.NET Core, this process determines how data returned by controller actions is transformed into specific media types such as JSON or XML. This article explains the fundamentals of content negotiation, how ASP.NET Core evaluates incoming request headers, and how output formatters serialize objects into final HTTP response payloads.

What is Content Negotiation?

Content negotiation (conneg) occurs when a client and server coordinate to determine the best data format for an HTTP exchange. The client initiates this process by sending specific headers within its request:

  • Accept: Specifies the media types the client understands (e.g., application/json, application/xml, text/plain).
  • Accept-Charset: Defines the acceptable character sets (e.g., utf-8).
  • Accept-Encoding: Specifies acceptable compression algorithms (e.g., gzip, br).
  • Accept-Language: Defines preferred natural languages (e.g., en-US, fr).

When an API controller returns an object—typically wrapped in an IActionResult or as a plain model type—the runtime inspects these headers to select the most appropriate response format. If the server cannot satisfy the request, it can either fall back to a default format or respond with an HTTP 406 Not Acceptable status code.

How ASP.NET Core Handles Content Negotiation

In ASP.NET Core, content negotiation triggers whenever an action returns an ObjectResult or a raw object. The runtime executes an internal algorithm to choose the format:

  1. Inspect Action Constraints: Checks if the action or controller explicitly specifies a media type via attributes such as [Produces("application/json")].
  2. Evaluate the Accept Header: Checks the media types requested by the client in order of their quality values (q factor).
  3. Match with Available Formatters: Compares requested media types against the list of registered OutputFormatters.
  4. Fallback Selection: If no Accept header is present or if no exact match is found, the framework defaults to the first formatter capable of writing the object type (typically JSON).

By default, ASP.NET Core ignores unsupported Accept headers and returns JSON. To enforce strict adherence and return a 406 Not Acceptable error when no match exists, configure the options in Program.cs:

builder.Services.AddControllers(options =>
{
    options.ReturnHttpNotAcceptable = true;
});

The Role of Output Formatters

Output formatters are classes responsible for translating C# objects into an HTTP response body. All output formatters implement the IOutputFormatter interface.

The framework evaluates formatters through two primary methods:

  1. CanWriteResult(OutputFormatterCanWriteContext context): Determines whether the formatter can serialize the specific object type and handle the requested media type.
  2. WriteResponseBodyAsync(OutputFormatterWriteContext context): Serializes the data and writes the binary or textual payload directly to the HTTP response stream.

Default and Additional Formatters

Out of the box, ASP.NET Core includes formatters powered by System.Text.Json:

  • SystemTextJsonOutputFormatter
  • StringOutputFormatter
  • StreamOutputFormatter

To support additional formats such as XML, you must explicitly register them in Program.cs:

builder.Services.AddControllers(options =>
{
    options.ReturnHttpNotAcceptable = true;
})
.AddXmlSerializerFormatters();

With XML formatters added, sending a request with Accept: application/xml prompts ASP.NET Core to route the model through XmlSerializerOutputFormatter, serializing the object into XML elements rather than JSON properties.

Creating a Custom Formatter

When dealing with proprietary media types or formats like CSV or vCard, you can implement a custom formatter by deriving from TextOutputFormatter:

using System.Text;
using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.Net.Http.Headers;

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

    protected override bool CanWriteType(Type? type)
    {
        return type != null;
    }

    public override async Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
    {
        var response = context.HttpContext.Response;
        var buffer = new StringBuilder();

        if (context.Object is IEnumerable<object> list)
        {
            foreach (var item in list)
            {
                buffer.AppendLine(item.ToString());
            }
        }
        else if (context.Object != null)
        {
            buffer.AppendLine(context.Object.ToString());
        }

        await response.WriteAsync(buffer.ToString(), selectedEncoding);
    }
}

Register the custom formatter in the dependency injection container:

builder.Services.AddControllers(options =>
{
    options.OutputFormatters.Add(new CsvOutputFormatter());
});

Clients can now request text/csv via the Accept header to trigger the custom serialization logic.

Execution Flow Summary

  1. A client dispatches an HTTP request with an Accept header (e.g., Accept: application/xml).
  2. The routing system directs the request to the target controller action.
  3. The action returns an object instance wrapped in an ObjectResult.
  4. ASP.NET Core's content negotiation service iterates through registered OutputFormatters.
  5. Each formatter's CanWriteResult method checks compatibility with the target type and media type.
  6. The selected formatter calls WriteResponseBodyAsync to serialize the object directly into the response stream.
  7. The final response is sent to the client with the matching Content-Type header.