Learnixo
Back to blog
AI Systemsintermediate

FluentValidation — Validating Commands and Queries in the Application Layer

How to use FluentValidation in Clean Architecture: validators for commands, async rules, integration with the handler pipeline, error mapping to Result, and the production pitfalls of validating too late.

Asma Hafeez KhanMay 16, 20265 min read
Clean Architecture.NETFluentValidationValidationCommands
Share:𝕏

Where Validation Lives

API layer:        parse the HTTP request, bind JSON → do NOT validate business rules here
Application layer: validate input data before touching the domain
Domain layer:     enforce invariants inside entities (this is NOT FluentValidation's job)

Production issue I've seen: A team validated everything in the controller's ModelState using data annotations. When they added a second consumer of the same handler (a background job), the validation was completely bypassed. Commands flowed through handlers with null required fields and caused NullReferenceExceptions deep in the domain. FluentValidation validators in the Application layer run regardless of what calls the handler.


Basic Command Validator

C#
// Application/Patients/Commands/CreatePatient/CreatePatientCommandValidator.cs
using FluentValidation;

namespace SystemForge.Application.Patients.Commands.CreatePatient;

public sealed class CreatePatientCommandValidator
    : AbstractValidator<CreatePatientCommand>
{
    public CreatePatientCommandValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty().WithMessage("Patient name is required.")
            .MaximumLength(200).WithMessage("Patient name cannot exceed 200 characters.");

        RuleFor(x => x.DateOfBirth)
            .NotEmpty().WithMessage("Date of birth is required.")
            .Must(dob => dob < DateOnly.FromDateTime(DateTime.UtcNow))
            .WithMessage("Date of birth cannot be in the future.");

        RuleFor(x => x.MRN)
            .NotEmpty().WithMessage("MRN is required.")
            .MaximumLength(50).WithMessage("MRN cannot exceed 50 characters.")
            .Matches(@"^[A-Z0-9\-]+$").WithMessage("MRN must contain only uppercase letters, digits, and hyphens.");
    }
}

Validator for a Drug Order

C#
// Application/DrugOrders/Commands/CreateOrder/CreateDrugOrderCommandValidator.cs
public sealed class CreateDrugOrderCommandValidator
    : AbstractValidator<CreateDrugOrderCommand>
{
    public CreateDrugOrderCommandValidator()
    {
        RuleFor(x => x.PatientId)
            .NotEmpty().WithMessage("Patient ID is required.");

        RuleFor(x => x.OrderedBy)
            .NotEmpty().WithMessage("Prescriber name is required.")
            .MaximumLength(200);

        RuleFor(x => x.Lines)
            .NotEmpty().WithMessage("A drug order must have at least one line.")
            .Must(lines => lines.Count <= 20)
            .WithMessage("A single drug order cannot exceed 20 lines.");

        RuleForEach(x => x.Lines).SetValidator(new OrderLineValidator());
    }
}

public sealed class OrderLineValidator : AbstractValidator<CreateOrderLineRequest>
{
    private static readonly string[] AllowedUnits = ["mg", "mcg", "g", "mL", "IU", "units"];

    public OrderLineValidator()
    {
        RuleFor(x => x.MedicationCode)
            .NotEmpty()
            .MaximumLength(20)
            .Matches(@"^[A-Z0-9]+$").WithMessage("Medication code must be alphanumeric.");

        RuleFor(x => x.DosageAmount)
            .GreaterThan(0).WithMessage("Dosage amount must be positive.");

        RuleFor(x => x.DosageUnit)
            .NotEmpty()
            .Must(u => AllowedUnits.Contains(u?.ToLowerInvariant()))
            .WithMessage($"Dosage unit must be one of: {string.Join(", ", AllowedUnits)}");

        RuleFor(x => x.Frequency)
            .NotEmpty()
            .MaximumLength(100);
    }
}

Async Validators (Database Checks)

C#
// Application/Patients/Commands/CreatePatient/CreatePatientCommandValidator.cs
public sealed class CreatePatientCommandValidator : AbstractValidator<CreatePatientCommand>
{
    private readonly IPatientRepository _patients;

    public CreatePatientCommandValidator(IPatientRepository patients)
    {
        _patients = patients;

        RuleFor(x => x.Name)
            .NotEmpty()
            .MaximumLength(200);

        RuleFor(x => x.MRN)
            .NotEmpty()
            .MaximumLength(50)
            .MustAsync(BeUniqueMRN)
            .WithMessage("A patient with this MRN already exists.");
    }

