← Back to the blogValidation in .NET with FluentValidation: Clear Rules, Conditional PATCH, and Tests
dotnetfluentvalidationvalidationaspnet-coretesting

Validation in .NET with FluentValidation: Clear Rules, Conditional PATCH, and Tests

A functional .NET 10 FluentValidation tutorial covering reusable rules, conditional PATCH validation, ProblemDetails, Dependency Injection, and black-box tests.

Validation if statements grow quickly inside a controller. FluentValidation moves those decisions into small, explicit, testable classes without hiding the request flow.


Validating an email address, comparing two passwords, or applying a rule only when a PATCH field is present sounds simple. The trouble starts when those conditions become mixed with persistence, HTTP responses, and business decisions.

In this tutorial, we will build a functional .NET 10 Web API with FluentValidation 12.1.1. The sample includes:

  • Complete account registration.
  • Partial updates with PATCH semantics.
  • Chained rules and CascadeMode.Stop.
  • Cross-property comparison.
  • Conditions with When.
  • A reusable password rule.
  • Automatic Dependency Injection registration.
  • Explicit asynchronous validation with ValidateAsync.
  • ValidationProblemDetails responses.
  • Black-box tests using FluentValidation.TestHelper.

The repository stays in memory so the project runs without a database. Validation is the only subject of the article.

What FluentValidation should validate

In this sample, FluentValidation owns the shape of the input:

  • Required fields.
  • Maximum and minimum lengths.
  • Email format.
  • Allowed characters.
  • Password and confirmation equality.
  • At least one supplied PATCH field.

Rules such as “the email already exists,” “this user may edit the account,” or “the current plan permits this update” belong to the use case or domain. Keeping those categories separate makes validators deterministic and fast.

Create the solution

mkdir ValidationTutorial && cd ValidationTutorial

dotnet new sln --name ValidationTutorial --format slnx
dotnet new webapi --use-controllers --no-https \
  -n ValidationTutorial.Api \
  -o src/ValidationTutorial.Api \
  --framework net10.0

dotnet new xunit \
  -n ValidationTutorial.Tests \
  -o tests/ValidationTutorial.Tests \
  --framework net10.0

dotnet sln add src/ValidationTutorial.Api/ValidationTutorial.Api.csproj
dotnet sln add tests/ValidationTutorial.Tests/ValidationTutorial.Tests.csproj
dotnet add tests/ValidationTutorial.Tests reference \
  src/ValidationTutorial.Api/ValidationTutorial.Api.csproj

dotnet add src/ValidationTutorial.Api package FluentValidation --version 12.1.1
dotnet add src/ValidationTutorial.Api package \
  FluentValidation.DependencyInjectionExtensions --version 12.1.1

Remove the WeatherForecast files generated by the template. The final structure is:

ValidationTutorial/
├── src/ValidationTutorial.Api/
│   ├── Contracts/
│   │   ├── RegisterAccountRequest.cs
│   │   ├── UpdateAccountRequest.cs
│   │   └── AccountResponse.cs
│   ├── Validation/
│   │   ├── ValidationRules.cs
│   │   ├── ValidationMessages.cs
│   │   ├── PasswordRuleExtensions.cs
│   │   ├── RegisterAccountRequestValidator.cs
│   │   └── UpdateAccountRequestValidator.cs
│   ├── Controllers/AccountsController.cs
│   ├── Domain/Account.cs
│   ├── Persistence/
│   │   ├── IAccountRepository.cs
│   │   └── InMemoryAccountRepository.cs
│   └── Program.cs
└── tests/ValidationTutorial.Tests/
    ├── RegisterAccountRequestValidatorTests.cs
    └── UpdateAccountRequestValidatorTests.cs

1. Define the HTTP contracts

Contracts/RegisterAccountRequest.cs

namespace ValidationTutorial.Api.Contracts;

/// <summary>
/// Input required to register a new account.
/// </summary>
public sealed record RegisterAccountRequest(
    string Email,
    string Password,
    string ConfirmPassword,
    string DisplayName,
    string? Bio);

Registration carries every value needed to create an account. Confirmation exists only to validate the user's intent and is never persisted.

Contracts/UpdateAccountRequest.cs

