Validation
GrydCrud integrates with FluentValidation for robust input validation.
Setup
Installation
bash
dotnet add package GrydCrud.FluentValidationRegistration
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
- Keep validators focused - One validator per DTO
- Use async for DB checks - Don't block on I/O operations
- Validate early, fail fast - Return validation errors before processing
- Provide clear messages - Users should understand what's wrong
- Localize messages - Support multiple languages from the start
- Test validators - Each rule should have unit tests
- Separate concerns - Use FluentValidation for input, hooks for business rules