Model validation and problem details
Data annotations, the attributes that quietly pass on null, IValidatableObject, and the 400 response clients actually receive.
After this lesson you can
- Validate a model with data annotations and read the results
- Explain why [Range] does not reject a missing value
- Know when IValidatableObject.Validate runs
- Describe the problem details body of a validation failure
ASP.NET Core's built-in validation is data annotations, from
System.ComponentModel.DataAnnotations:
public class CreateUser
{
[Required] public string? Name { get; set; }
[EmailAddress] public string? Email { get; set; }
[Range(18, 120)] public int? Age { get; set; }
[MaxLength(5)] public string? Code { get; set; }
}
In a controller marked [ApiController], model binding runs these
attributes and, if any fail, the action never executes: the framework
answers 400 with a ValidationProblemDetails body. Minimal APIs in
.NET 9 do not validate for you; you call a validator yourself, usually
from an endpoint filter. (.NET 10 adds opt-in validation for minimal
APIs; this course targets .NET 9.)
Data annotations live in the base class library, so the example runs the
real Validator, not a model of it.
using System.ComponentModel.DataAnnotations; public class CreateUser{ [Required] public string? Name { get; set; } [EmailAddress] public string? Email { get; set; } [Range(18, 120)] public int? Age { get; set; } [MaxLength(5)] public string? Code { get; set; }} public static class Solution{ static string Check(string label, CreateUser user, bool all) { var results = new List<ValidationResult>(); var ok = Validator.TryValidateObject(user, new ValidationContext(user), results, validateAllProperties: all); return label + (ok ? ": valid" : ": " + string.Join(" | ", results.Select(r => r.ErrorMessage))); } public static List<string> Run() { var bad = new CreateUser { Name = " ", Email = "not-an-email", Age = 7, Code = "TOOLONG" }; var sparse = new CreateUser { Name = "Ann" }; return new List<string> { Check("bad, default", bad, all: false), Check("bad, all properties", bad, all: true), Check("sparse, all properties", sparse, all: true), }; }}Two things in that output surprise people. TryValidateObject with its
default validateAllProperties: false checks only [Required];
pass true to run every attribute. And the second user, whose email,
age and code are all null, is valid: [Range], [EmailAddress] and
[MaxLength] all accept null. Only [Required] rejects a missing value,
so a field that must be present and in range needs both attributes.
Cross-field rules
A rule involving two properties goes in IValidatableObject.Validate.
It runs only after every property attribute has passed.
using System.ComponentModel.DataAnnotations; public class Booking : IValidatableObject{ [Required] public string? Guest { get; set; } public DateTime From { get; set; } public DateTime To { get; set; } public IEnumerable<ValidationResult> Validate(ValidationContext context) { if (To <= From) yield return new ValidationResult("To must be after From.", new[] { nameof(To) }); }} public static class Solution{ static string Check(Booking b) { var results = new List<ValidationResult>(); Validator.TryValidateObject(b, new ValidationContext(b), results, validateAllProperties: true); return results.Count == 0 ? "valid" : string.Join(" | ", results.Select(r => r.ErrorMessage)); } public static List<string> Run() { var backwards = new DateTime(2025, 5, 1); return new List<string> { Check(new Booking { Guest = null, From = backwards, To = backwards.AddDays(-2) }), Check(new Booking { Guest = "Ann", From = backwards, To = backwards.AddDays(-2) }), Check(new Booking { Guest = "Ann", From = backwards, To = backwards.AddDays(2) }), }; }}The response
A failed validation is returned in the problem details format
(application/problem+json, RFC 9457, which replaced RFC 7807):
status, title, and an errors object mapping each field name to an
array of messages. Grouping is per field, so one field can carry several
messages. Calling builder.Services.AddProblemDetails() makes the
exception handler and status code pages produce the same format for
other errors, so clients parse one shape everywhere. A trimmed example
(the real body also carries type and traceId):
{
"title": "One or more validation errors occurred.",
"status": 400,
"errors": { "Age": ["The field Age must be between 18 and 120."] }
}
Common mistakes
- Relying on
[Range]or[EmailAddress]alone for a field that must be present. Null passes; add[Required]. - Declaring the property as a non-nullable
intand adding[Required]. A missing JSON property binds as0, which[Required]accepts; useint?. - Calling
Validator.TryValidateObjectwithout and concluding the model is valid.
Exercise · Pro
Bind a JSON body, then validate it
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade