dotnet-cqrs/docs/getting-started/05-adding-validation.md

12 KiB

Adding Validation

Add input validation to your commands and queries using FluentValidation.

Why Validation?

Validation ensures:

  • Data integrity - Only valid data enters your system
  • Security - Prevent injection attacks and malformed input
  • User experience - Clear, structured error messages
  • Business rules - Enforce domain constraints

Install FluentValidation

Add the required packages:

dotnet add package Svrnty.CQRS.FluentValidation
dotnet add package FluentValidation

Step 1: Create a Validator

Let's add validation to the CreateUserCommand from the previous guide.

Create Validators/CreateUserCommandValidator.cs:

using FluentValidation;
using MyApp.Commands;

namespace MyApp.Validators;

public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
    public CreateUserCommandValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty()
            .WithMessage("Name is required")
            .MaximumLength(100)
            .WithMessage("Name must not exceed 100 characters");

        RuleFor(x => x.Email)
            .NotEmpty()
            .WithMessage("Email is required")
            .EmailAddress()
            .WithMessage("Email must be a valid email address");
    }
}

Step 2: Register the Validator

Option 1: Register Command with Validator

// Program.cs
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler, CreateUserCommandValidator>();

This single line registers:

  1. The command handler
  2. The validator
  3. The metadata for discovery

Option 2: Register Separately

// Register command
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();

// Register validator
builder.Services.AddTransient<IValidator<CreateUserCommand>, CreateUserCommandValidator>();

Step 3: Test Validation

Valid Request

curl -X POST http://localhost:5000/api/command/createUser \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Alice Smith",
    "email": "alice@example.com"
  }'

Response (200 OK):

123

Invalid Request

curl -X POST http://localhost:5000/api/command/createUser \
  -H "Content-Type: application/json" \
  -d '{
    "name": "",
    "email": "invalid-email"
  }'

HTTP Response (400 Bad Request):

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Name": [
      "Name is required"
    ],
    "Email": [
      "Email must be a valid email address"
    ]
  }
}

This follows RFC 7807 (Problem Details for HTTP APIs).

Validation Rules

FluentValidation provides many built-in validators:

Required Fields

RuleFor(x => x.Name)
    .NotEmpty()
    .WithMessage("Name is required");

String Length

RuleFor(x => x.Name)
    .MinimumLength(3)
    .WithMessage("Name must be at least 3 characters")
    .MaximumLength(100)
    .WithMessage("Name must not exceed 100 characters");

Email Validation

RuleFor(x => x.Email)
    .EmailAddress()
    .WithMessage("Email must be a valid email address");

Numeric Range

RuleFor(x => x.Age)
    .GreaterThan(0)
    .WithMessage("Age must be greater than 0")
    .LessThanOrEqualTo(120)
    .WithMessage("Age must be less than or equal to 120");

Regular Expression

RuleFor(x => x.PhoneNumber)
    .Matches(@"^\d{3}-\d{3}-\d{4}$")
    .WithMessage("Phone number must be in format: 123-456-7890");

Must (Custom Rule)

RuleFor(x => x.StartDate)
    .Must(BeAFutureDate)
    .WithMessage("Start date must be in the future");

private bool BeAFutureDate(DateTime date)
{
    return date > DateTime.UtcNow;
}

Nested Object Validation

public record CreateOrderCommand
{
    public AddressDto ShippingAddress { get; init; } = null!;
    public List<OrderItemDto> Items { get; init; } = new();
}

public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderCommandValidator()
    {
        RuleFor(x => x.ShippingAddress)
            .NotNull()
            .SetValidator(new AddressValidator());

        RuleForEach(x => x.Items)
            .SetValidator(new OrderItemValidator());

        RuleFor(x => x.Items)
            .NotEmpty()
            .WithMessage("Order must contain at least one item");
    }
}

public class AddressValidator : AbstractValidator<AddressDto>
{
    public AddressValidator()
    {
        RuleFor(x => x.Street).NotEmpty();
        RuleFor(x => x.City).NotEmpty();
        RuleFor(x => x.ZipCode).Matches(@"^\d{5}$");
    }
}

Complete Validation Example

Here's a comprehensive validator:

using FluentValidation;

namespace MyApp.Validators;

public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
    private readonly IUserRepository _userRepository;

    public CreateUserCommandValidator(IUserRepository userRepository)
    {
        _userRepository = userRepository;

        // Required fields
        RuleFor(x => x.Name)
            .NotEmpty().WithMessage("Name is required")
            .MaximumLength(100).WithMessage("Name must not exceed 100 characters");

        RuleFor(x => x.Email)
            .NotEmpty().WithMessage("Email is required")
            .EmailAddress().WithMessage("Email must be a valid email address")
            .MustAsync(BeUniqueEmail).WithMessage("Email already exists");

        RuleFor(x => x.Age)
            .GreaterThan(0).WithMessage("Age must be greater than 0")
            .LessThanOrEqualTo(120).WithMessage("Age must be realistic");

        RuleFor(x => x.PhoneNumber)
            .Matches(@"^\d{3}-\d{3}-\d{4}$")
            .When(x => !string.IsNullOrEmpty(x.PhoneNumber))
            .WithMessage("Phone number must be in format: 123-456-7890");
    }

    private async Task<bool> BeUniqueEmail(string email, CancellationToken cancellationToken)
    {
        var existingUser = await _userRepository.GetByEmailAsync(email, cancellationToken);
        return existingUser == null;
    }
}

