Model Validation
By the end of this lesson
Validate incoming models and return precise field-level errors.
Every request that carries data is a request you did not write. Someone else's code built the JSON, and it may be wrong by accident or wrong on purpose. Validation is the step where your API decides whether the data it received is usable before anything acts on it.
Two terms first. Model binding is the step where ASP.NET Core reads the incoming request and fills in the parameters of your action method — JSON body into an object, route segments and query string into values. Validation runs immediately after binding, and its results are collected in a dictionary called ModelState: one entry per field, each holding any error messages for that field.
The goal is not a yes or no answer. The goal is a response precise enough that the client can put a message next to the offending input.
One thing to know before the code, because it surprises people. With the [ApiController] attribute on your controller, you write no code at all to check the shape rules below. If validation fails, the framework returns a 400 and your action never runs.
That behaviour comes from a filter the attribute adds, which inspects ModelState before the action body executes. So the first line of your action can assume the annotated rules passed.
using System.ComponentModel.DataAnnotations;
public sealed class CreateEmployeeRequest
{
[Required(ErrorMessage = "A full name is required.")]
[StringLength(120, MinimumLength = 2)]
public string FullName { get; set; } = string.Empty;
[Required]
[EmailAddress]
public string WorkEmail { get; set; } = string.Empty;
// Range, not Required: see the note under this block.
[Range(1, int.MaxValue, ErrorMessage = "Choose a department.")]
public int DepartmentId { get; set; }
[Range(0, 500_000, ErrorMessage = "Annual salary must be between 0 and 500000.")]
public decimal AnnualSalary { get; set; }
[RegularExpression("^[A-Z]{3}-[0-9]{4}$",
ErrorMessage = "Payroll reference must look like HRD-0148.")]
public string PayrollReference { get; set; } = string.Empty;
}- These attributes are called data annotations. Each one is a rule the framework checks after binding, and each carries the message the client will receive when it fails.
- Required means the value must be present and, for a string, not empty. StringLength sets a maximum and an optional minimum.
- Range on DepartmentId does the work Required cannot. A missing int binds to 0, and 0 is a perfectly valid int, so Required never fails on it. Requiring a value of at least 1 is what actually rejects an absent department.
- RegularExpression is for a format you own, such as an internal reference. Keep the pattern anchored at both ends with ^ and $ so a partial match cannot slip through.
- Write the annotations on a request model rather than on your database entity. The shape a client is allowed to send and the shape you store are different concerns, and they drift apart the moment one of them changes.
[ApiController]
[Route("api/employees")]
public sealed class EmployeesController(IEmployeeService employees) : ControllerBase
{
[HttpPost]
public async Task<ActionResult<EmployeeResponse>> Create(
CreateEmployeeRequest request,
CancellationToken cancellationToken)
{
// Reaching this line means every annotated rule already passed.
EmployeeResponse created = await employees.CreateAsync(request, cancellationToken);
return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
}
[HttpGet("{id:int}")]
public async Task<ActionResult<EmployeeResponse>> GetById(
int id,
CancellationToken cancellationToken)
{
EmployeeResponse? employee = await employees.FindAsync(id, cancellationToken);
return employee is null ? NotFound() : Ok(employee);
}
}- [ApiController] switches on several API conventions at once. The one that matters here is the automatic 400 for an invalid model.
- Because the framework checks first, there is no if statement at the top of Create. Adding one would not be wrong, it would be unreachable.
- The CancellationToken parameter is bound automatically and is passed down so work stops if the client disconnects.
- If you ever need to take the check back into your own hands — to log the invalid payload, or to reshape the response — set SuppressModelStateInvalidFilter on ApiBehaviorOptions and check ModelState.IsValid yourself. Do that deliberately, not by accident.
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"traceId": "00-4bd9f0c1a7e34f52-9c1f2ab7-00",
"errors": {
"WorkEmail": [
"The WorkEmail field is not a valid e-mail address."
],
"DepartmentId": [
"Choose a department."
],
"PayrollReference": [
"Payroll reference must look like HRD-0148."
]
}
}- This shape is called ValidationProblemDetails. It is the standard problem-details format with one addition: an errors object.
- Each key in errors is a field name from your request model, and each value is the list of messages for that field. That is what lets a form highlight the email box and the department selector, rather than showing one vague banner.
- A field can carry more than one message, so clients should render the whole array, not the first item.
- traceId ties this response to your server logs. Ask a caller for it and you can find the exact request. The logging lesson later in this module covers how that identifier is produced.
[HttpPost]
public async Task<ActionResult<EmployeeResponse>> Create(
CreateEmployeeRequest request,
CancellationToken cancellationToken)
{
if (!await departments.ExistsAsync(request.DepartmentId, cancellationToken))
{
ModelState.AddModelError(
nameof(request.DepartmentId),
"That department does not exist.");
}
if (await employees.EmailInUseAsync(request.WorkEmail, cancellationToken))
{
ModelState.AddModelError(
nameof(request.WorkEmail),
"Another employee already uses that work email.");
}
if (!ModelState.IsValid)
{
return ValidationProblem(ModelState);
}
EmployeeResponse created = await employees.CreateAsync(request, cancellationToken);
return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
}- An attribute can check the shape of a value. It cannot ask the database a question, because it has no access to your services and runs before any of that work.
- So rules that need data live in the action. Add each failure to ModelState against the field it belongs to, using nameof so a property rename cannot leave a stale string behind.
- ValidationProblem(ModelState) returns exactly the same 400 body the framework produces automatically. The client sees one consistent format whether the rule was an annotation or a database lookup.
- Collect all the failures before returning. Returning on the first one makes a caller fix their request one field per round trip.
Summary
- Model binding fills your action parameters; validation runs next and records per-field results in ModelState
- Data annotations express shape rules; [ApiController] turns a failed check into an automatic 400 before your action runs
- The response format is ValidationProblemDetails, whose errors object is keyed by field so a client can highlight the right input
- Rules that need a database lookup belong in the action, added to ModelState and returned with ValidationProblem for one consistent shape
- Browser validation is feedback; the server is the only place a rule is actually enforced
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Validate a department request
Write a CreateDepartmentRequest with three fields: Name, a four-character Code such as HRD1, and CostCentre, which must be a positive number.
Add annotations so that a name shorter than two characters, a badly formatted code, and a cost centre of zero are each rejected with their own message.
Then write out, by hand, the 400 body you expect when all three are wrong.
Show solution
Name needs Required plus StringLength with a minimum. Code needs Required plus a RegularExpression anchored at both ends. CostCentre is an int, so it needs Range starting at 1 rather than Required.
The reason to write the expected response by hand is that it forces you to look at the contract from the client's side. You will notice that the keys in errors are your property names, which means renaming a property is a breaking change for anyone showing field-level messages.
There is a defensible alternative for Code: accept any casing and normalise it before storing, rather than rejecting lowercase. Validation and normalisation are different decisions, and mixing them is how you end up rejecting requests that were never really wrong.
public sealed class CreateDepartmentRequest
{
[Required]
[StringLength(80, MinimumLength = 2)]
public string Name { get; set; } = string.Empty;
[Required]
[RegularExpression("^[A-Z]{3}[0-9]$",
ErrorMessage = "Code must be three capital letters then one digit, such as HRD1.")]
public string Code { get; set; } = string.Empty;
[Range(1, int.MaxValue, ErrorMessage = "Cost centre must be a positive number.")]
public int CostCentre { get; set; }
}Think about it
Two callers, one rule
Your API rejects a salary above 500000. The web front end also checks it before submitting, so users never see a server error.
A scheduled payroll import starts calling the same endpoint directly. What happens, and what would have happened if the rule lived only in the front end?
Show solution
With the rule on the server, the import gets a 400 naming the salary field and the bad row is rejected. The rule holds for a caller nobody had in mind when the form was written.
With the rule only in the front end, the import writes the value straight through. Nothing fails, nothing is logged as an error, and the problem surfaces later as incorrect data that somebody has to unpick by hand.
This is the practical argument for duplicating rules. It looks like repetition, but the two copies serve different purposes: one is feedback, one is enforcement. Only the enforcement copy is load-bearing.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.