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

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