namespace ValidationTutorial.Api.Contracts;

/// <summary>
/// Partial account update. A null property means "leave the current value unchanged".
/// </summary>
public sealed record UpdateAccountRequest(
    string? DisplayName,
    string? Bio);

The fields are nullable because the endpoint uses PATCH semantics. null means “keep the current value”; an empty string was explicitly supplied and should be validated.

Contracts/AccountResponse.cs

using ValidationTutorial.Api.Domain;

namespace ValidationTutorial.Api.Contracts;

/// <summary>
/// Public account representation returned by the API.
/// </summary>
public sealed record AccountResponse(
    Guid Id,
    string Email,
    string DisplayName,
    string? Bio,
    DateTimeOffset CreatedAt)
{
    /// <summary>
    /// Maps the domain entity without exposing its internal implementation.
    /// </summary>
    public static AccountResponse From(Account account)
    {
        return new AccountResponse(
            account.Id,
            account.Email,
            account.DisplayName,
            account.Bio,
            account.CreatedAt);
    }
}

The response stays separate from the entity so the API does not expose domain internals.

2. Centralize limits and messages

Validation/ValidationRules.cs

namespace ValidationTutorial.Api.Validation;

/// <summary>
/// Shared limits and whitelist patterns used by account validators.
/// </summary>
public static class ValidationRules
{
    public const int EmailMaxLength = 254;
    public const int PasswordMinLength = 12;
    public const int DisplayNameMaxLength = 80;
    public const int BioMaxLength = 280;

    public const string DisplayNamePattern =
        @"^[\p{L}\p{M}\p{N}\s._\-]+$";

    public const string UppercasePattern = @"[A-Z]";
    public const string LowercasePattern = @"[a-z]";
    public const string DigitPattern = @"\d";
    public const string SymbolPattern = @"[^a-zA-Z0-9]";
}

Limits and patterns have names. This prevents numbers and regular expressions from being scattered across validators and lets a policy change without searching for magic strings.

The display-name expression is a whitelist: Unicode letters, diacritics, numbers, spaces, and a small set of separators are accepted. Names such as Álvaro García remain valid, while markup such as <script> does not match.

Validation/ValidationMessages.cs

namespace ValidationTutorial.Api.Validation;

/// <summary>
/// Stable error messages returned by the sample API and asserted by tests.
/// </summary>
public static class ValidationMessages
{
    public const string EmailRequired = "Email is required.";
    public const string EmailInvalid = "Email format is invalid.";
    public const string EmailTooLong = "Email is too long.";

    public const string PasswordRequired = "Password is required.";
    public const string PasswordTooShort =
        "Password must contain at least 12 characters.";
    public const string PasswordUppercase =
        "Password must contain an uppercase letter.";
    public const string PasswordLowercase =
        "Password must contain a lowercase letter.";
    public const string PasswordDigit =
        "Password must contain a digit.";
    public const string PasswordSymbol =
        "Password must contain a symbol.";
    public const string PasswordConfirmationRequired =
        "Password confirmation is required.";
    public const string PasswordsDoNotMatch =
        "Password confirmation must match the password.";

    public const string DisplayNameRequired = "Display name is required.";
    public const string DisplayNameTooLong = "Display name is too long.";
    public const string DisplayNameInvalid =
        "Display name contains unsupported characters.";
    public const string BioTooLong = "Bio cannot exceed 280 characters.";
    public const string UpdateRequiresValue =
        "Provide at least one field to update.";
}

Tests can assert stable messages and the API keeps a predictable contract. In a multilingual system, these constants can later become localized resources without changing the rules.

3. Create a reusable rule

Validation/PasswordRuleExtensions.cs

using FluentValidation;

namespace ValidationTutorial.Api.Validation;

