Validation Problem Responses

Shape 400 validation responses clients can map to form fields: the errors dictionary, consistent key naming and the right status code.

Syntaxreturn TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["lines[0].quantity"] = ["Quantity must be between 1 and 100."] });

A validation problem is a problem details response with one extra member, errors: an object whose keys name the invalid input and whose values are arrays of messages. ASP.NET Core uses HttpValidationProblemDetails for minimal APIs and its MVC subclass ValidationProblemDetails for controllers. Defaults: status 400, type https://tools.ietf.org/html/rfc9110#section-15.5.1, title One or more validation errors occurred., plus traceId.

Who produces it

  • AddValidation() (.NET 10) validates DataAnnotations on minimal API parameters and records before the handler runs.
  • TypedResults.ValidationProblem(errors) for checks only the handler can make, such as an expired discount code.
  • [ApiController] builds one from ModelState through InvalidModelStateResponseFactory.

Malformed input takes a different path. A body that is not valid JSON fails binding before validation runs. In Production a minimal API answers with a bare 400, which status code pages turns into a problem without errors. In Development binding throws BadHttpRequestException instead, so with UseExceptionHandler() in front the same request becomes a 500 locally unless you map that exception.

Key naming: what clients care about

Keys are built from C# member names, not JSON names. For a body posted as {"customerEmail": ..., "lines": [...], "shipTo": {...}}, built-in validation reports CustomerEmail, Lines[0].Quantity and ShipTo.PostalCode; the camelCase property naming policy does not apply to dictionary keys. MVC behaves the same unless you add SystemTextJsonValidationMetadataProvider, which makes model state keys follow [JsonPropertyName] and the naming policy.

Front ends map errors to form fields by key, so pick one convention, make every producer follow it and test it. The usual choice is the field's JSON path in the request body: lines[0].quantity. Rewriting keys in CustomizeProblemDetails covers every producer that goes through IProblemDetailsService in one place. Messages also use C# names (The CustomerEmail field is required.); set ErrorMessage on attributes when users will read the text.

Choosing the status

StatusMeaningUse
400Malformed request or a field rule brokenThe framework default and what most clients expect
422Well-formed but semantically invalid (RFC 9110 Unprocessable Content)Only if your API standard says so; pass statusCode: 422 everywhere
409Conflicts with current state: stock, version, duplicateA normal problem with its own type, not an errors entry

Choose 400 for field errors unless a published standard already says 422, and never mix the two for the same kind of failure.

Production failure modes

  • Clients parsing message text to find the field, then breaking when the wording changes. Give them keys.
  • Two shapes for one concept: a validation library's own error format beside the framework's. Convert everything to errors.
  • Messages that echo the rejected value, copying personal data or pasted secrets into logs and screenshots.

Example

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

When to use it

  • Highlight the quantity box of the first order line when POST /orders returns lines[0].quantity in the errors dictionary.
  • Reject an expired SPRING25 discount code with a validation problem keyed on code, so the checkout page shows the message under the coupon field.
  • Make controllers and minimal API endpoints in one service produce identical error keys for the same customer payload.

More examples

Controllers: model state keys from JSON names

<code>[ApiController]</code> returns the 400 before the action runs, keyed by C# names unless <code>SystemTextJsonValidationMetadataProvider</code> is registered. With it, keys match what the client actually sent, including explicit <code>[JsonPropertyName]</code> overrides such as <code>postal_code</code>. Use classes or records with ordinary properties for these models: MVC requires validation attributes on a positional record's constructor parameters, rejects them on its properties with an <code>InvalidOperationException</code>, and the JSON-name key mapping works reliably only with property-based models.

Example · csharp
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
builder.Services.AddControllers(options =>
{
    // Keys now follow [JsonPropertyName] and the camelCase naming policy: "customerEmail", "shipTo.postal_code".
    options.ModelMetadataDetailsProviders.Add(new SystemTextJsonValidationMetadataProvider());
});

var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.MapControllers();
app.Run();

public class ShippingAddress
{
    [JsonPropertyName("postal_code"), Required]
    public string? PostalCode { get; set; }
}

public class CreateCustomer
{
    [Required, EmailAddress]
    public string? CustomerEmail { get; set; }

    [Required]
    public ShippingAddress? ShipTo { get; set; }
}

[ApiController]
[Route("api/customers")]
public sealed class CustomersController : ControllerBase
{
    [HttpPost]
    public IActionResult Create(CreateCustomer customer) =>
        CreatedAtAction(nameof(Get), new { id = 501 }, customer);

    [HttpGet("{id:int}")]
    public IActionResult Get(int id) => Ok(new { id });
}

Cross-field rules with IValidatableObject

A rule that spans two fields still belongs to a field from the user's point of view: the due date is the one to fix. Passing <code>[nameof(DueOn)]</code> as the member names puts the message under that key, so the form highlights the right input. A <code>ValidationResult</code> without member names ends up under an empty key that most front ends cannot place.

Example · csharp
using System.ComponentModel.DataAnnotations;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddValidation();
builder.Services.AddProblemDetails();

var app = builder.Build();
app.UseExceptionHandler();

app.MapPost("/invoices", (CreateInvoice invoice) => TypedResults.Created($"/invoices/{invoice.Number}", invoice));

app.Run();

public record CreateInvoice(
    [Required, RegularExpression(@"^INV-\d{4}-\d{4}$")] string Number,
    DateOnly IssuedOn,
    DateOnly DueOn,
    [Range(0.01, 1_000_000)] decimal Amount) : IValidatableObject
{
    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        if (DueOn < IssuedOn)
        {
            // Naming the member puts the message under that key instead of an empty one.
            yield return new ValidationResult("Due date must be on or after the issue date.", [nameof(DueOn)]);
        }
    }
}

Pin the error keys in a test

Error keys are an API contract with the front end, so assert them exactly. This test fails if someone removes the key rewrite, renames a property or switches validation libraries. Deserialising into <code>HttpValidationProblemDetails</code> gives a typed <code>Errors</code> dictionary, and ordering the keys keeps the assertion independent of validation order.

Example · csharp
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;

public partial class Program;

public sealed class OrderValidationTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public async Task Invalid_order_reports_camel_case_json_paths()
    {
        var ct = TestContext.Current.CancellationToken;
        var client = factory.CreateClient();

        using var response = await client.PostAsJsonAsync("/orders", new
        {
            customerEmail = "not-an-email",
            lines = new[] { new { sku = "KB-104", quantity = 0 } },
            shipTo = new { postalCode = "" }
        }, ct);

        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
        var problem = await response.Content.ReadFromJsonAsync<HttpValidationProblemDetails>(ct);
        Assert.Equal(
            ["customerEmail", "lines[0].quantity", "shipTo.postalCode"],
            problem!.Errors.Keys.Order().ToArray());
    }
}

Discussion

  • Be the first to comment on this lesson.