Skip to main content
Published / updated

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
  • ModelState and automatic 400
  • Custom validation
  • ProblemDetails for 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 typeInferred source
Complex typeRequest body
Simple type matching a route tokenRoute
Other simple typesQuery string
IFormFileForm
Registered serviceDI
[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)
AttributeSource
[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

AttributeChecks
[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

RuleWhere
Required, length, format, rangeData annotation on the request model
Cross-property (absent versus marks)IValidatableObject
Uniqueness, existence, business limitsService layer
Absolute guaranteeDatabase 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

SymptomCause
Every property is defaultName mismatch, or [FromBody] missing without [ApiController]
415 Unsupported Media TypeNo Content-Type: application/json on the request
400 with a JSON parse errorMalformed body, or a type mismatch such as a string for an int
One property is defaultThat property name does not match
A date arrives as 01/01/0001Non-ISO format, or a non-nullable DateTime with [Required]
Blank passes [Required]Non-nullable value type
Query array does not bindWrong format — repeat the key, do not comma-separate
An unexpected field was savedOver-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

MessageCauseFix
415 Unsupported Media TypeContent-Type: application/json not sentSend the header
The request object is nullMissing [FromBody], or no Content-TypeAdd both
A property is null though it is in the payloadName mismatch, or no public setterMatch names; add setters
400 with an errors objectValidation failed — read it, it names the fieldFix the request
A breakpoint in the action never hits[ApiController] rejected it before your codeRead the 400 body
id is 0Route parameter name mismatchMatch 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 SchoolId from the request instead of the claim
  • [Required] on a non-nullable value type
  • Hand-written name attributes, so binding silently fails
  • Missing [ApiController], then forgetting a ModelState check
  • Two [FromBody] parameters
  • Returning entities as response models
  • A ModelState key 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.

  1. Build StudentCreateRequest with full annotations. POST an invalid body and read the errors object.
  2. Bind Student instead. POST an extra "schoolId": 7 and confirm the student is created in another school.
  3. Switch to the DTO and confirm the extra field is ignored.
  4. Add [Required] DateTime DateOfBirth (non-nullable). POST without it and confirm validation passes with 01/01/0001.
  5. Change it to DateOnly? and confirm it now fails correctly.
  6. Remove [ApiController]. POST an invalid body and confirm the action runs with invalid state.
  7. Add it back and confirm the automatic 400.
  8. POST without Content-Type: application/json. Record the 415.
  9. Implement IValidatableObject on ExamResultRequest for the absent/marks rule. Test both violations.
  10. Add a service-layer duplicate check throwing DuplicateRollNumberException, mapped to 409.
  11. Remove the database unique constraint and submit the same roll number from two clients simultaneously. Confirm the duplicate.
  12. Restore the constraint and repeat. Confirm one succeeds and one gets 409.
  13. Add traceId to validation responses and find the matching log entry.
  14. Log the raw request body with EnableBuffering. Forget to reset Position and 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-Type and say what 415 means
  • Read the errors object from a 400
  • Put each validation rule at the correct layer
  • Say why server-side validation is the control

Review questions

  1. What is over-posting, and what prevents it?
  2. Why does [Required] fail to catch a missing DateTime?
  3. Which validation rules belong in the service rather than in an attribute?
  4. Why keep both a service duplicate check and a database unique constraint?

Next: REST API design