Skip to content

Validation

GrydCrud integrates with FluentValidation for robust input validation.

Setup

Installation

bash
dotnet add package GrydCrud.FluentValidation

Registration

csharp
// Program.cs
using GrydCrud.FluentValidation.Extensions;

builder.Services.AddGrydCrud(options =>
{
    options.EnableValidation = true; // Enable validation pipeline
});

// Register validators from assembly
builder.Services.AddGrydCrudValidation(typeof(CreateProductDtoValidator).Assembly);

// Or register individual validators
builder.Services.AddCrudValidator<CreateProductDto, CreateProductDtoValidator>();
builder.Services.AddCrudValidator<UpdateProductDto, UpdateProductDtoValidator>();

Creating Validators

Basic Validator

csharp
using FluentValidation;

public class CreateProductDtoValidator : AbstractValidator<CreateProductDto>
{
    public CreateProductDtoValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty().WithMessage("Product name is required")
            .MaximumLength(200).WithMessage("Name cannot exceed 200 characters")
            .Must(BeValidProductName).WithMessage("Name contains invalid characters");
        
        RuleFor(x => x.Description)
            .MaximumLength(5000).WithMessage("Description cannot exceed 5000 characters");
        
        RuleFor(x => x.Price)
            .GreaterThan(0).WithMessage("Price must be greater than zero")
            .LessThanOrEqualTo(1_000_000).WithMessage("Price cannot exceed 1,000,000");
        
        RuleFor(x => x.Stock)
            .GreaterThanOrEqualTo(0).WithMessage("Stock cannot be negative");
        
        RuleFor(x => x.CategoryId)
            .NotEmpty().WithMessage("Category is required");
        
        RuleFor(x => x.Sku)
            .NotEmpty().WithMessage("SKU is required")
            .Matches(@"^[A-Z0-9\-]+$").WithMessage("SKU must contain only uppercase letters, numbers, and hyphens");
    }
    
    private bool BeValidProductName(string name)
    {
        if (string.IsNullOrEmpty(name)) return true; // Let NotEmpty handle this
        return !name.Any(c => char.IsControl(c));
    }
}

Validator with Async Rules

csharp
public class CreateProductDtoValidator : AbstractValidator<CreateProductDto>
{
    private readonly ICategoryRepository _categoryRepository;
    private readonly IProductRepository _productRepository;
    
    public CreateProductDtoValidator(
        ICategoryRepository categoryRepository,
        IProductRepository productRepository)
    {
        _categoryRepository = categoryRepository;
        _productRepository = productRepository;
        
        RuleFor(x => x.Name)
            .NotEmpty()
            .MaximumLength(200);
        
        RuleFor(x => x.CategoryId)
            .NotEmpty()
            .MustAsync(CategoryExists).WithMessage("Category does not exist");
        
        RuleFor(x => x.Sku)
            .NotEmpty()
            .MustAsync(BeUniqueSku).WithMessage("SKU already exists");
    }
    
    private async Task<bool> CategoryExists(Guid categoryId, CancellationToken cancellationToken)
    {
        return await _categoryRepository.ExistsAsync(categoryId, cancellationToken);
    }
    
    private async Task<bool> BeUniqueSku(string sku, CancellationToken cancellationToken)
    {
        return !await _productRepository.AnyAsync(p => p.Sku == sku, cancellationToken);
    }
}

Validator with Conditional Rules

csharp
public class UpdateProductDtoValidator : AbstractValidator<UpdateProductDto>
{
    public UpdateProductDtoValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty()
            .MaximumLength(200);
        
        RuleFor(x => x.Price)
            .GreaterThan(0);
        
        // Conditional: Only validate discount if product is on sale
        When(x => x.IsOnSale, () =>
        {
            RuleFor(x => x.DiscountPercentage)
                .InclusiveBetween(1, 99).WithMessage("Discount must be between 1% and 99%");
            
            RuleFor(x => x.SaleEndDate)
                .NotNull().WithMessage("Sale end date is required for sale products")
                .GreaterThan(DateTime.UtcNow).WithMessage("Sale end date must be in the future");
        });
        
        // Unless: Skip validation if product is inactive
        Unless(x => !x.IsActive, () =>
        {
            RuleFor(x => x.Stock)
                .GreaterThan(0).WithMessage("Active products must have stock");
        });
    }
}

Complex Object Validation

csharp
public class CreateOrderDtoValidator : AbstractValidator<CreateOrderDto>
{
    public CreateOrderDtoValidator()
    {
        RuleFor(x => x.CustomerId)
            .NotEmpty();
        
        RuleFor(x => x.ShippingAddress)
            .NotNull().WithMessage("Shipping address is required")
            .SetValidator(new AddressValidator());
        
        RuleFor(x => x.Items)
            .NotEmpty().WithMessage("Order must have at least one item");
        
        RuleForEach(x => x.Items)
            .SetValidator(new OrderItemValidator());
        
        // Cross-field validation
        RuleFor(x => x)
            .Must(HaveValidTotalAmount)
            .WithMessage("Order total exceeds maximum allowed");
    }
    
