Model Binding and Validation
Before you start
You need: controllers (Article 05).
Time: about 50 minutes, plus the practice.
Learning objective
Bind request data safely and validate it so that no invalid or unauthorised value reaches your service layer.
Topics
- Where binding takes values from
- Binding source attributes
- Complex types and collections
- Over-posting and DTOs
- Data annotations
ModelStateand automatic 400- Custom validation
ProblemDetailsfor validation errors- Diagnosing a binding failure
Where values come from
Model binding populates action parameters from the request. With [ApiController], the source is inferred:
| Parameter type | Inferred source |
|---|---|
| Complex type | Request body |
| Simple type matching a route token | Route |
| Other simple types | Query string |
IFormFile | Form |
| Registered service | DI |
[HttpGet("{publicId:guid}")]
public async Task<IActionResult> GetById(
Guid publicId, // route
bool includeResults, // query string
CancellationToken ct) // framework
[HttpPost]
public async Task<IActionResult> Create(
StudentCreateRequest request, // body
CancellationToken ct)
Without [ApiController] the inference does not happen, and a complex type binds from form values rather than the body — so a JSON POST arrives with every property at its default and no error. That alone is a reason to use the attribute.
Explicit sources
[HttpPost("{schoolId:int}/students")]
public async Task<IActionResult> Create(
[FromRoute] int schoolId,
[FromQuery] bool notifyParent,
[FromBody] StudentCreateRequest request,
[FromHeader(Name = "X-Correlation-Id")] string? correlationId,
[FromServices] IStudentService studentService,
CancellationToken ct)
| Attribute | Source |
|---|---|
[FromRoute] | Route values |
[FromQuery] | Query string |
[FromBody] | Request body — at most one per action |
[FromForm] | Form fields |
[FromHeader] | A header |
[FromServices] | The DI container |
Only one parameter can be [FromBody]. The body is a forward-only stream read once. Two [FromBody] parameters produce a runtime error; combine them into one model instead.
Names are matched case-insensitively:
[FromQuery(Name = "class")]
public string? ClassName { get; set; }
Useful when the query parameter name is a C# keyword or does not match your property naming.
Complex types and collections
public class StudentSearchRequest
{
public string? Term { get; set; }
public string? ClassName { get; set; }
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 20;
}
[HttpGet]
public async Task<IActionResult> Search([FromQuery] StudentSearchRequest request,
CancellationToken ct)
GET /api/students?term=Ravi&className=10th&page=2 binds each property. Grouping query parameters into a class keeps the signature readable and gives one place to put validation attributes.
// ?ids=1&ids=2&ids=3
public async Task<IActionResult> GetMany([FromQuery] int[] ids)
// JSON array body
public async Task<IActionResult> SaveResults([FromBody] List<ExamResultRequest> results)
Nested objects in a form use dotted names:
<input name="Input.Address.City" />
public class StudentInput
{
public AddressInput Address { get; set; } = new();
}
A name mismatch fails silently. The property keeps its default, ModelState reports nothing wrong, and the save writes empty data. In Razor, asp-for generates the name, which is why hand-writing it is a bug waiting to happen.
Over-posting
The security problem in model binding.
// Dangerous — binds every property on the entity
[HttpPost]
public async Task<IActionResult> Create(Student student, CancellationToken ct)
{
await _repository.CreateAsync(student, ct);
return Ok();
}
{
"name": "Ravi Kumar",
"rollNumber": "NCA-2024-0012",
"schoolId": 7,
"status": 2,
"id": 999
}
The client sets SchoolId and moves the student to another school. Your form never rendered that field; the binder does not know or care what the form showed.
Bind a purpose-built request model containing only what the caller may set:
public class StudentCreateRequest
{
[Required, StringLength(100, MinimumLength = 2)]
public string Name { get; set; } = string.Empty;
[Required]
[RegularExpression(@"^NCA-\d{4}-\d{4}$", ErrorMessage = "Format: NCA-2024-0012")]
public string RollNumber { get; set; } = string.Empty;
[Required, StringLength(10)]
public string ClassName { get; set; } = string.Empty;
[Required, StringLength(1)]
public string Section { get; set; } = string.Empty;
[Required]
public DateOnly? DateOfBirth { get; set; }
[Required, StringLength(100)]
public string ParentName { get; set; } = string.Empty;
[Required]
[RegularExpression(@"^[6-9]\d{9}$", ErrorMessage = "Enter a valid 10-digit mobile number.")]
public string ParentPhone { get; set; } = string.Empty;
[StringLength(250)]
public string? Address { get; set; }
}
[HttpPost]
public async Task<ActionResult<StudentDto>> Create(
StudentCreateRequest request, CancellationToken ct)
{
var schoolId = User.GetSchoolId(); // from the token, never the request
var created = await _studentService.CreateAsync(schoolId, request, ct);
return CreatedAtAction(nameof(GetById), new { publicId = created.PublicId }, created);
}
Id, PublicId, SchoolId and Status are absent from the request model, so they cannot be supplied. SchoolId comes from the authentication claim.
Never expose an entity as a request or response model. The request side is over-posting; the response side means a new PasswordHash column is published to every client automatically.
Data annotations
| Attribute | Checks |
|---|---|
[Required] | Not null, not empty |
[StringLength(100, MinimumLength = 2)] | Length |
[Range(0, 100)] | Numeric or date range |
[RegularExpression] | Pattern |
[EmailAddress], [Phone], [Url] | Format |
[Compare("Password")] | Two properties match |
[MaxLength] / [MinLength] | Collection or string length |
The non-nullable trap
[Required]
public DateTime DateOfBirth { get; set; } // defaults to 01/01/0001 — passes
[Required]
public decimal Amount { get; set; } // defaults to 0 — passes
A non-nullable value type always has a value, so [Required] never fails. A blank date silently saves as year 1, and a blank amount as zero.
[Required(ErrorMessage = "Date of birth is required.")]
public DateOnly? DateOfBirth { get; set; }
[Required]
[Range(1, 1000000)]
public decimal? Amount { get; set; }
Make it nullable so "not supplied" is representable. This is the single most common validation bug in ASP.NET Core.
With nullable reference types enabled, a non-nullable string property is treated as implicitly required — so Name fails validation when omitted even without [Required]. Being explicit is still better, because the error message is yours.
ModelState and automatic 400
[ApiController]
public class StudentsController : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Create(StudentCreateRequest request, CancellationToken ct)
{
// No ModelState check needed — [ApiController] returns 400 before this runs
}
}
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"RollNumber": ["Format: NCA-2024-0012"],
"ParentPhone": ["Enter a valid 10-digit mobile number."]
}
}
That errors object is what a frontend maps to field-level messages. It is standard ValidationProblemDetails, so every client can rely on the shape.
Without [ApiController] you must check yourself, and forgetting it in one action is all it takes:
if (!ModelState.IsValid)
{
return ValidationProblem(ModelState);
}
Adding an error from the action:
if (await _studentService.RollNumberExistsAsync(schoolId, request.RollNumber, null, ct))
{
ModelState.AddModelError(nameof(request.RollNumber),
"This roll number is already used by another student.");
return ValidationProblem(ModelState);
}
The key must match the property name the client sent, or the message lands in a general bucket the frontend cannot attach to a field.
Custom validation
For rules annotations cannot express.
public class AgeForClassAttribute : ValidationAttribute
{
protected override ValidationResult? IsValid(object? value, ValidationContext context)
{
if (value is not DateOnly dateOfBirth)
{
return ValidationResult.Success; // [Required] handles absence
}
var request = (StudentCreateRequest)context.ObjectInstance;
var age = DateOnly.FromDateTime(DateTime.UtcNow).Year - dateOfBirth.Year;
if (age < 3 || age > 25)
{
return new ValidationResult(
"Date of birth gives an age outside the accepted range.",
new[] { context.MemberName! });
}
return ValidationResult.Success;
}
}
Returning Success for a null value matters — otherwise a custom attribute duplicates [Required] and produces two messages for one omission.
For a rule spanning several properties, implement IValidatableObject:
public class ExamResultRequest : IValidatableObject
{
public int StudentId { get; set; }
public decimal? MarksObtained { get; set; }
public bool IsAbsent { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext context)
{
if (IsAbsent && MarksObtained is not null)
{
yield return new ValidationResult(
"An absent student cannot have marks.",
new[] { nameof(MarksObtained) });
}
if (!IsAbsent && MarksObtained is null)
{
yield return new ValidationResult(
"Enter marks, or mark the student absent.",
new[] { nameof(MarksObtained) });
}
}
}
Validate runs after every property-level attribute passes, so it can assume the individual values are shaped correctly.
Rules needing the database belong in the service, not in an attribute. A validation attribute resolving a repository is testable only with a container, and it runs before you know the caller is authorised.
Where each rule belongs
| Rule | Where |
|---|---|
| Required, length, format, range | Data annotation on the request model |
| Cross-property (absent versus marks) | IValidatableObject |
| Uniqueness, existence, business limits | Service layer |
| Absolute guarantee | Database constraint |
public async Task<StudentDto> CreateAsync(
int schoolId, StudentCreateRequest request, CancellationToken ct)
{
if (await _repository.RollNumberExistsAsync(schoolId, request.RollNumber, null, ct))
{
throw new DuplicateRollNumberException(request.RollNumber);
}
var student = new Student
{
SchoolId = schoolId, // from the claim, not the request
Status = StudentStatus.Active, // server-decided
Name = request.Name.Trim(),
RollNumber = request.RollNumber,
// ...
};
await _repository.CreateAsync(student, ct);
return MapToDto(student);
}
The service check gives a clean message. The database unique constraint wins when two callers submit simultaneously — no application check can close that window.
ProblemDetails
builder.Services.AddProblemDetails();
builder.Services.Configure<ApiBehaviorOptions>(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var problem = new ValidationProblemDetails(context.ModelState)
{
Status = StatusCodes.Status400BadRequest,
Title = "Validation failed",
Instance = context.HttpContext.Request.Path
};
problem.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
return new BadRequestObjectResult(problem);
};
});
Adding traceId to every validation response lets a user quote it in a support ticket and someone find the exact request in the logs.
Diagnosing a binding failure
| Symptom | Cause |
|---|---|
| Every property is default | Name mismatch, or [FromBody] missing without [ApiController] |
| 415 Unsupported Media Type | No Content-Type: application/json on the request |
| 400 with a JSON parse error | Malformed body, or a type mismatch such as a string for an int |
| One property is default | That property name does not match |
A date arrives as 01/01/0001 | Non-ISO format, or a non-nullable DateTime with [Required] |
Blank passes [Required] | Non-nullable value type |
| Query array does not bind | Wrong format — repeat the key, do not comma-separate |
| An unexpected field was saved | Over-posting — bind a DTO |
Inspect what was actually sent before assuming the binder is wrong:
app.Use(async (context, next) =>
{
if (app.Environment.IsDevelopment() && context.Request.ContentLength > 0)
{
context.Request.EnableBuffering();
using var reader = new StreamReader(
context.Request.Body, leaveOpen: true);
var body = await reader.ReadToEndAsync();
context.Request.Body.Position = 0;
app.Logger.LogDebug("Request body: {Body}", body);
}
await next(context);
});
EnableBuffering allows the body to be read twice; resetting Position is essential or the binder finds an empty stream.
To see what the binder produced:
foreach (var entry in ModelState)
{
_logger.LogDebug("{Key}: value={Value}, errors={Errors}",
entry.Key,
entry.Value.AttemptedValue,
string.Join("; ", entry.Value.Errors.Select(e => e.ErrorMessage)));
}
AttemptedValue shows the raw string the binder received — which immediately distinguishes "not sent" from "sent and rejected".
Errors you will hit
| Message | Cause | Fix |
|---|---|---|
| 415 Unsupported Media Type | Content-Type: application/json not sent | Send the header |
The request object is null | Missing [FromBody], or no Content-Type | Add both |
A property is null though it is in the payload | Name mismatch, or no public setter | Match names; add setters |
400 with an errors object | Validation failed — read it, it names the field | Fix the request |
| A breakpoint in the action never hits | [ApiController] rejected it before your code | Read the 400 body |
id is 0 | Route parameter name mismatch | Match the names |
With [ApiController], validation failures return 400 before your action runs. The response body names the field and the rule — read it rather than guessing.
Common mistakes
- Binding the entity rather than a request DTO — over-posting
- Taking
SchoolIdfrom the request instead of the claim [Required]on a non-nullable value type- Hand-written
nameattributes, so binding silently fails - Missing
[ApiController], then forgetting aModelStatecheck - Two
[FromBody]parameters - Returning entities as response models
- A
ModelStatekey that does not match the property name - A database-dependent rule implemented as a validation attribute
- A duplicate check with no matching unique constraint
- Assuming client-side validation is a control
- Reading the request body without
EnableBuffering, leaving it empty for the binder
Practice
The course exercise is validate a request DTO.
- Build
StudentCreateRequestwith full annotations. POST an invalid body and read theerrorsobject. - Bind
Studentinstead. POST an extra"schoolId": 7and confirm the student is created in another school. - Switch to the DTO and confirm the extra field is ignored.
- Add
[Required] DateTime DateOfBirth(non-nullable). POST without it and confirm validation passes with01/01/0001. - Change it to
DateOnly?and confirm it now fails correctly. - Remove
[ApiController]. POST an invalid body and confirm the action runs with invalid state. - Add it back and confirm the automatic 400.
- POST without
Content-Type: application/json. Record the 415. - Implement
IValidatableObjectonExamResultRequestfor the absent/marks rule. Test both violations. - Add a service-layer duplicate check throwing
DuplicateRollNumberException, mapped to 409. - Remove the database unique constraint and submit the same roll number from two clients simultaneously. Confirm the duplicate.
- Restore the constraint and repeat. Confirm one succeeds and one gets 409.
- Add
traceIdto validation responses and find the matching log entry. - Log the raw request body with
EnableBuffering. Forget to resetPositionand confirm every property binds as default.
Exercises 2, 4 and 11 correspond to a tenant breach, a silent data defect and a race condition.
You can now
- Bind request data from route, query and body
- Send the right
Content-Typeand say what 415 means - Read the
errorsobject from a 400 - Put each validation rule at the correct layer
- Say why server-side validation is the control
Review questions
- What is over-posting, and what prevents it?
- Why does
[Required]fail to catch a missingDateTime? - Which validation rules belong in the service rather than in an attribute?
- Why keep both a service duplicate check and a database unique constraint?
Next: REST API design