479 lines
12 KiB
Markdown
479 lines
12 KiB
Markdown
# 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:
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```csharp
|
|
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
|
|
|
|
```csharp
|
|
// 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
|
|
|
|
```csharp
|
|
// Register command
|
|
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
|
|
|
|
// Register validator
|
|
builder.Services.AddTransient<IValidator<CreateUserCommand>, CreateUserCommandValidator>();
|
|
```
|
|
|
|
## Step 3: Test Validation
|
|
|
|
### Valid Request
|
|
|
|
```bash
|
|
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):**
|
|
|
|
```json
|
|
123
|
|
```
|
|
|
|
### Invalid Request
|
|
|
|
```bash
|
|
curl -X POST http://localhost:5000/api/command/createUser \
|
|
-H "Content-Type: application/json" \
|
|
-d '{
|
|
"name": "",
|
|
"email": "invalid-email"
|
|
}'
|
|
```
|
|
|
|
**HTTP Response (400 Bad Request):**
|
|
|
|
```json
|
|
{
|
|
"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
|
|
|
|
```csharp
|
|
RuleFor(x => x.Name)
|
|
.NotEmpty()
|
|
.WithMessage("Name is required");
|
|
```
|
|
|
|
### String Length
|
|
|
|
```csharp
|
|
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
|
|
|
|
```csharp
|
|
RuleFor(x => x.Email)
|
|
.EmailAddress()
|
|
.WithMessage("Email must be a valid email address");
|
|
```
|
|
|
|
### Numeric Range
|
|
|
|
```csharp
|
|
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
|
|
|
|
```csharp
|
|
RuleFor(x => x.PhoneNumber)
|
|
.Matches(@"^\d{3}-\d{3}-\d{4}$")
|
|
.WithMessage("Phone number must be in format: 123-456-7890");
|
|
```
|
|
|
|
### Must (Custom Rule)
|
|
|
|
```csharp
|
|
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
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
// 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:
|
|
|
|
```csharp
|
|
// 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**:
|
|
|
|
```json
|
|
{
|
|
"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**:
|
|
|
|
```protobuf
|
|
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:
|
|
|
|
```csharp
|
|
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](06-choosing-http-or-grpc.md) →**
|
|
|
|
## See Also
|
|
|
|
- [HTTP Validation](../core-features/validation/http-validation.md) - RFC 7807 Problem Details
|
|
- [gRPC Validation](../core-features/validation/grpc-validation.md) - Google Rich Error Model
|
|
- [Custom Validation](../core-features/validation/custom-validation.md) - Advanced validation scenarios
|
|
- [FluentValidation Documentation](https://docs.fluentvalidation.net/) - Official FluentValidation docs
|