Async Validation

For validation that requires database access or external API calls:

RuleFor(x => x.Email)
    .MustAsync(BeUniqueEmail)
    .WithMessage("Email already exists");

private async Task<bool> BeUniqueEmail(string email, CancellationToken cancellationToken)
{
    var exists = await _userRepository.EmailExistsAsync(email, cancellationToken);
    return !exists;
}

Conditional Validation

Validate only when certain conditions are met:

// Validate only when property is not null
RuleFor(x => x.ShippingAddress)
    .SetValidator(new AddressValidator())
    .When(x => x.ShippingAddress != null);

// Validate based on other property
RuleFor(x => x.CreditCardNumber)
    .NotEmpty()
    .When(x => x.PaymentMethod == "CreditCard")
    .WithMessage("Credit card number is required for credit card payments");

Validating Queries

Queries can also be validated:

// Query
public record SearchUsersQuery
{
    public string Keyword { get; init; } = string.Empty;
    public int Page { get; init; } = 1;
    public int PageSize { get; init; } = 10;
}

// Validator
public class SearchUsersQueryValidator : AbstractValidator<SearchUsersQuery>
{
    public SearchUsersQueryValidator()
    {
        RuleFor(x => x.Keyword)
            .MinimumLength(3)
            .When(x => !string.IsNullOrEmpty(x.Keyword))
            .WithMessage("Keyword must be at least 3 characters");

        RuleFor(x => x.Page)
            .GreaterThan(0)
            .WithMessage("Page must be greater than 0");

        RuleFor(x => x.PageSize)
            .InclusiveBetween(1, 100)
            .WithMessage("Page size must be between 1 and 100");
    }
}

// Registration
builder.Services.AddQuery<SearchUsersQuery, List<UserDto>, SearchUsersQueryHandler, SearchUsersQueryValidator>();

HTTP vs gRPC Validation

HTTP (Minimal API)

Validation errors return RFC 7807 Problem Details:

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Email": ["Email is required", "Email must be a valid email address"],
    "Age": ["Age must be greater than 0"]
  }
}

HTTP Status: 400 Bad Request

gRPC

Validation errors return Google Rich Error Model:

status {
  code: 3  // INVALID_ARGUMENT
  message: "Validation failed"
  details: [
    google.rpc.BadRequest {
      field_violations: [
        { field: "Email", description: "Email is required" },
        { field: "Email", description: "Email must be a valid email address" },
        { field: "Age", description: "Age must be greater than 0" }
      ]
    }
  ]
}

gRPC Status Code: INVALID_ARGUMENT

Both formats are automatically generated by Svrnty.CQRS!

Validation Best Practices

DO

  • Validate early - At the API boundary
  • Use descriptive messages - Help users fix errors
  • Validate business rules - Not just data types
  • Use async validation - For database checks
  • Return all errors - Don't stop at first error
  • Validate commands AND queries - Both need validation

DON'T

  • Don't validate in handlers - Use validators
  • Don't use exceptions - Let FluentValidation handle it
  • Don't skip validation - Even for "internal" commands
  • Don't return generic messages - Be specific
  • Don't over-validate - Balance security and usability

Custom Validators

Create reusable validators:

public static class CustomValidators
{
    public static IRuleBuilderOptions<T, string> MustBeValidUrl<T>(
        this IRuleBuilder<T, string> ruleBuilder)
    {
        return ruleBuilder
            .Must(url => Uri.TryCreate(url, UriKind.Absolute, out _))
            .WithMessage("'{PropertyName}' must be a valid URL");
    }

    public static IRuleBuilderOptions<T, string> MustBeStrongPassword<T>(
        this IRuleBuilder<T, string> ruleBuilder)
    {
        return ruleBuilder
            .MinimumLength(8).WithMessage("Password must be at least 8 characters")
            .Matches(@"[A-Z]").WithMessage("Password must contain uppercase letter")
            .Matches(@"[a-z]").WithMessage("Password must contain lowercase letter")
            .Matches(@"\d").WithMessage("Password must contain digit")
            .Matches(@"[^\w]").WithMessage("Password must contain special character");
    }
}

// Usage
RuleFor(x => x.Website)
    .MustBeValidUrl();

RuleFor(x => x.Password)
    .MustBeStrongPassword();

Troubleshooting

Validation Not Running

Problem: Requests succeed even with invalid data

Solutions:

  1. Ensure you installed Svrnty.CQRS.FluentValidation
  2. Verify validator is registered in DI
  3. Check validator class inherits AbstractValidator<T>

Validation Always Fails

Problem: All requests return 400 even with valid data

Solutions:

  1. Check validator rules are correct
  2. Verify async validators return correct boolean
  3. Ensure property names match exactly

Multiple Validators Registered

Problem: Conflicting validation rules

Solutions:

  1. Only register one validator per command/query
  2. Combine rules in a single validator
  3. Use RuleSet for conditional validation

What's Next?

Now you know how to add validation! Let's discuss when to use HTTP vs gRPC.

Continue to Choosing HTTP vs gRPC

See Also