    private async Task<bool> BeUniqueMRN(
        string mrn,
        CancellationToken ct)
    {
        return !await _patients.ExistsByMRNAsync(mrn, ct);
    }
}

PRO TIP: Be careful with async validation in validators. If you validate in the validator AND check again in the handler, you hit the database twice. Choose one place for the uniqueness check. Validators are good for format checks; handlers are better for business-rule DB lookups because they can return a typed error.


Running Validation in the Handler

Manual validation call inside the handler — no pipeline magic needed:

C#
// Application/Patients/Commands/CreatePatient/CreatePatientCommandHandler.cs
public sealed class CreatePatientCommandHandler
{
    private readonly IPatientRepository _patients;
    private readonly IUnitOfWork _unitOfWork;
    private readonly CreatePatientCommandValidator _validator;

    public CreatePatientCommandHandler(
        IPatientRepository patients,
        IUnitOfWork unitOfWork,
        CreatePatientCommandValidator validator)
    {
        _patients  = patients;
        _unitOfWork = unitOfWork;
        _validator = validator;
    }

    public async Task<Result<PatientId>> Handle(
        CreatePatientCommand command,
        CancellationToken ct)
    {
        var validation = await _validator.ValidateAsync(command, ct);
        if (!validation.IsValid)
        {
            var firstError = validation.Errors[0];
            return Result.Failure<PatientId>(
                Error.Validation(firstError.PropertyName, firstError.ErrorMessage));
        }

        // proceed...
    }
}

Validation Pipeline Decorator (Optional)

If you want to run validation automatically for every command, add a decorator:

C#
// Application/Common/ValidationCommandHandlerDecorator.cs
public sealed class ValidationCommandHandlerDecorator<TCommand, TResponse>
    : ICommandHandler<TCommand, TResponse>
    where TCommand : ICommand<TResponse>
{
    private readonly ICommandHandler<TCommand, TResponse> _inner;
    private readonly IValidator<TCommand>? _validator;

    public ValidationCommandHandlerDecorator(
        ICommandHandler<TCommand, TResponse> inner,
        IValidator<TCommand>? validator = null)
    {
        _inner     = inner;
        _validator = validator;
    }

    public async Task<Result<TResponse>> Handle(TCommand command, CancellationToken ct)
    {
        if (_validator is not null)
        {
            var result = await _validator.ValidateAsync(command, ct);
            if (!result.IsValid)
            {
                var error = result.Errors[0];
                return Result.Failure<TResponse>(
                    Error.Validation(error.PropertyName, error.ErrorMessage));
            }
        }

        return await _inner.Handle(command, ct);
    }
}

Mapping Validation Failures to Problem Details

C#
// Api/Controllers/PatientsController.cs
[HttpPost]
public async Task<IActionResult> Create(CreatePatientRequest request, CancellationToken ct)
{
    var command = new CreatePatientCommand(request.Name, request.DateOfBirth, request.MRN);
    var result  = await _create.Handle(command, ct);

    return result.Match<IActionResult>(
        id  => CreatedAtAction(nameof(GetById), new { id = id.Value }, new { id = id.Value }),
        err =>
        {
            if (err.Code.StartsWith("Validation."))
            {
                ModelState.AddModelError(
                    err.Code.Replace("Validation.", ""),
                    err.Description);
                return ValidationProblem(ModelState);   // 422 Unprocessable Entity
            }

            return Problem(detail: err.Description, statusCode: 400);
        });
}

DI Registration

C#
// Application/DependencyInjection.cs
public static IServiceCollection AddApplication(this IServiceCollection services)
{
    // Registers all AbstractValidator<T> implementations in the assembly
    services.AddValidatorsFromAssembly(typeof(AssemblyReference).Assembly);

    // ... register handlers
    return services;
}

Red Flag Answers

Red flag: "I use [Required] and [MaxLength] data annotations on my command records."

Data annotations on commands tightly couple the Application layer to the presentation model. They only run when called through the ASP.NET model binding pipeline — not when a background job or test calls the handler directly.

Green answer: "FluentValidation validators live in the Application layer alongside the commands they validate. They run whenever the handler runs, regardless of the caller, and return typed errors rather than throwing exceptions."


Key Takeaway

FluentValidation belongs in the Application layer because commands belong in the Application layer. If you put validation in the controller, you break every other consumer of the handler. If you put it in the domain, you mix infrastructure concerns (error messages, field names) with business invariants. The Application layer is the right boundary — it is the single entry point for all callers, background jobs and HTTP requests alike.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.