VSATutorialSeptember 2026 · 8 min read

VSA Todo App — Part 4: FluentValidation Inside Vertical Slices

TL;DR

Part 4 tackles validation in VSA: where validators should live, how to call them from handlers vs the UI, the FluentValidation rules from both production codebases (including enum, child entity, and conditional validation), and how to return standardized validation error responses via ApiResponse.Fail().

Part 4 of 20 in the VSA Todo App Tutorial Series | ← Previous: Part 3|Next: Part 5 →

Help Us Grow

Love this tutorial? Explore our ready-to-use enterprise starter kits built with VSA in .NET 10.

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.

Key Takeaways

  • Server-side validation belongs in the handler, called before entity creation — this is the security boundary
  • Client-side validation in Blazor MudForm or MVC Vue.js is a UX convenience, not a security guarantee
  • ApiResponse.Fail() with validationResult.ToDictionary() returns structured, field-level errors
  • FluentValidation's IsInEnum(), ChildRules, and .When() handle enum, child entity, and conditional validation
  • Validators live in the feature's Cqrs/ folder next to their handler — co-location is the VSA principle

Frequently Asked Questions

Q: Where should validators live in VSA?

In the feature's Cqrs/ folder, next to the handler they validate. CreateTodoValidator.cs next to CreateTodoHandler.cs. This co-location is the core VSA principle — everything for a feature operation in one place. Shared validators defeat the purpose of VSA.

Q: Server-side or client-side validation — which should I use in VSA?

Both. Client-side validation (Blazor MudForm, Vue.js) provides instant feedback and reduces server round-trips. Server-side validation in the handler is the security boundary — it runs regardless of how the handler is called. The MVC Project Manager pattern (validation in handler) is the most robust for APIs.

Q: How do I validate child entities in FluentValidation?

Use RuleForEach(x => x.Items).ChildRules(item => { ... }). This validates each item in the collection independently and returns per-item error messages. Combine with .When() for conditional rules on updates.

Q: Can I reuse validators across VSA slices?

Avoid it. Each feature slice should own its validation rules. If two slices share identical validation, consider whether they're actually the same feature. Duplication in VSA is acceptable when features are genuinely independent. Shared validators create coupling that makes features harder to modify independently.

Part 4 of 20 in the VSA Todo App Tutorial Series | ← Previous: Part 3|Next: Part 5 →

Ready to study real VSA code?

Every Indotalent product is a complete .NET 10 application built with Vertical Slice Architecture. Complete source code — $21 each.

Explore Products

Looking for a ready-to-use traditional monolithic multilayered clean architecture?

Monolithic Clean Architecture — 1,300+ devs, 500+ forks. Clean Arch + CQRS + Repository Pattern. Free & open source for commercial use. ⭐ Star our repo or ❤️ buy our products — your support means everything!

Star on GitHub