Handling validation errors (HTTP 400 mapping)¶
What this does¶
Both IQueryable<T>.Apply(...) and ApplyPagedAsync(...) validate the request first. On failure they throw FilterValidationException, whose .Result property is a FilterValidationResult — a record carrying IsValid plus a list of FilterValidationError (each with Path, Code, Message, Field, OperatorName). The controller catches the exception and converts the structured result into an HTTP 400 response payload.
When to use¶
Every endpoint that accepts an external FilterRequest payload. Validation failures are caller-driven (bad field name, unsupported operator, malformed value) and deserve a 400; they should never become a 500.
Minimal code¶
Pattern from samples/UserManagement.WebApi/Controllers/UsersController.cs:
[HttpPost("search")]
public async Task<ActionResult<PageResult<User>>> SearchAsync(
[FromBody] FilterRequest request,
CancellationToken cancellationToken)
{
try
{
var pageResult = await _dbContext.Users
.ApplyPagedAsync(_userFilter, request, cancellationToken);
return Ok(pageResult);
}
catch (FilterValidationException invalid)
{
// invalid.Result is a FilterValidationResult with .Errors
// (Path, Code, Message, Field, OperatorName).
return BadRequest(invalid.Result);
}
}
The FilterValidationResult serializes to JSON cleanly, so passing it straight to BadRequest(...) produces a structured response that clients can parse. An example payload:
{
"isValid": false,
"errors": [
{
"path": "where.and[0].value",
"code": "InvalidValueType",
"message": "Expected number for operator 'gt' on field 'age'.",
"field": "age",
"operatorName": "gt"
}
]
}
Variations¶
- Global exception filter — instead of a try/catch in every controller action, register an ASP.NET Core
IExceptionFilterthat mapsFilterValidationExceptionto a 400 withinvalid.Resultas the body. Keeps controller methods clean. (The implementation is a few lines — outside the scope of this guide.) - Custom error envelope — wrap
invalid.Result.Errorsinside your project's standard error shape (e.g.{ "type": "validation", "details": [...] }) before returning. The structured fields make the mapping mechanical. - Pre-validate without throwing — for endpoints that report validation status without executing (the sample's
/users/validate), call_userFilter.Validate(request)directly and return theFilterValidationResult; no exception is thrown.
Pitfalls¶
- The
catchmust be specific toFilterValidationException. Never catch the broaderExceptionand return 400 — that hides genuine bugs (database failures, NREs, programming errors) behind a misleading status code. - Don't swallow
OperationCanceledExceptionin the same catch block. Cancellation is not a validation failure; it's the client disconnecting. Let it propagate so the host can short-circuit the response. - The
FilterValidationCodeenum (UnknownField,OperatorNotAllowed,InvalidValueType,InvalidValueFormat,EmptyInArray,InterceptorRejected,NotSortable,InvalidSortDirection,PageInvalid,PageSizeTooLarge,PageSizeInvalid,NestingTooDeep,TooManyConditions,GroupEmpty) is the canonical list — UI translations and per-error help links should switch on these codes, not on the human-readable message.