Endpoints and Validation

Build the orders endpoints as a route group with typed results, automatic DataAnnotations validation and ProblemDetails for every error.

Syntaxvar orders = app.MapGroup("/api/orders").WithTags("Orders"); orders.MapPost("/", PlaceOrder); // Created<OrderResponse> builder.Services.AddValidation(); builder.Services.AddProblemDetails();

The HTTP surface of the project is one route group per feature. Program.cs calls app.MapOrders(); the extension method creates MapGroup("/api/orders"), applies what every orders endpoint shares (tags, authorization, CORS, rate limiting later) and maps static handler methods. Handlers are small: bind, call the domain, return a typed result.

Typed results are the contract

A handler declared as Results<Ok<OrderResponse>, NotFound> can only return those two outcomes; the compiler rejects anything else, and the OpenAPI generator reads the same type to document 200 and 404 without Produces calls. Returning IResult loses both. Keep each handler a static method rather than a lambda so it can be unit tested by calling it and checking the returned object, for example Assert.IsType<NotFound>(result.Result).

Validation before the handler runs

builder.Services.AddValidation() (.NET 10) generates validators at compile time for parameter types used by minimal API endpoints and runs them in an endpoint filter. DataAnnotations on records, nested records and collection elements are checked; a failure short-circuits with a 400 ValidationProblem whose errors keys are paths such as Lines[0].Quantity. Use it for shape rules (required, length, range, pattern). Rules that need the database, such as "SKU exists and is active", belong in the handler and return a problem with a specific type. .DisableValidation() opts one endpoint out, for example a bulk import that validates row by row.

One error format

FailureProduced byStatus
Field rule brokenAddValidation()400 with errors
Order not found, or not yoursTypedResults.NotFound() plus UseStatusCodePages()404 problem
Illegal transitionTypedResults.Problem(statusCode: 409, type: ...)409 problem
Stale If-Match versionThe endpoint, as in the data lesson412 problem
Unhandled exceptionUseExceptionHandler()500 problem, no stack trace

AddProblemDetails() registers the writer all of these share, and CustomizeProblemDetails is the single place to add fields such as instance. Give each business problem a stable type URI; clients branch on type, never on title text.

Decisions and failure modes

  • Customer id from the token, not the body. A body field that names the customer is an invitation to place orders for someone else.
  • Validation attributes on the wrong target. Put attributes on the positional record parameters. Both AddValidation() and MVC read them there, and MVC throws an InvalidOperationException if you move them to the properties with [property: ...]. A request record shared between controllers and minimal APIs needs no second copy.
  • Unbounded collections. A Lines list without MaxLength lets one request insert ten thousand rows. Cap it, and cap the request body size.
  • Bare 404s. Without UseStatusCodePages() an empty 404 reaches the client with no body, unlike every other error.

Example

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

When to use it

  • Return 400 with Lines[0].Quantity in the errors dictionary when a basket line asks for 0 or 500 units, before any handler code runs.
  • Answer POST /api/orders/1001/cancel on a shipped order with a 409 problem whose type URI the web shop maps to a friendly message.
  • Keep Program.cs to one app.MapOrders() call while the orders group carries tags, authorization and rate limiting for every endpoint.

More examples

Mapping domain outcomes to typed results

Expected failures come back from the service as values, and the endpoint is the only place that knows HTTP. Cancelling an order that is already cancelled returns 204 again, which makes the command safe to retry. The <code>UnreachableException</code> arm turns a newly added enum member into a loud 500 in tests instead of a silent default. Exceptions stay reserved for bugs and infrastructure failures.

Example · csharp
using System.Diagnostics;
using Microsoft.AspNetCore.Http.HttpResults;

public enum CancelOutcome { Cancelled, NotFound, AlreadyShipped, AlreadyCancelled }

public interface IOrderService
{
    Task<CancelOutcome> CancelAsync(long orderId, string customerId, CancellationToken ct);
}

public static class CancelOrderEndpoint
{
    public static async Task<Results<NoContent, NotFound, ProblemHttpResult>> Handle(
        long id, HttpContext http, IOrderService orders, CancellationToken ct)
    {
        var customerId = http.User.FindFirst("sub")?.Value ?? throw new InvalidOperationException("No sub claim.");

        return await orders.CancelAsync(id, customerId, ct) switch
        {
            CancelOutcome.Cancelled or CancelOutcome.AlreadyCancelled => TypedResults.NoContent(),
            CancelOutcome.NotFound => TypedResults.NotFound(),
            CancelOutcome.AlreadyShipped => TypedResults.Problem(
                statusCode: StatusCodes.Status409Conflict,
                title: "Order already shipped",
                type: "https://errors.example.com/orders/already-shipped"),
            var other => throw new UnreachableException($"Unhandled outcome {other}."),
        };
    }
}

Turning a concurrency conflict into a 409 problem

A handler that forgets to catch <code>DbUpdateConcurrencyException</code> would otherwise produce a 500 and page someone at night. The 409 here is for conflicts without an <code>If-Match</code>, such as two server-side writers racing; a client whose <code>If-Match</code> version is stale gets 412 Precondition Failed from the endpoint itself, as in the data lesson. Returning <code>false</code> for every other exception lets the default 500 problem through. In .NET 10 an exception handled by an <code>IExceptionHandler</code> is no longer logged as an error by the middleware, so log the conflict at Information inside the handler if you want to count them.

Example · csharp
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<ConcurrencyExceptionHandler>();

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

public sealed class ConcurrencyExceptionHandler(IProblemDetailsService problems) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(HttpContext http, Exception exception, CancellationToken ct)
    {
        if (exception is not DbUpdateConcurrencyException) return false;

        http.Response.StatusCode = StatusCodes.Status409Conflict;
        return await problems.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = http,
            Exception = exception,
            ProblemDetails = new ProblemDetails
            {
                Status = StatusCodes.Status409Conflict,
                Title = "The order was changed by another request",
                Type = "https://errors.example.com/orders/concurrency-conflict",
                Detail = "Reload the order and apply your change again.",
            },
        });
    }
}

What a client receives for a broken basket

Every field error for the request arrives at once, keyed by the C# member path. The front end maps keys to form fields, so pin this shape with an integration test; if clients want camelCase JSON paths such as <code>lines[0].quantity</code>, rewrite the keys once in <code>CustomizeProblemDetails</code>. The <code>traceId</code> is the request's W3C <code>traceparent</code> value; the 32-hex segment after <code>00-</code> is the trace id to search for in the tracing UI, or write <code>Activity.Current.TraceId</code> into the problem in <code>CustomizeProblemDetails</code> to give support staff only that part.

Example · json
{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "instance": "POST /api/orders",
  "errors": {
    "Lines[0].Sku": [ "The field Sku must match the regular expression '^[A-Z0-9-]{3,32}$'." ],
    "Lines[0].Quantity": [ "The field Quantity must be between 1 and 100." ]
  },
  "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}

Discussion

  • Be the first to comment on this lesson.