/// <summary>
/// Reusable password rules that can be shared by registration and reset flows.
/// </summary>
public static class PasswordRuleExtensions
{
    /// <summary>
    /// Requires a minimum length plus uppercase, lowercase, digit, and symbol.
    /// </summary>
    public static IRuleBuilderOptions<T, string> StrongPassword<T>(
        this IRuleBuilder<T, string> ruleBuilder)
    {
        return ruleBuilder
            .MinimumLength(ValidationRules.PasswordMinLength)
                .WithMessage(ValidationMessages.PasswordTooShort)
            .Matches(ValidationRules.UppercasePattern)
                .WithMessage(ValidationMessages.PasswordUppercase)
            .Matches(ValidationRules.LowercasePattern)
                .WithMessage(ValidationMessages.PasswordLowercase)
            .Matches(ValidationRules.DigitPattern)
                .WithMessage(ValidationMessages.PasswordDigit)
            .Matches(ValidationRules.SymbolPattern)
                .WithMessage(ValidationMessages.PasswordSymbol);
    }
}

FluentValidation lets us extend IRuleBuilder. The policy becomes available like a built-in rule:

RuleFor(request => request.Password)
    .NotEmpty()
    .StrongPassword();

The extension does not query a database or know about an endpoint. It only encapsulates format rules shared by registration, password change, and recovery flows.

4. Validate registration

Validation/RegisterAccountRequestValidator.cs

using FluentValidation;
using ValidationTutorial.Api.Contracts;

namespace ValidationTutorial.Api.Validation;

/// <summary>
/// Validates registration input format and cross-property consistency.
/// </summary>
public sealed class RegisterAccountRequestValidator
    : AbstractValidator<RegisterAccountRequest>
{
    public RegisterAccountRequestValidator()
    {
        RuleLevelCascadeMode = CascadeMode.Stop;

        RuleFor(request => request.Email)
            .NotEmpty().WithMessage(ValidationMessages.EmailRequired)
            .MaximumLength(ValidationRules.EmailMaxLength)
                .WithMessage(ValidationMessages.EmailTooLong)
            .EmailAddress().WithMessage(ValidationMessages.EmailInvalid);

        RuleFor(request => request.Password)
            .NotEmpty().WithMessage(ValidationMessages.PasswordRequired)
            .StrongPassword();

        RuleFor(request => request.ConfirmPassword)
            .NotEmpty()
                .WithMessage(ValidationMessages.PasswordConfirmationRequired)
            .Equal(request => request.Password)
                .WithMessage(ValidationMessages.PasswordsDoNotMatch);

        RuleFor(request => request.DisplayName)
            .NotEmpty().WithMessage(ValidationMessages.DisplayNameRequired)
            .MaximumLength(ValidationRules.DisplayNameMaxLength)
                .WithMessage(ValidationMessages.DisplayNameTooLong)
            .Matches(ValidationRules.DisplayNamePattern)
                .WithMessage(ValidationMessages.DisplayNameInvalid);

        When(request => request.Bio is not null, () =>
        {
            RuleFor(request => request.Bio)
                .MaximumLength(ValidationRules.BioMaxLength)
                .WithMessage(ValidationMessages.BioTooLong);
        });
    }
}

Several choices matter here:

  1. RuleLevelCascadeMode = CascadeMode.Stop prevents later validators from running after an earlier rule fails. An empty email returns “required” rather than several redundant errors.
  2. Equal(request => request.Password) compares two properties on the same object.
  3. When(request => request.Bio is not null) runs the optional rule only when the client supplied the field.
  4. The validator describes input. It does not attempt uniqueness or authorization checks.

5. Validate PATCH input

Validation/UpdateAccountRequestValidator.cs

using FluentValidation;
using ValidationTutorial.Api.Contracts;

namespace ValidationTutorial.Api.Validation;

/// <summary>
/// Validates PATCH input only when an optional property was supplied.
/// </summary>
public sealed class UpdateAccountRequestValidator
    : AbstractValidator<UpdateAccountRequest>
{
    public UpdateAccountRequestValidator()
    {
        RuleLevelCascadeMode = CascadeMode.Stop;

        RuleFor(request => request)
            .Must(HasAtLeastOneValue)
            .WithName("request")
            .WithMessage(ValidationMessages.UpdateRequiresValue);

        When(request => request.DisplayName is not null, () =>
        {
            RuleFor(request => request.DisplayName)
                .NotEmpty().WithMessage(ValidationMessages.DisplayNameRequired)
                .MaximumLength(ValidationRules.DisplayNameMaxLength)
                    .WithMessage(ValidationMessages.DisplayNameTooLong)
                .Matches(ValidationRules.DisplayNamePattern)
                    .WithMessage(ValidationMessages.DisplayNameInvalid);
        });

        When(request => request.Bio is not null, () =>
        {
            RuleFor(request => request.Bio)
                .MaximumLength(ValidationRules.BioMaxLength)
                .WithMessage(ValidationMessages.BioTooLong);
        });
    }

    private static bool HasAtLeastOneValue(UpdateAccountRequest request)
    {
        return request.DisplayName is not null ||
               request.Bio is not null;
    }
}

