Validation is one of the most debated topics in VSA: should it live in the handler, in the endpoint, or in the UI? The answer from production code is clear — server-side validation belongs in the handler, and client-side validation is a UX convenience that mirrors the same rules. This part shows both approaches with real FluentValidation code from the Blazor CRM and MVC Project Manager Todo implementations.
The two codebases take different approaches to where validation runs. The Blazor CRM instantiates validators in the Blazor component's @code block for client-side validation. The MVC Project Manager calls validators inside the handler's HandleAsync method for server-side validation. Both use identical FluentValidation rules. The difference is where the validator is invoked, not what it validates.
Server-Side Validation in Handlers (MVC Project Manager)
The MVC Project Manager calls FluentValidation directly inside the handler. This guarantees validation runs regardless of how the handler is called — from an API, a background job, or a test:
public class CreateTodoHandler
{
public async Task<ApiResponse<CreateTodoResponse>> HandleAsync(
CreateTodoRequest request, CancellationToken cancellationToken)
{
var validator = new CreateTodoValidator();
var validationResult = await validator.ValidateAsync(request, cancellationToken);
if (!validationResult.IsValid)
return ApiResponse<CreateTodoResponse>.Fail(
"Validation failed", validationResult.ToDictionary());
// ... entity creation ...
}
}
The ApiResponse.Fail() with validationResult.ToDictionary() returns a structured error object that the client can parse and display field-by-field. This is vastly better than throwing exceptions — it keeps the handler's contract explicit and makes error handling predictable.
Client-Side Validation in Blazor (Blazor CRM)
The Blazor CRM instantiates validators in the Razor component and uses MudBlazor's built-in validation display:
@code {
private CreateTodoValidator _validator = new();
private CreateTodoRequest _model = new();
}
<MudForm @ref="_form" Model="_model">
<MudTextField @bind-Value="_model.Name"
For="@(() => _model.Name)"
Validation="@(_validator.ValidateValue())" />
</MudForm>
This approach validates on the client before the request is sent, providing instant feedback. However, it's not a security boundary — a malicious client can bypass it. That's why the Blazor CRM still relies on database constraints and proper authorization. For applications that need guaranteed server-side validation, the MVC Project Manager pattern is more robust.
The Validator Rules: What Production Code Actually Validates
The MVC Project Manager's CreateTodoValidator is comprehensive — it validates the main entity, child items, and file attachments all in one validator:
public class CreateTodoValidator : AbstractValidator<CreateTodoRequest>
{
public CreateTodoValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Name is required")
.MaximumLength(200);
RuleFor(x => x.Description)
.MaximumLength(1000);
RuleFor(x => x.Priority)
.IsInEnum().WithMessage("Priority must be a valid value");
RuleFor(x => x.Category)
.IsInEnum().WithMessage("Category must be a valid value");
RuleFor(x => x.Progress)
.InclusiveBetween(0, 100);
RuleFor(x => x.Tags)
.MaximumLength(500);
RuleForEach(x => x.Items).ChildRules(item =>
{
item.RuleFor(i => i.Name)
.NotEmpty().WithMessage("Item name is required")
.MaximumLength(200);
});
RuleForEach(x => x.ImageAttachments).ChildRules(att =>
{
att.RuleFor(a => a.FileName)
.NotEmpty().WithMessage("Image file name is required")
.Must(f => f.EndsWith(".png"))
.WithMessage("Only PNG images are allowed");
});
}
}
Key patterns: IsInEnum() validates enum values, InclusiveBetween validates numeric ranges, RuleForEach with ChildRules validates collections of child entities, and file extension validation ensures only allowed types are uploaded. The Blazor CRM validator is simpler — just name required and max length — because it has fewer domain concepts.
Conditional Validation for Updates
The UpdateTodoValidator adds conditional rules using .When(). File attachments only need validation if they're new (no existing Id):
RuleForEach(x => x.ImageAttachments).ChildRules(att =>
{
att.RuleFor(a => a.FileName)
.NotEmpty().When(a => string.IsNullOrEmpty(a.Id))
.WithMessage("New image attachments require a file name");
});
This prevents re-validating existing attachments that haven't changed while still enforcing rules on new uploads. The diff-based update logic in the handler (Part 7) works hand-in-hand with this conditional validation.
Where Validators Should Live in VSA
Validators belong in the feature's Cqrs/ folder, next to the handler they validate. CreateTodoValidator.cs sits next to CreateTodoHandler.cs. This co-location means you can see the validation rules and the handler logic in the same folder, which is the entire point of VSA. Don't put validators in a shared Validators/ folder — that's the layered architecture mistake VSA was designed to avoid.