Validating Input
Rules that reject bad input before your code runs: data annotations, AddValidation in .NET 10, and checks you write by hand.
builder.Services.AddValidation(); // then [Required], [Range(13, 120)] on parameters and propertiesEverything in a request was typed by someone you cannot see, and some of them are hostile. Validation is the step that checks the request against rules you wrote, and rejects it before your code reaches a database.
Binding has already checked shapes: an int parameter holds a number or the request was a 400. Validation checks meaning: an age is between 13 and 120, an email address has an at sign, a note is under 500 characters.
Rules as attributes
A data annotation is an attribute placed on a property or parameter to state a rule. [Required], [Range], [StringLength] and [EmailAddress] live in System.ComponentModel.DataAnnotations. .NET 10 added automatic enforcement of these in minimal APIs: call builder.Services.AddValidation(), and a failing request stops with 400 and a body naming each offending member. A build-time source generator produces the metadata, so the call must be made in the assembly that declares the types being validated.
Checks you write yourself
An attribute sees one value. A rule that compares two properties belongs in IValidatableObject.Validate, which runs after the per-property rules pass. A rule that needs a service can derive from ValidationAttribute and resolve one through the validation context. And a plain if that returns TypedResults.ValidationProblem is a perfectly good rule.
Hostile until proven otherwise
Validation in the browser is a courtesy to honest users; anyone can post to your endpoint directly. Put a length limit on every string, prefer a list of allowed values over a list of banned ones, and validate on the server every time.
Example
using System.ComponentModel.DataAnnotations;
var builder = WebApplication.CreateBuilder(args);
// .NET 10 and later. Without this call, no automatic validation runs
// and invalid input reaches the handler.
builder.Services.AddValidation();
var app = builder.Build();
app.MapPost("/signups", (Signup signup) =>
// Reached only after every rule on Signup has passed.
TypedResults.Created($"/signups/{signup.Email}", signup));
app.Run();
public class Signup
{
[Required]
[EmailAddress]
public string Email { get; set; } = "";
[Range(13, 120)]
public int Age { get; set; }
[StringLength(40, MinimumLength = 2)]
public string DisplayName { get; set; } = "";
}When to use it
- Reject a sign-up whose email address is malformed before touching the database.
- Enforce a maximum length on every free-text field so one request cannot store a megabyte of prose.
- Check that an end date falls on or after a start date, which no single-property rule can see.
- Return every problem with a submitted form at once, instead of one error per round trip.
More examples
The same rules on a record
A record declares the properties, the constructor and the equality members in one line, and validation attributes work on its positional parameters. Reach for this shape for request types, which are usually read once and thrown away. Use the longer class form when you need property-level defaults, extra members, or room for comments on each rule.
using System.ComponentModel.DataAnnotations;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddValidation();
var app = builder.Build();
app.MapPost("/signups", (Signup signup) => TypedResults.Ok(signup.Email));
app.Run();
// Shorthand for a class with three properties and a constructor that sets them.
// The attributes on the positional parameters are the same rules as the ones
// written above each property in the longer form.
public record Signup(
[Required][EmailAddress] string Email,
[Range(13, 120)] int Age,
[StringLength(40, MinimumLength = 2)] string DisplayName);A rule that spans two properties
An attribute on End cannot reliably see Start, so a comparison between the two belongs in IValidatableObject. Naming the member in the ValidationResult is what attaches the message to the End field in the response rather than to the object as a whole. In .NET 10 the Microsoft.Extensions.Validation attributes are published as experimental, so a plain class library using them reports warning ASP0029.
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.Validation;
// [ValidatableType] marks a root type for the validation source generator.
// Types reachable from it are included automatically.
[ValidatableType]
public class Booking : IValidatableObject
{
[Required]
public string Reference { get; set; } = "";
public DateOnly Start { get; set; }
public DateOnly End { get; set; }
// Runs after the per-property rules, and only if they all passed.
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (End < Start)
{
yield return new ValidationResult(
"End date must fall on or after the start date.",
[nameof(End)]);
}
if (End.DayNumber - Start.DayNumber > 28)
{
yield return new ValidationResult(
"A booking cannot be longer than 28 days.",
[nameof(End)]);
}
}
}A hand-written check and the response it produces
Not every rule fits an attribute, and a few lines of plain C# are easier to read than a custom attribute you use once. ValidationProblem takes a dictionary of member name to messages and produces the same 400 shape the automatic validation produces, so clients handle both the same way. Both branches here use the Results helpers, which all return IResult, so the two different results share one return type.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapPost("/transfers", (Transfer transfer) =>
{
Dictionary<string, string[]> errors = new();
if (transfer.Amount <= 0)
{
errors["amount"] = ["Amount must be greater than zero."];
}
if (transfer.From == transfer.To)
{
errors["to"] = ["Cannot transfer to the same account."];
}
if (errors.Count > 0)
{
// 400 with a problem body whose "errors" member is this dictionary.
return Results.ValidationProblem(errors);
}
return Results.Accepted("/transfers/1");
});
app.Run();
record Transfer(string From, string To, decimal Amount);
Discussion