Skip to main content

Model Binding and Validation: Admitting a Student – Web API | Lesson 73

Published / updated

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 Created with a Location header using CreatedAtAction.

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:

SourceAttributeTypical useInferred by [ApiController] for
Route[FromRoute]{admissionNumber}Parameters named in the route template
Query string[FromQuery]?page=2Other simple types, such as int and string
Body[FromBody]JSON for POST and PUTComplex types, such as CreateStudentRequest
Header[FromHeader]X-Request-IdNever: always write the attribute
Services[FromServices]A service for one action onlyRegistered 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:

AttributeRuleSMS example
[Required]Must be present and not emptyFirstName
[StringLength(50)]At most 50 charactersNames, to fit NVARCHAR(50)
[Range(1, 100)]A number in a rangepageSize (Lesson 72)
[RegularExpression(...)]Must match a patternADM-2026-00001
[EmailAddress], [Phone]Looks like an email or phone numberGuardian contacts (Module 11)
[EnumDataType(typeof(T))]A defined value of the enumGender

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​

StepWhat happensOn failure
1. Content typeIs the body JSON?415 Unsupported Media Type
2. BindingCan the JSON become a CreateStudentRequest?400, naming the JSON path, such as $.dateOfBirth
3. AttributesDoes each field follow its rules?400, every failing field listed
4. ValidateDo the fields agree with each other?400, runs only if step 3 passed
5. The actionYour 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​

src/SchoolApp.Core/Dtos/StudentDtos.cs
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​

src/SchoolApp.Api/Controllers/StudentsController.cs
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":

Output
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:

src/SchoolApp.Api/Program.cs
// 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:

src/requests/admit-student.json
{
"admissionNumber": "ADM-2026-10001",
"firstName": "Ishaan",
"lastName": "Kumar",
"dateOfBirth": "2016-04-12",
"gender": "Male",
"admissionDate": "2026-10-07",
"address": null
}
Terminal 2
curl.exe -i --json "@src/requests/admit-student.json" http://localhost:5080/api/students
Output
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-:

Output
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:

Output
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":

Output
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​

RequestStatusWhy
POST with Content-Type: application/json and no body400, "A non-empty request body is required."Nothing to bind
POST with Content-Type: text/plain415 Unsupported Media TypeThe API reads only JSON
The valid request again500 Internal Server ErrorStudentService 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​

  1. Checking the request with if statements in every action. Rules are repeated and drift apart. Fix: attributes and IValidatableObject on the request type.
  2. Returning entities from POST. Clients see internal fields. Fix: accept a request DTO, return a response DTO.
  3. Enums as numbers in JSON. Clients send 77 and meaning is lost. Fix: JsonStringEnumConverter.
  4. 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.
  5. Writing Location URLs by hand. They break when routes change. Fix: CreatedAtAction with nameof.

In real projects​

From 16 years of .NET 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 Created with CreatedAtAction for 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 answers 400 or 415 before the action runs.
  • Attributes check fields; IValidatableObject.Validate checks rules across fields, after the attributes pass.
  • Binding errors stop at the first value that cannot be read.
  • POST /api/students admitted Ishaan with 201 Created and a Location header.

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:

Output
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-assignment/073-library-validation.cs
// 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);
}
}
library-assignment/073-issue-late-due.json
{
"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​

  1. Where does [ApiController] bind a CreateStudentRequest parameter from?

    1. The query string
    2. The request body
    3. The route
    4. A header
    Show answer

    Answer: B. The request body Complex types are inferred to come from the body.

  2. Why did gender Male fail before the converter was added?

    1. Male is not a valid name
    2. JSON enums are numbers by default
    3. The record had no Gender
    4. DateOnly was wrong
    Show answer

    Answer: B. JSON enums are numbers by default JsonStringEnumConverter lets enums travel as names.

  3. When does IValidatableObject.Validate run?

    1. Before binding
    2. After every attribute on the object has passed
    3. After the action
    4. Only in Development
    Show answer

    Answer: B. After every attribute on the object has passed The age error never appeared together with attribute errors.

  4. What does CreatedAtAction return?

    1. 200 with no body
    2. 201 with a Location header and the body
    3. 204
    4. 302 redirect
    Show answer

    Answer: B. 201 with a Location header and the body Location pointed at /api/students/ADM-2026-10001.

  5. Which status does a text/plain body get from SchoolApp.Api?

    1. 400
    2. 415 Unsupported Media Type
    3. 404
    4. 500
    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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.