By the end of this session, you will be able to:
DataAnnotations attributesFluentValidationProblemDetails format| Time | Segment | Type | Duration |
|---|---|---|---|
| 0:00 | Why validation belongs on the server | Theory | 10 min |
| 0:10 | DataAnnotations — the basics | Theory + Demo | 20 min |
| 0:30 | FluentValidation — richer rules | Demo | 30 min |
| 1:00 | Sanitization & dangerous inputs | Theory | 15 min |
| 1:15 | Lab — validate the task creation endpoint | Lab | 35 min |
| 1:50 | Wrap-up | Discussion | 10 min |
These are real scenarios that happen in production when server validation is missing:
[ApiController] attribute, DataAnnotations, and FluentValidation together make server validation declarative — you describe the rules, the framework enforces them automatically before your action code runs.
System.ComponentModel.DataAnnotations namespace that you place directly on your DTO properties. When combined with [ApiController], ASP.NET Core validates the model automatically and returns 400 before your action code runs.| Attribute | What it validates | ErrorMessage parameter |
|---|---|---|
[Required] | Value is not null or empty | Yes |
[MaxLength(n)] | String length does not exceed n | Yes |
[MinLength(n)] | String length is at least n | Yes |
[Range(min, max)] | Numeric value within bounds | Yes |
[EmailAddress] | Basic email format check | Yes |
[RegularExpression] | Value matches a regex pattern | Yes |
[StringLength(max)] | Max and optional min length | Yes |
ValidationAttribute and overriding IsValid. This keeps the annotation style but allows arbitrary logic — useful for rules like "date must be in the future."
using System.ComponentModel.DataAnnotations;
public class CreateTaskRequest
{
[Required(ErrorMessage = "Title is required.")]
[MaxLength(200, ErrorMessage = "Title cannot exceed 200 characters.")]
public string Title { get; set; } = "";
[MaxLength(2000, ErrorMessage = "Description cannot exceed 2000 characters.")]
public string? Description { get; set; }
[Range(1, 5, ErrorMessage = "Priority must be between 1 and 5.")]
public int Priority { get; set; } = 1;
public DateTime? DueDate { get; set; }
}
Attributes stack — a property can carry multiple annotations. Each rule is checked independently. All violations are collected and returned together in the 400 response body, not one at a time.
Without [ApiController], you would check ModelState.IsValid manually in every action. With it, ASP.NET Core intercepts the request before the action runs:
ValidationProblemDetails object containing all errors, grouped by field nameif (!ModelState.IsValid) guard needed in any action[ApiController]
[Route("api/tasks")]
public class TasksController : ControllerBase
{
[HttpPost]
public IActionResult Create(
[FromBody] CreateTaskRequest req)
{
// Reaching here means req is valid.
// [ApiController] already returned 400
// if any annotation rule was violated.
var task = _service.Create(req);
return CreatedAtAction(
nameof(GetById),
new { id = task.Id },
task);
}
}
ASP.NET Core returns a ValidationProblemDetails object — an extension of the RFC 7807 ProblemDetails format with an added errors dictionary:
type — a URI identifying the error classtitle — human-readable summarystatus — 400errors — dictionary keyed by field name, each value is an array of error messages for that field{
"type": "https://tools.ietf.org/html/rfc7807",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Title": [
"Title is required."
],
"Priority": [
"Priority must be between 1 and 5."
]
}
}
.When), cross-property checks, and chained predicates read like English sentences rather than attribute soup on a model classCreateTaskRequestValidator is a plain class; you can write unit tests against it without an HTTP request or a controllerNotEmpty, MaximumLength, GreaterThan, Matches (regex), Must (custom predicate), and async variants for database checks.WithMessage() at the rule level; no need to repeat error strings in attribute constructors across multiple DTOsOne NuGet package adds FluentValidation with built-in ASP.NET Core integration:
dotnet add package FluentValidation.AspNetCore
The FluentValidation.AspNetCore package includes:
FluentValidation — the core library with all rule buildersModelState automaticallyRegister all validators from the current assembly in Program.cs:
using FluentValidation;
using FluentValidation.AspNetCore;
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddValidatorsFromAssemblyContaining<
CreateTaskRequestValidator>();
AbstractValidator<T> and registers them all with the DI container. You do not need to register each validator individually.
using FluentValidation;
public class CreateTaskRequestValidator : AbstractValidator<CreateTaskRequest>
{
public CreateTaskRequestValidator()
{
RuleFor(x => x.Title)
.NotEmpty().WithMessage("Title is required.")
.MaximumLength(200).WithMessage("Title cannot exceed 200 characters.");
RuleFor(x => x.Priority)
.InclusiveBetween(1, 5)
.WithMessage("Priority must be between 1 and 5.");
RuleFor(x => x.DueDate)
.GreaterThan(DateTime.UtcNow)
.When(x => x.DueDate.HasValue)
.WithMessage("Due date must be in the future.");
}
}
Each RuleFor call targets one property. Rules chain with . — all chained rules apply to the same property in order. .When() makes the preceding rule conditional.
Two mechanisms handle rules that cannot be expressed with built-in methods:
.When(predicate) — applies the preceding rule only when the predicate is true. Useful for optional fields that have constraints when present.Must(predicate) — runs a custom boolean function against the value. Receives the entire model as a second parameter if you need cross-property logic.MustAsync(predicate) — the async variant, for rules that query the database (e.g., checking uniqueness)// Conditional: only validate if the field has a value
RuleFor(x => x.Description)
.MaximumLength(2000)
.WithMessage("Description cannot exceed 2000 characters.")
.When(x => x.Description is not null);
// Custom predicate: reject HTML in the title
RuleFor(x => x.Title)
.Must(title => !title.Contains('<'))
.WithMessage("Title must not contain HTML tags.");
// Cross-property rule: end date after start date
RuleFor(x => x.EndDate)
.GreaterThan(x => x.StartDate)
.When(x => x.EndDate.HasValue && x.StartDate.HasValue)
.WithMessage("End date must be after start date.");
using FluentValidation;
public class UpdateTaskRequestValidator : AbstractValidator<UpdateTaskRequest>
{
public UpdateTaskRequestValidator()
{
RuleFor(x => x.Title)
.NotEmpty().WithMessage("Title is required.")
.MaximumLength(200).WithMessage("Title cannot exceed 200 characters.")
.Must(t => !t.Contains('<')).WithMessage("Title must not contain HTML tags.");
RuleFor(x => x.Description)
.MaximumLength(2000)
.WithMessage("Description cannot exceed 2000 characters.")
.When(x => x.Description is not null);
RuleFor(x => x.DueDate)
.GreaterThan(DateTime.UtcNow)
.When(x => x.DueDate.HasValue)
.WithMessage("Due date must be in the future.");
}
}
Each request DTO gets its own validator class. This keeps rules separate and makes unit-testing each set of rules straightforward without coupling them to the controller.
<script> before saving user-provided description textCross-Site Scripting (XSS) occurs when user-supplied text containing HTML or JavaScript is rendered unescaped in a browser. The script runs with the privileges of the page.
.Must() rule to reject any input containing < or > characters when HTML is not expectedSystem.Text.Json escapes special characters automaticallyusing System.Text.Encodings.Web;
public TaskItemDto Create(CreateTaskRequest req)
{
var task = new TaskItem
{
Title = req.Title,
// Encode any HTML in the description
// before persisting to the database.
Description = req.Description is null
? null
: HtmlEncoder.Default.Encode(
req.Description)
};
_context.Tasks.Add(task);
_context.SaveChanges();
return _mapper.Map<TaskItemDto>(task);
}
SQL injection occurs when user input is concatenated directly into a SQL query string. The input can change the meaning of the query — reading, deleting, or modifying data the caller should not access.
FromSqlInterpolated (parameterized) rather than FromSqlRaw with string concatenation// Safe: EF Core parameterizes this automatically
var tasks = await _context.Tasks
.Where(t => t.Title.Contains(searchTerm))
.ToListAsync();
// Safe: FromSqlInterpolated uses SQL parameters
var tasks = await _context.Tasks
.FromSqlInterpolated(
$"SELECT * FROM Tasks WHERE Title LIKE {search}")
.ToListAsync();
// UNSAFE: string concatenation — never do this
var sql = "SELECT * FROM Tasks WHERE Title LIKE '%"
+ searchTerm + "%'";
var tasks = await _context.Tasks
.FromSqlRaw(sql) // vulnerable to injection
.ToListAsync();
CreateUserRequest has only Name and Email, but a client sends an extra "IsAdmin": true field that maps onto the entity and elevates their own privileges[FromBody] TaskItem task in a controller action exposes every property on the entity — including ones like CreatedAt, OwnerId, or IsDeleted — to overwriting. Always use a purpose-built request DTO.
.jpg, .png, .pdf) and reject anything else. Never use a blocklist — attackers enumerate extensions you forgot to blockContent-Type header and compare it against the extension. A file named photo.jpg with Content-Type: application/x-executable is not a photoMaxRequestBodySize in Kestrel to prevent multi-GB uploads from exhausting server memory before your action code can reject themPath.GetFileName to strip directory traversal sequences before using it in a file pathAdd FluentValidation to the Task API and verify that the error response shape matches the ProblemDetails format from Session 05.
FluentValidation.AspNetCore and register AddFluentValidationAutoValidation and AddValidatorsFromAssemblyContaining in Program.cs
CreateTaskRequestValidator: title not empty, max 200 characters, no HTML tags; due date must be in the future if provided
UpdateTaskRequestValidator with the same title and due date rules applied to the update DTO
errors dictionary should be keyed by field name and the status should be 400
<script> tags
[ApiController], which returns 400 before the action runserrors dictionary groups messages by field name, keeping the API consistent with the error handling established in Session 05Session 13 — Authentication Theory & JWT
/auth/login endpoint that issues a signed JWT, and all task endpoints will be protected with [Authorize]Add full FluentValidation coverage to the Task Manager API and verify consistent error responses.
What to build:
CreateTaskRequestValidator and UpdateTaskRequestValidator covering: non-empty title, max 200 characters, no HTML tags in title, and due date in the future when providedAddFluentValidationAutoValidation in Program.cs — remove any overlapping DataAnnotation attributes from the DTOs to avoid duplicate error messagesValidationProblemDetails body when any rule is violatedAcceptance criteria:
POST /api/tasks returns 400 with errors.Title containing the expected message<script> returns 400 with the no-HTML-tags messagevalidator.Validate(request), and assert on result.IsValid and the specific error messages — no HTTP request or controller required.