An empty request should not produce a successful no-op:

{
  "displayName": null,
  "bio": null
}

The object-level rule uses Must(HasAtLeastOneValue) and names the failure request, so ProblemDetails contains a clear key.

Each optional property is then validated inside its own When. The distinction matters:

  • null: the field does not participate in the PATCH.
  • "": the client supplied it and the rule can reject it or treat it as an explicit action.

For Bio, an empty string is valid and the entity normalizes it to null, which lets a client clear the biography.

6. Run explicit validation in ASP.NET Core

Controllers/AccountsController.cs

using FluentValidation;
using FluentValidation.Results;
using Microsoft.AspNetCore.Mvc;
using ValidationTutorial.Api.Contracts;
using ValidationTutorial.Api.Domain;
using ValidationTutorial.Api.Persistence;

namespace ValidationTutorial.Api.Controllers;

/// <summary>
/// Demonstrates explicit asynchronous FluentValidation usage in an API controller.
/// </summary>
[ApiController]
[Route("api/accounts")]
public sealed class AccountsController(
    IValidator<RegisterAccountRequest> registerValidator,
    IValidator<UpdateAccountRequest> updateValidator,
    IAccountRepository repository) : ControllerBase
{
    /// <summary>Registers an account after validating the complete request.</summary>
    [HttpPost]
    public async Task<ActionResult<AccountResponse>> Register(
        RegisterAccountRequest request,
        CancellationToken cancellationToken)
    {
        ValidationResult validation =
            await registerValidator.ValidateAsync(request, cancellationToken);

        if (!validation.IsValid)
        {
            return BadRequest(CreateProblemDetails(validation));
        }

        Account account = Account.Create(
            request.Email,
            request.DisplayName,
            request.Bio);

        await repository.AddAsync(account, cancellationToken);

        return CreatedAtAction(
            nameof(GetById),
            new { id = account.Id },
            AccountResponse.From(account));
    }

    /// <summary>Applies a validated partial update to an existing account.</summary>
    [HttpPatch("{id:guid}")]
    public async Task<ActionResult<AccountResponse>> Update(
        Guid id,
        UpdateAccountRequest request,
        CancellationToken cancellationToken)
    {
        ValidationResult validation =
            await updateValidator.ValidateAsync(request, cancellationToken);

        if (!validation.IsValid)
        {
            return BadRequest(CreateProblemDetails(validation));
        }

        Account? current = await repository.GetByIdAsync(id, cancellationToken);
        if (current is null)
        {
            return NotFound();
        }

        Account updated = current.Update(
            request.DisplayName,
            request.Bio);

        await repository.UpdateAsync(updated, cancellationToken);
        return Ok(AccountResponse.From(updated));
    }

    /// <summary>Returns an account by id.</summary>
    [HttpGet("{id:guid}")]
    public async Task<ActionResult<AccountResponse>> GetById(
        Guid id,
        CancellationToken cancellationToken)
    {
        Account? account = await repository.GetByIdAsync(id, cancellationToken);
        return account is null
            ? NotFound()
            : Ok(AccountResponse.From(account));
    }

    private static ValidationProblemDetails CreateProblemDetails(
        ValidationResult validation)
    {
        return new ValidationProblemDetails(validation.ToDictionary())
        {
            Title = "One or more validation errors occurred.",
            Status = StatusCodes.Status400BadRequest
        };
    }
}

Explicit validation keeps the flow visible:

HTTP request
  → ValidateAsync
  → ValidationProblemDetails or use case
  → HTTP response

The official documentation describes manual validation as the most straightforward and easiest approach to debug. It also supports asynchronous rules, unlike the traditional MVC automatic validation pipeline.

