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:
- Inspect Action Constraints: Checks if the action or
controller explicitly specifies a media type via attributes such as
[Produces("application/json")]. - Evaluate the
AcceptHeader: Checks the media types requested by the client in order of their quality values (qfactor). - Match with Available Formatters: Compares requested
media types against the list of registered
OutputFormatters. - Fallback Selection: If no
Acceptheader 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:
CanWriteResult(OutputFormatterCanWriteContext context): Determines whether the formatter can serialize the specific object type and handle the requested media type.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:
SystemTextJsonOutputFormatterStringOutputFormatterStreamOutputFormatter
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
- A client dispatches an HTTP request with an
Acceptheader (e.g.,Accept: application/xml). - The routing system directs the request to the target controller action.
- The action returns an object instance wrapped in an
ObjectResult. - ASP.NET Core's content negotiation service iterates through
registered
OutputFormatters. - Each formatter's
CanWriteResultmethod checks compatibility with the target type and media type. - The selected formatter calls
WriteResponseBodyAsyncto serialize the object directly into the response stream. - The final response is sent to the client with the matching
Content-Typeheader.