    private bool HaveValidTotalAmount(CreateOrderDto order)
    {
        var total = order.Items.Sum(i => i.Quantity * i.UnitPrice);
        return total <= 100_000;
    }
}

public class AddressValidator : AbstractValidator<AddressDto>
{
    public AddressValidator()
    {
        RuleFor(x => x.Street).NotEmpty().MaximumLength(200);
        RuleFor(x => x.City).NotEmpty().MaximumLength(100);
        RuleFor(x => x.State).NotEmpty().MaximumLength(100);
        RuleFor(x => x.ZipCode).NotEmpty().Matches(@"^\d{5}(-\d{4})?$");
        RuleFor(x => x.Country).NotEmpty().MaximumLength(100);
    }
}

public class OrderItemValidator : AbstractValidator<OrderItemDto>
{
    public OrderItemValidator()
    {
        RuleFor(x => x.ProductId).NotEmpty();
        RuleFor(x => x.Quantity).GreaterThan(0).LessThanOrEqualTo(100);
        RuleFor(x => x.UnitPrice).GreaterThan(0);
    }
}

Validation in Operations

Automatic Validation

When EnableValidation = true, validation runs automatically:

csharp
public class DefaultCreateOperation<TEntity, TCreateDto, TResultDto>
{
    private readonly IValidator<TCreateDto>? _validator;
    
    public async Task<Result<TResultDto>> ExecuteAsync(TCreateDto dto, CancellationToken cancellationToken)
    {
        // 1. Before validation hook
        var beforeResult = await BeforeValidationAsync(dto, cancellationToken);
        if (!beforeResult.IsSuccess)
            return Result<TResultDto>.Failure(beforeResult.Error);
        
        // 2. FluentValidation (if validator registered)
        if (_validator != null)
        {
            var validationResult = await _validator.ValidateAsync(dto, cancellationToken);
            if (!validationResult.IsValid)
            {
                return Result<TResultDto>.Failure(
                    "VALIDATION_ERROR",
                    validationResult.Errors.Select(e => e.ErrorMessage));
            }
        }
        
        // 3. Continue with operation...
    }
}

Custom Validation in Hooks

csharp
public class ProductCreateOperation : DefaultCreateOperation<Product, CreateProductDto, ProductDto>
{
    protected override async Task<Result> BeforeValidationAsync(
        CreateProductDto dto,
        CancellationToken cancellationToken)
    {
        // Business rule validation (not suitable for FluentValidation)
        var existingProduct = await _repository
            .FirstOrDefaultAsync(p => p.Sku == dto.Sku, cancellationToken);
        
        if (existingProduct != null)
        {
            return Result.Failure("DUPLICATE_SKU", "A product with this SKU already exists");
        }
        
        // Check inventory availability
        var inventoryAvailable = await _inventoryService.CheckAvailabilityAsync(dto.Stock);
        if (!inventoryAvailable)
        {
            return Result.Failure("INVENTORY_UNAVAILABLE", "Requested stock quantity is not available");
        }
        
        return Result.Success();
    }
}

Validation Error Responses

Standard Error Format

When validation fails, GrydCrud returns a structured error response:

json
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "One or more validation errors occurred",
    "details": [
      {
        "field": "name",
        "message": "Product name is required"
      },
      {
        "field": "price",
        "message": "Price must be greater than zero"
      },
      {
        "field": "categoryId",
        "message": "Category does not exist"
      }
    ]
  }
}

Customizing Error Response

csharp
public class CustomValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;
    
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        var context = new ValidationContext<TRequest>(request);
        
        var validationResults = await Task.WhenAll(
            _validators.Select(v => v.ValidateAsync(context, cancellationToken)));
        
        var failures = validationResults
            .SelectMany(r => r.Errors)
            .Where(f => f != null)
            .ToList();
        
        if (failures.Any())
        {
            // Custom error handling
            throw new ValidationException(failures);
        }
        
        return await next();
    }
}

Validation Rulesets

Defining Rulesets

csharp
public class ProductValidator : AbstractValidator<ProductDto>
{
    public ProductValidator()
    {
        // Default rules (always applied)
        RuleFor(x => x.Name).NotEmpty();
        RuleFor(x => x.Price).GreaterThan(0);
        
        // Create-specific rules
        RuleSet("Create", () =>
        {
            RuleFor(x => x.Id).Empty().WithMessage("ID should not be provided for new products");
            RuleFor(x => x.Sku).NotEmpty();
        });
        
        // Update-specific rules
        RuleSet("Update", () =>
        {
            RuleFor(x => x.Id).NotEmpty().WithMessage("ID is required for updates");
        });
        
        // Admin-specific rules
        RuleSet("Admin", () =>
        {
            RuleFor(x => x.CostPrice).NotNull();
            RuleFor(x => x.SupplierCode).NotEmpty();
        });
    }
}