ValidationResult.ToDictionary() groups failures in the shape expected by ValidationProblemDetails:

{
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Email": ["Email format is invalid."],
    "ConfirmPassword": ["Password confirmation must match the password."]
  }
}

Even though this project only contains synchronous rules, the controller always calls ValidateAsync. Adding MustAsync later will not require changing the endpoint.

7. Register validators with Dependency Injection

Program.cs

using FluentValidation;
using ValidationTutorial.Api.Persistence;
using ValidationTutorial.Api.Validation;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddValidatorsFromAssemblyContaining<RegisterAccountRequestValidator>(
    ServiceLifetime.Transient);
builder.Services.AddSingleton<IAccountRepository, InMemoryAccountRepository>();

WebApplication app = builder.Build();

app.MapControllers();

app.Run();

AddValidatorsFromAssemblyContaining discovers public AbstractValidator<T> implementations and registers them as IValidator<T>.

This sample uses ServiceLifetime.Transient, the safest choice when a validator may later receive shorter-lived dependencies. The documentation recommends avoiding singleton validators unless the team carefully controls every injected lifetime.

8. Keep the sample runnable

Domain/Account.cs

namespace ValidationTutorial.Api.Domain;

/// <summary>
/// Minimal account entity used by the validation tutorial.
/// </summary>
public sealed record Account
{
    public required Guid Id { get; init; }
    public required string Email { get; init; }
    public required string DisplayName { get; init; }
    public string? Bio { get; init; }
    public required DateTimeOffset CreatedAt { get; init; }

    /// <summary>
    /// Creates an account after the request has passed input validation.
    /// </summary>
    public static Account Create(
        string email,
        string displayName,
        string? bio)
    {
        return new Account
        {
            Id = Guid.NewGuid(),
            Email = email.Trim().ToLowerInvariant(),
            DisplayName = displayName.Trim(),
            Bio = NormalizeOptionalText(bio),
            CreatedAt = DateTimeOffset.UtcNow
        };
    }

    /// <summary>
    /// Applies PATCH semantics: null values preserve the current property.
    /// </summary>
    public Account Update(
        string? displayName,
        string? bio)
    {
        return this with
        {
            DisplayName = displayName?.Trim() ?? DisplayName,
            Bio = bio is null ? Bio : NormalizeOptionalText(bio)
        };
    }

    private static string? NormalizeOptionalText(string? value)
    {
        return string.IsNullOrWhiteSpace(value)
            ? null
            : value.Trim();
    }
}

The entity receives input that has already passed request validation but still owns normalization. The domain does not depend on FluentValidation.

Persistence/IAccountRepository.cs

using ValidationTutorial.Api.Domain;

namespace ValidationTutorial.Api.Persistence;

/// <summary>
/// Persistence abstraction used by the controller.
/// </summary>
public interface IAccountRepository
{
    Task AddAsync(
        Account account,
        CancellationToken cancellationToken);

    Task<Account?> GetByIdAsync(
        Guid id,
        CancellationToken cancellationToken);

    Task UpdateAsync(
        Account account,
        CancellationToken cancellationToken);
}

Persistence/InMemoryAccountRepository.cs

using System.Collections.Concurrent;
using ValidationTutorial.Api.Domain;

namespace ValidationTutorial.Api.Persistence;

/// <summary>
/// Thread-safe store that keeps the sample runnable without a database.
/// </summary>
public sealed class InMemoryAccountRepository : IAccountRepository
{
    private readonly ConcurrentDictionary<Guid, Account> _accounts = new();

    /// <inheritdoc />
    public Task AddAsync(
        Account account,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        _accounts[account.Id] = account;
        return Task.CompletedTask;
    }

    /// <inheritdoc />
    public Task<Account?> GetByIdAsync(
        Guid id,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        _accounts.TryGetValue(id, out Account? account);
        return Task.FromResult(account);
    }

    /// <inheritdoc />
    public Task UpdateAsync(
        Account account,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        _accounts[account.Id] = account;
        return Task.CompletedTask;
    }
}

The in-memory repository is only demo infrastructure. It can be replaced with EF Core without changing the validators.

