Request Validation
By the end of this lesson
Reject invalid requests at the boundary with clear, specific messages.
Validation at the boundary means a request is checked as it arrives, before any business logic runs. Everything past that point can then assume the data is shaped correctly, which removes a layer of defensive checking from the rest of the code.
The alternative is validation scattered through the layers: a null check in the service, a length check in the repository, a constraint in the database that surfaces as a 500. The same rule ends up expressed three times, in three places, with three different messages, and a caller cannot predict which one they will hit.
Three kinds of check, which belong in different places:
- Shape
- Can this JSON become the request type at all? "tomorrow" is not a date, a string is not an int. The serialiser answers this, and the answer is 400.
- Field rules
- Is each value acceptable on its own — required, within a length, within a range, a plausible email address? These live on the request type or in a validator beside it, and need nothing from the database.
- Business rules
- Rules that need context: does department 9 exist, is the salary inside the band for that grade, is the start date after the department opened. These need data, so they live in the handler or the service, after the field rules have passed.
The split matters because the two kinds fail differently. A field rule fails the same way every time and can be described in the API documentation: fullName is required, maximum 200 characters. A business rule depends on the state of the system, so the same request can be valid on Monday and invalid on Tuesday.
Run field rules first. There is no point querying the database to check a department when the department id is missing, and doing so turns a clean 400 into a confusing one.
public sealed record CreateEmployeeRequest(
[Required, StringLength(200)] string FullName,
[Required, EmailAddress] string Email,
[Range(1, int.MaxValue)] int DepartmentId,
DateOnly StartDate);
app.MapPost("/api/employees", async (
CreateEmployeeRequest request, AppDbContext db, CancellationToken ct) =>
{
var errors = new Dictionary<string, string[]>();
var department = await db.Departments
.SingleOrDefaultAsync(d => d.Id == request.DepartmentId, ct);
if (department is null)
{
errors["departmentId"] = ["No department exists with that id."];
}
else if (request.StartDate < department.OpenedOn)
{
errors["startDate"] = ["Start date cannot be before the department opened."];
}
if (await db.Employees.AnyAsync(e => e.Email == request.Email, ct))
{
errors["email"] = ["An employee already exists with that email address."];
}
if (errors.Count > 0)
{
return Results.ValidationProblem(errors);
}
var created = await db.AddEmployeeAsync(request, ct);
return Results.Created($"/api/employees/{created.Id}", created);
});- The attributes on the record describe field rules in the one place a reader looks for them. They also feed the generated API documentation, so the rules reach callers rather than living only in your head. Note that attributes alone do not run in a minimal API — you need a validation step, either the framework's or a small filter of your own.
- The handler collects errors in a dictionary rather than returning on the first failure, so a caller fixing a form sees every problem at once instead of discovering them one request at a time.
- The department check and the start-date check are business rules: both need a row from the database. Neither could be expressed as an attribute, because an attribute knows nothing about departments.
- Results.ValidationProblem returns 400 with a standard problem document containing the field errors, keyed by field name. Those keys are what let a client attach each message to the right input.
- The duplicate email check returns a field error here. Returning 409 Conflict instead is equally defensible — what matters is choosing one and applying it across every endpoint that can produce a duplicate.
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"departmentId": ["No department exists with that id."],
"email": ["An employee already exists with that email address."]
}
}- The errors object is keyed by field, so a form can highlight departmentId and email without parsing prose.
- Each value is an array, because one field can fail more than one rule.
- Both failures are reported together. A response containing only the first would send the caller round the loop twice for a problem you already knew about.
- The message says what is wrong in terms the caller can act on. It does not mention tables, constraints or exception types.
What separates a useful validation message from a frustrating one:
- It names the field, in the same name the caller sent — departmentId, not DepartmentId or _departmentId
- It says what is wrong, not that something is wrong
- It says what would be acceptable where that is short: "Quantity must be between 1 and 500"
- It reports every failing field in one response
- It uses the caller's vocabulary, not your schema's — "start date" rather than "EmpStartDt"
- It never echoes back a value that might be sensitive, and never includes an exception message or stack trace
Summary
- Validate as the request arrives, so everything after the boundary can trust the data
- Field rules need only the request; business rules need data, and run second
- Collect and return every failing field at once, keyed by the name the caller sent
- Return 400 with a standard problem document rather than an exception or a bare string
- A caller mistake that produces a 500 is a missing boundary check, not a bug in the caller
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Sort the rules
An order request contains employeeId, sku, quantity and a requestedDeliveryDate. The rules are: quantity between 1 and 500, sku is required and at most 20 characters, the employee must exist and be active, the delivery date must be a working day, and the total value must not exceed the employee's spending limit.
Sort each rule into a field rule or a business rule, and say where you would implement it.
Show solution
Field rules: quantity range and sku required and length. Both are decidable from the request alone, so they go on the request type or in a validator beside it and fail with 400 before anything else runs.
Business rules: the employee must exist and be active, and the total must be within the spending limit. Both need data, so they belong in the handler or service after the field rules pass.
The working-day rule is the interesting one and it is fair to argue either way. If working days are a fixed rule about weekends, it is a field rule. If your organisation has a holiday calendar in the database, it needs a lookup and becomes a business rule. Noticing the distinction is the point of the exercise.
Think about it
Where a 500 comes from
An endpoint returns 500 when a caller omits a required field, and the log shows a null reference exception in the service layer.
What went wrong, and why is the status code the more informative symptom?
Show solution
Nothing validated the request, so the missing field travelled into the service and was dereferenced there. The exception is the effect, not the cause.
The status code is the more informative symptom because it tells you the boundary check is missing entirely. A caller mistake should never produce a 5xx: the 500 means your code had no answer for input it should have rejected in one line.
It is also an operational problem. That 500 counts against your error rate and can wake someone up for a request that was invalid all along.
Saved in this browser only.