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:
- The command handler
- The validator
- 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:
- Ensure you installed
Svrnty.CQRS.FluentValidation - Verify validator is registered in DI
- Check validator class inherits
AbstractValidator<T>
Validation Always Fails
Problem: All requests return 400 even with valid data
Solutions:
- Check validator rules are correct
- Verify async validators return correct boolean
- Ensure property names match exactly
Multiple Validators Registered
Problem: Conflicting validation rules
Solutions:
- Only register one validator per command/query
- Combine rules in a single validator
- Use
RuleSetfor 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
- HTTP Validation - RFC 7807 Problem Details
- gRPC Validation - Google Rich Error Model
- Custom Validation - Advanced validation scenarios
- FluentValidation Documentation - Official FluentValidation docs