9. Test validators as black boxes

RegisterAccountRequestValidatorTests.cs

using FluentValidation.TestHelper;
using ValidationTutorial.Api.Contracts;
using ValidationTutorial.Api.Validation;

namespace ValidationTutorial.Tests;

/// <summary>
/// Black-box tests for registration rules.
/// </summary>
public sealed class RegisterAccountRequestValidatorTests
{
    private readonly RegisterAccountRequestValidator _validator = new();

    [Fact]
    public async Task ValidateAsync_WithValidUnicodeName_Passes()
    {
        RegisterAccountRequest request = CreateValidRequest() with
        {
            DisplayName = "Álvaro García"
        };

        TestValidationResult<RegisterAccountRequest> result =
            await _validator.TestValidateAsync(request);

        result.ShouldNotHaveAnyValidationErrors();
    }

    [Fact]
    public async Task ValidateAsync_WithInvalidEmail_ReturnsEmailError()
    {
        RegisterAccountRequest request = CreateValidRequest() with
        {
            Email = "not-an-email"
        };

        TestValidationResult<RegisterAccountRequest> result =
            await _validator.TestValidateAsync(request);

        result.ShouldHaveValidationErrorFor(item => item.Email)
            .WithErrorMessage(ValidationMessages.EmailInvalid)
            .Only();
    }

    [Fact]
    public async Task ValidateAsync_WithoutUppercasePassword_ReturnsPasswordError()
    {
        RegisterAccountRequest request = CreateValidRequest() with
        {
            Password = "lowercase123!",
            ConfirmPassword = "lowercase123!"
        };

        TestValidationResult<RegisterAccountRequest> result =
            await _validator.TestValidateAsync(request);

        result.ShouldHaveValidationErrorFor(item => item.Password)
            .WithErrorMessage(ValidationMessages.PasswordUppercase)
            .Only();
    }

    [Fact]
    public async Task ValidateAsync_WithDifferentConfirmation_ReturnsMatchError()
    {
        RegisterAccountRequest request = CreateValidRequest() with
        {
            ConfirmPassword = "Different123!"
        };

        TestValidationResult<RegisterAccountRequest> result =
            await _validator.TestValidateAsync(request);

        result.ShouldHaveValidationErrorFor(item => item.ConfirmPassword)
            .WithErrorMessage(ValidationMessages.PasswordsDoNotMatch)
            .Only();
    }

    [Fact]
    public async Task ValidateAsync_WithMarkupInDisplayName_ReturnsCharacterError()
    {
        RegisterAccountRequest request = CreateValidRequest() with
        {
            DisplayName = "Alice<script>"
        };

        TestValidationResult<RegisterAccountRequest> result =
            await _validator.TestValidateAsync(request);

        result.ShouldHaveValidationErrorFor(item => item.DisplayName)
            .WithErrorMessage(ValidationMessages.DisplayNameInvalid)
            .Only();
    }

    private static RegisterAccountRequest CreateValidRequest()
    {
        return new RegisterAccountRequest(
            Email: "[email protected]",
            Password: "StrongPass123!",
            ConfirmPassword: "StrongPass123!",
            DisplayName: "Alice Developer",
            Bio: null);
    }
}

UpdateAccountRequestValidatorTests.cs

using FluentValidation.TestHelper;
using ValidationTutorial.Api.Contracts;
using ValidationTutorial.Api.Validation;

namespace ValidationTutorial.Tests;

/// <summary>
/// Black-box tests for conditional PATCH validation.
/// </summary>
public sealed class UpdateAccountRequestValidatorTests
{
    private readonly UpdateAccountRequestValidator _validator = new();

    [Fact]
    public async Task ValidateAsync_WithoutValues_ReturnsRequestError()
    {
        UpdateAccountRequest request = new(
            DisplayName: null,
            Bio: null);

        TestValidationResult<UpdateAccountRequest> result =
            await _validator.TestValidateAsync(request);

        result.ShouldHaveValidationErrorFor("request")
            .WithErrorMessage(ValidationMessages.UpdateRequiresValue)
            .Only();
    }

