Output Formatters & Content Negotiation
Key Points
- Content negotiation picks the response format based on the client's
Acceptheader (and supported media types). - MVC default: JSON (System.Text.Json). XML, Newtonsoft.Json, custom formatters are opt-in.
[Produces]/[Consumes]restrict negotiation per controller / action.- Minimal APIs don't have output formatters in the MVC sense — return
IResult(e.g.,Results.Json,Results.Text,Results.File); custom shapes viaIResultimplementations. - Senior pattern: stick to JSON for SPAs/APIs. Add Problem Details (RFC 7807/9457) for errors. Add CSV/PDF/streaming through dedicated endpoints, not formatter trickery.
Concepts (deep dive)
How MVC negotiates
MVC walks supported IOutputFormatter instances, matches Accept types, and picks the highest-priority match. If none match: 406 Not Acceptable (when RespectBrowserAcceptHeader=true) or default to JSON.
Default formatters
builder.Services.AddControllers(options =>
{
options.RespectBrowserAcceptHeader = true; // honor Accept on browser requests
options.ReturnHttpNotAcceptable = true; // 406 instead of falling back
})
.AddXmlSerializerFormatters(); // adds XML
After this, Accept: application/xml returns XML; Accept: application/json returns JSON; unsupported types → 406.
[Produces] and [Consumes]
[ApiController]
[Route("api/[controller]")]
[Produces("application/json")] // only JSON, ever
[Consumes("application/json")] // only JSON request bodies
public class OrdersController : ControllerBase
{
[HttpGet]
public IEnumerable<Order> List() => /* ... */;
[HttpGet("{id}/csv")]
[Produces("text/csv")] // override per action
public IActionResult Csv(Guid id) => Content(BuildCsv(id), "text/csv");
}
Drives OpenAPI metadata: clients see exactly which content types are valid.
JSON config
builder.Services.AddControllers().AddJsonOptions(opts =>
{
opts.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
opts.JsonSerializerOptions.WriteIndented = false;
opts.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
opts.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
});
For source-gen JSON (AOT-friendly):
[JsonSerializable(typeof(Order))]
public partial class AppJsonContext : JsonSerializerContext { }
builder.Services.AddControllers().AddJsonOptions(opts =>
opts.JsonSerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default));
Newtonsoft.Json (legacy)
builder.Services.AddControllers().AddNewtonsoftJson(opts =>
{
opts.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();
});
Use only when migrating from older code; System.Text.Json is faster and AOT-friendly.
Custom output formatter
public class CsvOutputFormatter : TextOutputFormatter
{
public CsvOutputFormatter()
{
SupportedMediaTypes.Add("text/csv");
SupportedEncodings.Add(Encoding.UTF8);
}
protected override bool CanWriteType(Type? type) =>
type is not null && typeof(IEnumerable<object>).IsAssignableFrom(type);
public override async Task WriteResponseBodyAsync(OutputFormatterWriteContext ctx, Encoding selectedEncoding)
{
var items = (IEnumerable<object>)ctx.Object!;
await using var writer = new StreamWriter(ctx.HttpContext.Response.Body, selectedEncoding);
var props = items.FirstOrDefault()?.GetType().GetProperties() ?? [];
await writer.WriteLineAsync(string.Join(",", props.Select(p => p.Name)));
foreach (var item in items)
await writer.WriteLineAsync(string.Join(",", props.Select(p => p.GetValue(item)?.ToString() ?? "")));
}
}
builder.Services.AddControllers(options => options.OutputFormatters.Insert(0, new CsvOutputFormatter()));
Now Accept: text/csv returns CSV.
Custom input formatter
public class YamlInputFormatter : TextInputFormatter
{
public YamlInputFormatter()
{
SupportedMediaTypes.Add("application/yaml");
SupportedEncodings.Add(Encoding.UTF8);
}
public override async Task<InputFormatterResult> ReadRequestBodyAsync(...)
{ /* parse YAML → object */ return await InputFormatterResult.SuccessAsync(parsed); }
}
Often easier to expose a separate [Consumes("application/yaml")] action and parse manually.
Minimal APIs
No output formatter pipeline — return IResult:
app.MapGet("/orders/{id}", (Guid id) => Results.Ok(GetOrder(id))); // JSON
app.MapGet("/orders/{id}/text", (Guid id) => Results.Text($"Order {id}", "text/plain"));
app.MapGet("/orders/{id}/csv", (Guid id) => Results.Stream(BuildCsvStream(id), "text/csv"));
For multi-format, write a dispatcher:
app.MapGet("/orders", (HttpContext ctx) =>
{
var accept = ctx.Request.GetTypedHeaders().Accept;
if (accept.Any(a => a.MediaType == "text/csv")) return Results.Text(ToCsv(orders), "text/csv");
return Results.Ok(orders);
});
Problem Details for errors (RFC 7807 / 9457)
builder.Services.AddProblemDetails();
app.UseExceptionHandler();
// ApiController auto-returns ProblemDetails on validation failures
Errors land as:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "Bad Request",
"status": 400,
"detail": "...",
"errors": { "Email": ["Required"] }
}
Consistent across endpoints; clients parse a single shape.
Streaming JSON
System.Text.Json streams IAsyncEnumerable<T> and IEnumerable<T> natively — no full materialization. Perfect for large collections.
app.MapGet("/orders/stream", (CancellationToken ct) =>
Results.Json(StreamOrders(ct), contentType: "application/x-ndjson"));
static async IAsyncEnumerable<Order> StreamOrders([EnumeratorCancellation] CancellationToken ct)
{
await foreach (var o in _store.AllAsync(ct)) yield return o;
}
Polymorphic types
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(Cash), typeDiscriminator: "cash")]
[JsonDerivedType(typeof(Card), typeDiscriminator: "card")]
public abstract record Payment;
public record Cash(decimal Amount) : Payment;
public record Card(string Last4) : Payment;
System.Text.Json handles polymorphism via attributes (.NET 7+).
Code: correct vs wrong
❌ Wrong: relying on default browser-friendly behavior
builder.Services.AddControllers(); // RespectBrowserAcceptHeader=false
// Browser sends Accept: text/html → server returns JSON anyway
Confusing for testers; surprise behavior.
✅ Correct: explicit + 406
builder.Services.AddControllers(opts =>
{
opts.RespectBrowserAcceptHeader = true;
opts.ReturnHttpNotAcceptable = true;
});
❌ Wrong: throwing inside formatter
public override async Task WriteResponseBodyAsync(...)
{
if (something) throw new InvalidOperationException(); // partial response written; broken connection
}
✅ Correct: validate before formatting
Move validation to controller / endpoint logic; let formatters assume input is valid.
❌ Wrong: per-controller JSON options drift
✅ Correct: single config
Design patterns for this topic
Pattern 1 — JSON-first API + dedicated CSV endpoint
[Produces("application/json")]
public class OrdersController { /* ... */ }
[HttpGet("/exports/orders.csv")]
public IActionResult Csv() => File(StreamCsv(), "text/csv");
Don't pretend CSV is the same resource as JSON; expose it as a distinct export endpoint.
Pattern 2 — Versioned content types
Variants per version; lets you evolve schemas without changing URLs. Heavyweight; usually overkill — version in path (/api/v2/orders).
Pattern 3 — application/problem+json everywhere
All errors → ProblemDetails. Frontend has one error parser.
Pattern 4 — application/x-ndjson for streams
NDJSON (newline-delimited JSON): one JSON object per line, perfect for IAsyncEnumerable<T> exports without [/] wrapping.
Pattern 5 — Accept from query string for browser-friendly testing
app.Use(async (ctx, next) =>
{
if (ctx.Request.Query.TryGetValue("format", out var fmt))
{
ctx.Request.Headers.Accept = fmt switch
{
"csv" => "text/csv",
"xml" => "application/xml",
_ => ctx.Request.Headers.Accept
};
}
await next();
});
Lets users append ?format=csv in the browser.
Pros & cons / trade-offs
| Approach | Pros | Cons |
|---|---|---|
| JSON only | Simple, fast | Some clients prefer XML/CSV |
| Multi-format | Flexible | More code, more tests |
| Dedicated export endpoints | Clean separation | More URLs |
| Custom formatter | Per-action transparent | Complex to debug |
IResult (minimal API) | Explicit | No automatic negotiation |
When to use / when to avoid
- Default: JSON only.
- Add XML for SOAP-era integrations.
- Add CSV/PDF as dedicated endpoints, not formatters.
- Avoid custom formatters when a path-distinct endpoint expresses intent better.
Interview Q&A
Q1. What is content negotiation? A. The process of matching the client's Accept header against server-supported media types to decide the response format. ASP.NET Core MVC walks IOutputFormatters; minimal APIs leave it to the developer.
Q2. Why does [Produces("application/json")] matter for OpenAPI? A. Generators emit the precise content type in the OpenAPI document; clients (Swagger UI, NSwag-generated SDKs) know what to send/expect.
Q3. Difference between RespectBrowserAcceptHeader and ReturnHttpNotAcceptable? A. The first respects Accept from browsers (which often send text/html); the second returns 406 instead of falling back to the default formatter when no match. Both default false in MVC.
Q4. How do minimal APIs negotiate? A. They don't, by default — return whatever IResult you choose. Build a dispatcher reading Accept if you need multi-format.
Q5. What's application/problem+json? A. RFC 7807 (updated by 9457) media type for error responses with a structured type/title/status/detail/instance schema. ASP.NET Core's ProblemDetails helpers emit this.
Q6. How does IAsyncEnumerable<T> serialize? A. System.Text.Json yields each item as it's produced, writing them inside a JSON array. Memory-bounded; no full materialization. Cancellation propagates via the [EnumeratorCancellation] token.
Q7. When use NDJSON over JSON array? A. When clients want to start processing before the whole response arrives — line-by-line. Streaming dashboards, log shipping, large exports.
Q8. How does polymorphic JSON work in System.Text.Json? A. [JsonPolymorphic] + [JsonDerivedType] attributes on the base type enable type discrimination via a configurable property. The serializer writes/reads the discriminator and dispatches to the right derived type.
Gotchas / common mistakes
- Default JSON config drift across modules — centralize options.
- Custom formatter throwing mid-response — partial output, broken response.
[Produces]declared but action returns wrong type — runtime mismatch.- Forgetting
RespectBrowserAcceptHeader— confusing during browser testing. - Mixing System.Text.Json and Newtonsoft.Json — incompatible attributes.
- CSV formatter that doesn't escape commas / quotes / newlines — corrupt files.
- Streaming JSON without
IAsyncEnumerable— full collection materialized. - Returning XML by default to mobile clients — bandwidth waste.