Using Rulesets

csharp
// Validate with specific ruleset
var result = await _validator.ValidateAsync(dto, options => 
    options.IncludeRuleSets("Create"));

// Validate with multiple rulesets
var result = await _validator.ValidateAsync(dto, options => 
    options.IncludeRuleSets("Create", "Admin"));

// Include default rules + ruleset
var result = await _validator.ValidateAsync(dto, options => 
    options.IncludeRulesNotInRuleSet().IncludeRuleSets("Create"));

Localization

Setting Up Localization

csharp
// Program.cs
builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");

ValidatorOptions.Global.LanguageManager.Culture = new CultureInfo("pt-BR");

Localized Validators

csharp
public class CreateProductDtoValidator : AbstractValidator<CreateProductDto>
{
    public CreateProductDtoValidator(IStringLocalizer<ProductResources> localizer)
    {
        RuleFor(x => x.Name)
            .NotEmpty().WithMessage(localizer["ProductNameRequired"])
            .MaximumLength(200).WithMessage(localizer["ProductNameTooLong"]);
        
        RuleFor(x => x.Price)
            .GreaterThan(0).WithMessage(localizer["ProductPriceMustBePositive"]);
    }
}

Custom Validators

Creating Reusable Validators

csharp
public static class CustomValidators
{
    public static IRuleBuilderOptions<T, string> BeValidSlug<T>(
        this IRuleBuilder<T, string> ruleBuilder)
    {
        return ruleBuilder
            .Matches(@"^[a-z0-9]+(?:-[a-z0-9]+)*$")
            .WithMessage("Invalid slug format. Use lowercase letters, numbers, and hyphens.");
    }
    
    public static IRuleBuilderOptions<T, string> BeValidEmail<T>(
        this IRuleBuilder<T, string> ruleBuilder)
    {
        return ruleBuilder
            .EmailAddress()
            .WithMessage("Invalid email address format.");
    }
    
    public static IRuleBuilderOptions<T, Guid> BeExistingEntity<T, TEntity>(
        this IRuleBuilder<T, Guid> ruleBuilder,
        IRepository<TEntity> repository) where TEntity : class
    {
        return ruleBuilder
            .MustAsync(async (id, ct) => await repository.ExistsAsync(id, ct))
            .WithMessage($"{typeof(TEntity).Name} not found.");
    }
}

// Usage
public class ProductValidator : AbstractValidator<CreateProductDto>
{
    public ProductValidator(ICategoryRepository categoryRepository)
    {
        RuleFor(x => x.Slug).BeValidSlug();
        RuleFor(x => x.CategoryId).BeExistingEntity(categoryRepository);
    }
}

Testing Validators

csharp
public class CreateProductDtoValidatorTests
{
    private readonly CreateProductDtoValidator _validator;
    
    public CreateProductDtoValidatorTests()
    {
        _validator = new CreateProductDtoValidator();
    }
    
    [Fact]
    public async Task Should_Have_Error_When_Name_Is_Empty()
    {
        // Arrange
        var dto = new CreateProductDto { Name = "" };
        
        // Act
        var result = await _validator.TestValidateAsync(dto);
        
        // Assert
        result.ShouldHaveValidationErrorFor(x => x.Name)
              .WithErrorMessage("Product name is required");
    }
    
    [Fact]
    public async Task Should_Not_Have_Error_When_Valid()
    {
        // Arrange
        var dto = new CreateProductDto
        {
            Name = "Valid Product",
            Price = 99.99m,
            Stock = 10,
            CategoryId = Guid.NewGuid()
        };
        
        // Act
        var result = await _validator.TestValidateAsync(dto);
        
        // Assert
        result.ShouldNotHaveAnyValidationErrors();
    }
    
    [Theory]
    [InlineData(0)]
    [InlineData(-1)]
    [InlineData(-100)]
    public async Task Should_Have_Error_When_Price_Is_Not_Positive(decimal price)
    {
        // Arrange
        var dto = new CreateProductDto { Price = price };
        
        // Act
        var result = await _validator.TestValidateAsync(dto);
        
        // Assert
        result.ShouldHaveValidationErrorFor(x => x.Price);
    }
}

Best Practices

  1. Keep validators focused - One validator per DTO
  2. Use async for DB checks - Don't block on I/O operations
  3. Validate early, fail fast - Return validation errors before processing
  4. Provide clear messages - Users should understand what's wrong
  5. Localize messages - Support multiple languages from the start
  6. Test validators - Each rule should have unit tests
  7. Separate concerns - Use FluentValidation for input, hooks for business rules

Released under the MIT License.