    [Fact]
    public async Task ValidateAsync_WithEmptyDisplayName_ReturnsRequiredError()
    {
        UpdateAccountRequest request = new(
            DisplayName: string.Empty,
            Bio: null);

        TestValidationResult<UpdateAccountRequest> result =
            await _validator.TestValidateAsync(request);

        result.ShouldHaveValidationErrorFor(item => item.DisplayName)
            .WithErrorMessage(ValidationMessages.DisplayNameRequired)
            .Only();
    }

    [Fact]
    public async Task ValidateAsync_WithLongBio_ReturnsLengthError()
    {
        UpdateAccountRequest request = new(
            DisplayName: null,
            Bio: new string('A', ValidationRules.BioMaxLength + 1));

        TestValidationResult<UpdateAccountRequest> result =
            await _validator.TestValidateAsync(request);

        result.ShouldHaveValidationErrorFor(item => item.Bio)
            .WithErrorMessage(ValidationMessages.BioTooLong)
            .Only();
    }

    [Fact]
    public async Task ValidateAsync_WithOneValidField_Passes()
    {
        UpdateAccountRequest request = new(
            DisplayName: "Updated Name",
            Bio: null);

        TestValidationResult<UpdateAccountRequest> result =
            await _validator.TestValidateAsync(request);

        result.ShouldNotHaveAnyValidationErrors();
    }
}

The tests instantiate the real validator and assert behavior. They do not need to know how FluentValidation internally builds each rule.

TestValidateAsync can assert the property, message, error code, or severity. The Only() modifier confirms that the scenario did not create additional unexpected failures.

The suite covers:

  • Valid Unicode input.
  • Invalid email.
  • Password without uppercase.
  • Mismatched confirmation.
  • Unsupported characters.
  • Empty PATCH.
  • Supplied empty optional field.
  • Bio length overflow.
  • Valid single-field update.

10. Run the project

dotnet build ValidationTutorial.slnx
dotnet test ValidationTutorial.slnx
dotnet run --project src/ValidationTutorial.Api \
  --urls http://localhost:5100

Try an invalid request:

curl -X POST http://localhost:5100/api/accounts \
  -H 'Content-Type: application/json' \
  -d '{
    "email": "bad",
    "password": "weak",
    "confirmPassword": "different",
    "displayName": "<script>",
    "bio": null
  }'

Register a valid account:

curl -X POST http://localhost:5100/api/accounts \
  -H 'Content-Type: application/json' \
  -d '{
    "email": "[email protected]",
    "password": "StrongPass123!",
    "confirmPassword": "StrongPass123!",
    "displayName": "Álvaro Developer",
    "bio": "Building with .NET"
  }'

Update only the name:

curl -X PATCH http://localhost:5100/api/accounts/<guid> \
  -H 'Content-Type: application/json' \
  -d '{
    "displayName": "Updated Developer",
    "bio": null
  }'

The project in this tutorial builds without warnings, runs 9 tests, and returns 400 ValidationProblemDetails for invalid input.

Mistakes I would avoid

Mixing input validation with business rules

Checking whether an email already exists inside a validator couples validation to persistence and can still race. The use case should make that decision with a unique database constraint.

Using synchronous automatic validation with async rules

The traditional ASP.NET Core automatic validation pipeline is synchronous and is no longer recommended for new projects. If a validator contains MustAsync, invoke ValidateAsync explicitly.

Duplicating rules and messages

Repeated limits, patterns, and policies eventually drift. Named constants and reusable extensions keep one definition.

Validating PATCH fields that were omitted

A nullable field should not fail because it was absent. Guard its rule with When and validate an entirely empty request separately.

Conclusion

FluentValidation is most useful when rules stay explicit, small, and observable:

  • RuleFor describes each property.
  • When models optional fields.
  • Equal expresses relationships between values.
  • Must handles conditions on the complete object.
  • Extensions encapsulate reusable policy.
  • ValidateAsync keeps the flow visible and ready for async rules.
  • TestValidateAsync verifies the contract without relying on internals.

The result is a controller that coordinates HTTP and validators that focus exclusively on input validation.

Sources and further reading


Tutorial validated with .NET 10, FluentValidation 12.1.1, and an xUnit suite of 9 tests.

Comments

Loading comments…