Model Binding and Validation: Admitting a Student – Web API | Lesson 73
Introduction
So far, every endpoint has only read data. Admitting a student is the first one that receives data: the client sends JSON in the request body, and the API turns it into a C# object.
Turning request data into C# parameters is model binding. Checking that the data follows the rules
before any work is done is validation. With [ApiController], both happen before your action runs,
and bad requests never reach it.
By the end of this lesson, you will be able to:
- Explain where model binding gets values: route, query string, body, headers, and services.
- Bind a JSON body to a record, including enums and dates.
- Add validation rules with attributes, and rules that compare fields with
IValidatableObject. - Read ValidationProblemDetails responses, and explain 400 and 415.
- Return
201 Createdwith aLocationheader usingCreatedAtAction.
Why this matters
Every value from a client is untrusted: mistyped, missing, or deliberately wrong. An API that checks input in one consistent place is safer and easier to use than one where each action checks a few things its own way.
Good validation errors also save support time. "firstName is required" tells the app developer exactly
what to fix; a 500 with a stack trace in the logs tells them nothing.
The concept
Example: binding sources
Think of an admission form handed to the office clerk. First the clerk reads what is written (binding), then checks each box and the dates against each other (validation). Only a complete, correct form reaches the admissions office (the action).
Model binding fills action parameters from these sources:
| Source | Attribute | Typical use | Inferred by [ApiController] for |
|---|---|---|---|
| Route | [FromRoute] | {admissionNumber} | Parameters named in the route template |
| Query string | [FromQuery] | ?page=2 | Other simple types, such as int and string |
| Body | [FromBody] | JSON for POST and PUT | Complex types, such as CreateStudentRequest |
| Header | [FromHeader] | X-Request-Id | Never: always write the attribute |
| Services | [FromServices] | A service for one action only | Registered services |
Because of the inference, Admit(CreateStudentRequest request, ...) needs no attribute: a record is a
complex type, so it comes from the body.
Example: JSON to C#
ASP.NET Core uses System.Text.Json to read the body. Property names match without case, so the
JSON firstName fills the C# FirstName. Dates such as "2016-04-12" fill DateOnly directly.
Enums are the exception. By default, JSON enums are numbers, so "gender": "Male" cannot be read until a
JsonStringEnumConverter is registered. This lesson's first request shows the error, and the fix.
Example: validation rules
Data annotations are attributes from System.ComponentModel.DataAnnotations, part of .NET itself:
| Attribute | Rule | SMS example |
|---|---|---|
[Required] | Must be present and not empty | FirstName |
[StringLength(50)] | At most 50 characters | Names, to fit NVARCHAR(50) |
[Range(1, 100)] | A number in a range | pageSize (Lesson 72) |
[RegularExpression(...)] | Must match a pattern | ADM-2026-00001 |
[EmailAddress], [Phone] | Looks like an email or phone number | Guardian contacts (Module 11) |
[EnumDataType(typeof(T))] | A defined value of the enum | Gender |
Rules that compare two fields, such as "at least 3 years old on the admission date", cannot be attributes.
A class or record implements IValidatableObject and checks them in Validate.
Example: the order of checks
| Step | What happens | On failure |
|---|---|---|
| 1. Content type | Is the body JSON? | 415 Unsupported Media Type |
| 2. Binding | Can the JSON become a CreateStudentRequest? | 400, naming the JSON path, such as $.dateOfBirth |
| 3. Attributes | Does each field follow its rules? | 400, every failing field listed |
4. Validate | Do the fields agree with each other? | 400, runs only if step 3 passed |
| 5. The action | Your code runs |
Each step runs only if the one before it passed. That is why one wrong request shows several attribute
errors at once, but never a Validate error alongside them.
Apply it to the SMS
Step 1: rules on the request
using System.ComponentModel.DataAnnotations;
using SchoolApp.Core.Enums;
namespace SchoolApp.Core.Dtos;
// Lesson 45: DTOs (data transfer objects): the shapes the API sends and receives.
// Records: short to write, compared by value, and fixed once created.
// What a client sees about a student: no audit columns, no soft-delete flags.
public record StudentDto(
int Id,
string AdmissionNumber,
string FullName,
DateOnly DateOfBirth,
string Gender,
string Status);
// What a client sends to add a student. No Id: the database creates it.
// Lesson 73: validation rules, checked by ASP.NET Core before the action runs. The attributes come from
// System.ComponentModel.DataAnnotations, part of .NET itself, so Core still needs no web package.
public record CreateStudentRequest(
[Required, RegularExpression(@"^ADM-\d{4}-\d{4,5}$", ErrorMessage = "AdmissionNumber must look like ADM-2026-00001.")]
string AdmissionNumber,
[Required, StringLength(50)] string FirstName,
[Required, StringLength(50)] string LastName,
DateOnly DateOfBirth,
[EnumDataType(typeof(Gender))] Gender Gender,
DateOnly AdmissionDate,
[StringLength(200)] string? Address) : IValidatableObject
{
// A rule that compares two fields cannot be an attribute. ASP.NET Core calls Validate after
// every attribute has passed.
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (DateOfBirth.AddYears(3) > AdmissionDate)
{
yield return new ValidationResult(
"A student must be at least 3 years old on the admission date.",
[nameof(DateOfBirth), nameof(AdmissionDate)]);
}
}
}
// What a client may change later. The admission number and date of birth are not here.
public record UpdateStudentRequest(
string FirstName,
string LastName,
string? Address,
StudentStatus Status);
The attributes sit on the record's parameters and describe the request's shape: lengths that fit the
Students table, and the admission number pattern. Validate adds the age rule, giving both field names,
so a client can highlight both boxes on its form.
These rules check the request. The Student entity still checks its own rules in its constructor (Lesson
38), and the database still has its constraints (Lesson 10). Three layers, each catching what the others
cannot see.
Step 2: the POST action
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using SchoolApp.Api.Options;
using SchoolApp.Core.Common;
using SchoolApp.Core.Dtos;
using SchoolApp.Core.Interfaces;
using SchoolApp.Core.Services;
namespace SchoolApp.Api.Controllers;
// Lesson 68: the first SMS endpoint backed by SchoolDb. The controller only translates HTTP:
// the route value goes into StudentService, and the result comes back as 200 or 404.
// Lesson 71: /// comments and Produces... attributes describe the endpoint in the OpenAPI document.
// Lesson 72: a paged list at GET /api/students, with page and pageSize from the query string.
// Lesson 73: POST /api/students admits a student.
[ApiController]
[Route("api/students")]
[Produces("application/json")]
public class StudentsController : ControllerBase
{
private readonly StudentService _students;
private readonly IStudentQueries _queries;
private readonly SchoolOptions _school;
// ASP.NET Core passes in these services for each request (dependency injection, Lesson 69).
public StudentsController(StudentService students, IStudentQueries queries, IOptions<SchoolOptions> school)
{
_students = students;
_queries = queries;
_school = school.Value;
}
// GET /api/students?page=2&pageSize=20
/// <summary>Gets one page of students, sorted by last name, first name, and Id.</summary>
/// <param name="page">The page number, starting at 1.</param>
/// <param name="pageSize">Students per page, 1 to 100. Leave it out for the school's default.</param>
/// <param name="cancellationToken">Cancelled when the client gives up.</param>
/// <response code="200">The page, with the total count for page numbers.</response>
/// <response code="400">The page or page size is out of range.</response>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<PagedResult<StudentDto>>> GetPage(
[FromQuery, Range(1, int.MaxValue)] int page = 1,
[FromQuery, Range(1, Paging.MaxPageSize)] int? pageSize = null,
CancellationToken cancellationToken = default)
{
return await _queries.GetPageAsync(page, pageSize ?? _school.DefaultPageSize, cancellationToken);
}
// GET /api/students/ADM-2017-0003
// ActionResult<StudentDto>: either a StudentDto (200 OK) or another result, such as 404.
/// <summary>Gets one student by admission number.</summary>
/// <param name="admissionNumber">The student's admission number, such as ADM-2017-0003.</param>
/// <param name="cancellationToken">Cancelled when the client gives up.</param>
/// <response code="200">The student.</response>
/// <response code="404">No student has this admission number.</response>
[HttpGet("{admissionNumber}")]
[ProducesResponseType(StatusCodes.Status200OK)] // the body type comes from ActionResult<StudentDto>
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<StudentDto>> GetByAdmissionNumber(string admissionNumber, CancellationToken cancellationToken)
{
StudentDto? student = await _students.FindAsync(admissionNumber, cancellationToken);
if (student is null)
{
return NotFound();
}
return student; // converted to Ok(student)
}
// POST /api/students, with a CreateStudentRequest as JSON in the body.
// Lesson 73: [ApiController] reads the body (model binding) and checks its rules (validation)
// before this method runs. An invalid request gets 400 and never reaches it.
/// <summary>Admits a new student.</summary>
/// <param name="request">The new student's details.</param>
/// <param name="cancellationToken">Cancelled when the client gives up.</param>
/// <response code="201">The student was admitted; Location gives the new student's address.</response>
/// <response code="400">The request is invalid; errors lists each problem.</response>
[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<StudentDto>> Admit(CreateStudentRequest request, CancellationToken cancellationToken)
{
StudentDto admitted = await _students.AdmitAsync(request, cancellationToken);
// 201 Created, a Location header pointing at GET /api/students/{admissionNumber}, and the student.
return CreatedAtAction(nameof(GetByAdmissionNumber), new { admissionNumber = admitted.AdmissionNumber }, admitted);
}
}
The action has no if statements about the request at all. By the time it runs, the request has been
read and checked. It admits the student and returns CreatedAtAction(...).
Example: 201 Created and Location
CreatedAtAction(nameof(GetByAdmissionNumber), new { admissionNumber = ... }, admitted) does three
things: sets the status to 201, builds the new student's address from the GetByAdmissionNumber
action's route, and returns the student as the body.
Using nameof and the route, instead of writing the URL by hand, means the Location header stays
correct even if the route changes later.
Step 3: enums as names
The first attempt, before the converter, sent "gender": "Male":
HTTP/1.1 400 Bad Request
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"request":["The request field is required."],"$.gender":["The JSON value could not be converted to SchoolApp.Core.Dtos.CreateStudentRequest. Path: $.gender | LineNumber: 5 | BytePositionInLine: 18."]},"traceId":"00-5bcebefb381e240407014c9edffc9248-2d1708bdfe167452-00"}
Program.cs now registers the converter for every request and response:
// Lesson 66: the start of SchoolApp.Api. Program.cs has two halves:
// 1. Register services (the "builder" part), 2. build the app and describe how requests are handled.
using System.Text.Json.Serialization;
using SchoolApp.Api.Diagnostics;
using SchoolApp.Api.Middleware;
using SchoolApp.Api.Options;
using Scalar.AspNetCore;
using SchoolApp.Core.Services;
using SchoolApp.Data;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Services: things the app can use. AddControllers finds every controller class in this project.
// Lesson 73: enums travel as their names ("Male"), not as numbers, in requests and responses.
builder.Services.AddControllers()
.AddJsonOptions(options => options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
// Lesson 71: the OpenAPI document, with a title and description of our own.
builder.Services.AddOpenApi(options =>
{
options.AddDocumentTransformer((document, context, cancellationToken) =>
{
document.Info.Title = "SchoolApp API";
document.Info.Description = "The School Management System API from the NexCoding SMS course.";
return Task.CompletedTask;
});
});
// Lesson 70: settings come from configuration: appsettings.json, then appsettings.{Environment}.json,
// then user secrets (Development only), then environment variables, then the command line.
// A later source overrides an earlier one.
string? connectionString = builder.Configuration.GetConnectionString("SchoolDb");
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new InvalidOperationException("The connection string 'SchoolDb' is missing or empty.");
}
// The "School" section as a SchoolOptions object, checked when the app starts.
builder.Services.AddOptions<SchoolOptions>()
.BindConfiguration(SchoolOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();
// Lesson 69: the dependency injection container. Each line says "when something asks for X, give it Y,
// and keep it for this long". SchoolApp.Data registers its own classes.
builder.Services.AddSchoolData(connectionString);
builder.Services.AddScoped<StudentService>();
builder.Services.AddScoped<FeePaymentService>();
// Lesson 69: the three lifetimes, side by side (see DiagnosticsController).
builder.Services.AddSingleton<SingletonExample>(); // one object for the whole app
builder.Services.AddScoped<ScopedExample>(); // one object per request
builder.Services.AddTransient<TransientExample>(); // a new object every time one is asked for
builder.Services.AddScoped<LifetimeReporter>();
WebApplication app = builder.Build();
// The request pipeline: what happens to every request, in this order (Lesson 67).
// Each Use... adds one middleware; a request goes down the list, and the response comes back up.
app.UseRequestTiming(); // first, so its time includes everything below it
if (app.Environment.IsDevelopment())
{
app.MapOpenApi(); // /openapi/v1.json, only while developing
app.MapScalarApiReference(); // Lesson 71: /scalar, a page to read and try every endpoint
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers(); // send each request to the matching controller action
app.Run(); // start the web server and wait for requests
Names are clearer than numbers for clients, and safer too: adding an enum value in the middle would shift every number, but never a name.
Step 4: send the requests
The request bodies are files in src/requests:
{
"admissionNumber": "ADM-2026-10001",
"firstName": "Ishaan",
"lastName": "Kumar",
"dateOfBirth": "2016-04-12",
"gender": "Male",
"admissionDate": "2026-10-07",
"address": null
}
curl.exe -i --json "@src/requests/admit-student.json" http://localhost:5080/api/students
HTTP/1.1 201 Created
Location: http://localhost:5080/api/students/ADM-2026-10001
{"id":10048,"admissionNumber":"ADM-2026-10001","fullName":"Ishaan Kumar","dateOfBirth":"2016-04-12","gender":"Male","status":"Active"}
Ishaan is now in SchoolDb, and GET on the Location address returns him. Your id will differ. The
.http file has the same request, with the body written below the headers.
Unlike the rolled-back demos of Module 9, this student is really saved. To send the same request again,
change the admission number in the file, or remove him in SSMS with
DELETE FROM dbo.Students WHERE AdmissionNumber = 'ADM-2026-10001';.
Example: field errors
admit-student-invalid.json has an empty first name and an admission number without ADM-:
HTTP/1.1 400 Bad Request
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"FirstName":["The FirstName field is required."],"AdmissionNumber":["AdmissionNumber must look like ADM-2026-00001."]},"traceId":"00-6208c331345f22e449e3aa4d2537be0e-e48b708d4cb9ba60-00"}
Both problems are reported in one response, so the client can fix everything at once. The keys use the C#
names, FirstName, while the JSON used firstName; clients should match keys without case.
This shape is ValidationProblemDetails: the ProblemDetails fields from Lesson 68, plus errors, a list
of messages for each field. The traceId identifies this one request, so once logging records it
(Module 12), support staff can find the exact request a parent complained about.
Example: a rule across fields
admit-student-too-young.json is valid field by field, but the child was born in January 2025:
HTTP/1.1 400 Bad Request
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"DateOfBirth":["A student must be at least 3 years old on the admission date."],"AdmissionDate":["A student must be at least 3 years old on the admission date."]},"traceId":"00-f11da3b0dd66b81b50cc193d6908764f-d7abfb0beb6859d3-00"}
Validate ran because every attribute passed, and its error appears under both fields it named.
Example: JSON that cannot be read
admit-student-bad-json.json has the date "2016-13-45" and the gender "Girl":
HTTP/1.1 400 Bad Request
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"request":["The request field is required."],"$.dateOfBirth":["The JSON value could not be converted to SchoolApp.Core.Dtos.CreateStudentRequest. Path: $.dateOfBirth | LineNumber: 4 | BytePositionInLine: 29."]},"traceId":"00-aef9d9c7c56c66973a576e868d0c27c4-a2c1ee78fd69c93f-00"}
Only the date is reported. The JSON reader stops at the first value it cannot convert, so "Girl" was
never reached, and no request object was created to validate.
Example: no body, wrong type, and a duplicate
| Request | Status | Why |
|---|---|---|
POST with Content-Type: application/json and no body | 400, "A non-empty request body is required." | Nothing to bind |
POST with Content-Type: text/plain | 415 Unsupported Media Type | The API reads only JSON |
| The valid request again | 500 Internal Server Error | StudentService throws for a duplicate admission number |
The last row is a bug in the API's answers, not in the request: a duplicate is the client's mistake, and
should be a 409 Conflict with a clear message. Lesson 74 fixes it for every error of that kind.
Common mistakes
- Checking the request with
ifstatements in every action. Rules are repeated and drift apart. Fix: attributes andIValidatableObjecton the request type. - Returning entities from
POST. Clients see internal fields. Fix: accept a request DTO, return a response DTO. - Enums as numbers in JSON. Clients send
77and meaning is lost. Fix:JsonStringEnumConverter. - Only validating in the API. Other callers of the service skip the rules. Fix: keep business rules in Core too; API validation checks the request's shape.
- Writing
LocationURLs by hand. They break when routes change. Fix:CreatedAtActionwithnameof.
In real projects
On a school portal, the admission form's date picker sent dates as "12/04/2016". On some phones that
meant 12 April; on others, 4 December. The API accepted both, and nobody noticed for weeks.
The fix had two parts. The API accepted only "2016-04-12", the ISO format, and rejected anything else
with a 400 naming the field. The apps were changed to send that format.
Strict input with clear errors felt unfriendly at first. It turned a silent data problem into a loud one that took ten minutes to fix.
Best practices
- Accept request DTOs and validate them with attributes and
IValidatableObject. - Report every field error at once, in ValidationProblemDetails.
- Send enums as names and dates in ISO format.
- Return
201 CreatedwithCreatedAtActionfor new resources. - Keep business rules in Core and constraints in the database; API validation is the first layer, not the only one.
Recap
- Model binding fills parameters from the route, query string, body, headers, and services.
[ApiController]infers sources, and answers400or415before the action runs.- Attributes check fields;
IValidatableObject.Validatechecks rules across fields, after the attributes pass. - Binding errors stop at the first value that cannot be read.
POST /api/studentsadmitted Ishaan with201 Createdand aLocationheader.
Homework
Library module task: Add POST /api/book-issues to a single-file library API. Validate the request:
a 13-digit ISBN, a student id of at least 1, an issue date, and an optional due date that must be after
the issue date and at most 14 days later. Return 201 with the issue, using a 14-day loan when no due
date is sent.
A correct result looks like:
073-issue-valid.json
HTTP/1.1 201 Created
{"isbn":"9780143039655","studentId":6,"issuedOn":"2026-08-03","dueOn":"2026-08-17"}
073-issue-invalid.json
HTTP/1.1 400 Bad Request
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Isbn":["Isbn must be 13 digits."],"StudentId":["The field StudentId must be between 1 and 2147483647."]},"traceId":"00-a2a78539ecaf834ae7e6c6f60568c392-ac8c025a5460ccb9-00"}
073-issue-late-due.json
HTTP/1.1 400 Bad Request
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"DueOn":["DueOn must be after IssuedOn and at most 14 days later."]},"traceId":"00-5adb719385333ee3f58e465c226ce3ad-4af90a23cb6d69db-00"}
Show the solution
// Library homework, Lesson 73: a validated book-issue request. Attributes check each field; Validate
// checks the rule that compares two dates. Nothing is saved: the endpoint only answers with the issue.
// Run with: dotnet run library-assignment/073-library-validation.cs
// Send: curl.exe -i --json "@library-assignment/073-issue-valid.json" http://localhost:5073/api/book-issues
#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
WebApplication app = builder.Build();
app.MapControllers();
app.Run("http://localhost:5073");
public record IssueRequest(
[Required, RegularExpression(@"^\d{13}$", ErrorMessage = "Isbn must be 13 digits.")] string Isbn,
[Range(1, int.MaxValue)] int StudentId,
DateOnly IssuedOn,
DateOnly? DueOn) : IValidatableObject
{
public const int LoanDays = 14;
// The due date is optional; when given, it must be after the issue date and within the loan period.
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (DueOn is DateOnly due && (due <= IssuedOn || due > IssuedOn.AddDays(LoanDays)))
{
yield return new ValidationResult(
$"DueOn must be after IssuedOn and at most {LoanDays} days later.", [nameof(DueOn)]);
}
}
}
public record BookIssue(string Isbn, int StudentId, DateOnly IssuedOn, DateOnly DueOn);
[ApiController]
[Route("api/book-issues")]
public class BookIssuesController : ControllerBase
{
[HttpPost]
public ActionResult<BookIssue> Issue(IssueRequest request)
{
// Only valid requests get here; [ApiController] answered 400 for the rest.
BookIssue issue = new BookIssue(request.Isbn, request.StudentId, request.IssuedOn,
request.DueOn ?? request.IssuedOn.AddDays(IssueRequest.LoanDays));
return StatusCode(StatusCodes.Status201Created, issue);
}
}
{
"isbn": "9780143039655",
"studentId": 6,
"issuedOn": "2026-08-03",
"dueOn": "2026-09-30"
}
The 14-day loan comes from the library rules in the Lesson 3 homework. The controller has no checks of its own: everything it receives is already valid.
Quiz
Where does [ApiController] bind a CreateStudentRequest parameter from?
Show answer
Answer: B. The request body Complex types are inferred to come from the body.
Why did gender Male fail before the converter was added?
Show answer
Answer: B. JSON enums are numbers by default JsonStringEnumConverter lets enums travel as names.
When does IValidatableObject.Validate run?
Show answer
Answer: B. After every attribute on the object has passed The age error never appeared together with attribute errors.
What does CreatedAtAction return?
Show answer
Answer: B. 201 with a Location header and the body Location pointed at /api/students/ADM-2026-10001.
Which status does a text/plain body get from SchoolApp.Api?
Show answer
Answer: B. 415 Unsupported Media Type The API accepts only JSON request bodies.
Interview questions
Questions are grouped by experience level. Click a question to see a model answer. All questions: Web API interview questions.
Fresher (0-2 yrs)
F What is model binding in ASP.NET Core? concept
Turning request data into action parameters: route values, query string values, the JSON body,
headers, and services. With [ApiController], a complex type such as CreateStudentRequest is read
from the body, and simple types from the route or query string.
F How do you add validation rules to a request model? coding
With data annotation attributes such as [Required], [StringLength(50)], [Range], and
[RegularExpression] on its properties or record parameters. With [ApiController], invalid requests
get 400 with ValidationProblemDetails before the action runs.
F What does CreatedAtAction return, and why use it? coding
A 201 Created response with the new resource as the body and a Location header built from another
action's route, such as the GET for one student. Building the URL from the route keeps it correct when
routes change, unlike a hand-written string.
Mid (3-5 yrs)
M How do you validate a rule that compares two fields? coding
Implement IValidatableObject on the request type and return ValidationResult objects from
Validate, naming the fields involved, such as an age rule over DateOfBirth and AdmissionDate.
ASP.NET Core calls it only after every attribute has passed.
M Why might a JSON enum value like "Male" fail to bind, and how do you fix it? concept
System.Text.Json reads and writes enums as numbers by default, so a name cannot be converted and the
request gets 400. Register JsonStringEnumConverter in the JSON options so enums travel as names,
which are clearer and stay stable when enum values are reordered.
M In what order are binding and validation checks applied, and why does it matter? concept
Content type (415 if not JSON), then binding (400 for values that cannot be converted, stopping at the first), then attributes (every failing field), then IValidatableObject, then the action. Each step runs only if the previous passed, so clients may need two attempts to see all kinds of errors.
Senior (6-9 yrs)
S Where should validation live: the API request model, the domain, or the database? design
In all three, for different purposes. The request model checks shape and format and gives clients clear 400 errors. The domain enforces business rules for every caller, not just the API. Database constraints are the last guard against any client or bug. Duplicated rules are acceptable when each layer has its own reason.
How to answer: Explain what each layer can see, give SMS examples for each, and how you keep messages consistent, such as mapping domain errors to ProblemDetails.
S A mobile app sends dates like 12/04/2016 and some records are wrong. What do you change? scenario
Accept only an unambiguous format, ISO 8601 (2016-04-12), which DateOnly binds directly, and reject
anything else with a 400 that names the field. Fix the app to send ISO dates, and check existing data
for swapped day and month values.
How to answer: Identify the ambiguity, enforce a strict contract, communicate it in the OpenAPI document, and plan the data repair.
Architect / Lead (10+ yrs)
A How would you design input validation and error reporting for a large API used by several client teams? design
One request DTO per operation with declarative rules, cross-field rules in IValidatableObject or a validation library, one error format (ValidationProblemDetails with stable keys and messages), rules visible in the OpenAPI document, and tests for each rule. Keep business rules in the domain and map their errors to the same format.
How to answer: Cover consistency, documentation, localisation of messages if needed, and how clients can rely on error keys. Mention when a library like FluentValidation is worth it.
Resources
- Code for this lesson: git tag
lesson-73-model-binding-validation(code repository coming soon) - Files:
src/SchoolApp.Core/Dtos/StudentDtos.cs,src/SchoolApp.Api/Controllers/StudentsController.cs,src/SchoolApp.Api/Program.cs,src/requests/admit-student.json,library-assignment/073-library-validation.cs - Video: coming soon
What's next
A duplicate admission still answers 500. Next: making every response correct, with the right status
code for each kind of failure and one error format for the whole API.
Lesson 74: Responses and status codes
Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.