Form Validation Patterns
Comprehensive validation patterns for PearDrop applications, from server-side FluentValidation to client-side Blazor forms.
Validation Architecture
Client Side Server Side
─────────── ───────────
Blazor EditForm FluentValidation
DataAnnotations ──────► AbstractValidator<TCommand>
(Run in MediatR pipeline)
↓
Command Handler (if valid)
Server-Side Validation (FluentValidation)
Basic Command Validator
Location: Infrastructure/Domain/ProductAggregate/CommandValidators/CreateProductCommandValidator.cs
using FluentValidation;
using YourApp.Infrastructure.Domain.ProductAggregate.Commands;
namespace YourApp.Infrastructure.Domain.ProductAggregate.CommandValidators;
public sealed class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>
{
public CreateProductCommandValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Product name is required")
.MaximumLength(200).WithMessage("Name cannot exceed 200 characters")
.MinimumLength(3).WithMessage("Name must be at least 3 characters");
RuleFor(x => x.Price)
.GreaterThan(0).WithMessage("Price must be greater than zero")
.LessThan(1000000).WithMessage("Price cannot exceed $1,000,000");
RuleFor(x => x.CategoryId)
.NotEqual(Guid.Empty).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");
}
}
Conditional Validation
public sealed class UpdateOrderCommandValidator : AbstractValidator<UpdateOrderCommand>
{
public UpdateOrderCommandValidator()
{
RuleFor(x => x.OrderId)
.NotEqual(Guid.Empty).WithMessage("Order ID is required");
RuleFor(x => x.Status)
.IsInEnum().WithMessage("Invalid order status");
// Conditional: If status is "Shipped", tracking number is required
RuleFor(x => x.TrackingNumber)
.NotEmpty()
.When(x => x.Status == OrderStatus.Shipped)
.WithMessage("Tracking number required when order is shipped");
// Conditional: If payment method is credit card, card details required
RuleFor(x => x.CardNumber)
.NotEmpty()
.CreditCard()
.When(x => x.PaymentMethod == PaymentMethod.CreditCard)
.WithMessage("Valid credit card number required");
}
}
Complex Business Rules
public sealed class CreateInvoiceCommandValidator : AbstractValidator<CreateInvoiceCommand>
{
public CreateInvoiceCommandValidator()
{
RuleFor(x => x.CustomerId)
.NotEqual(Guid.Empty).WithMessage("Customer is required");
RuleFor(x => x.Items)
.NotEmpty().WithMessage("Invoice must have at least one item")
.Must(items => items.Count <= 100).WithMessage("Invoice cannot exceed 100 items");
// Validate each item in collection
RuleForEach(x => x.Items)
.SetValidator(new InvoiceItemValidator());
// Custom business rule
RuleFor(x => x)
.Must(BeValidTotalCalculation)
.WithMessage("Invoice total does not match sum of items");
// Date range validation
RuleFor(x => x.DueDate)
.GreaterThanOrEqualTo(x => x.InvoiceDate)
.WithMessage("Due date must be on or after invoice date")
.LessThanOrEqualTo(x => x.InvoiceDate.AddDays(90))
.WithMessage("Due date cannot be more than 90 days from invoice date");
}
private bool BeValidTotalCalculation(CreateInvoiceCommand command)
{
var calculatedTotal = command.Items.Sum(i => i.Quantity * i.UnitPrice);
var tolerance = 0.01m; // Allow 1 cent rounding difference
return Math.Abs(command.Total - calculatedTotal) < tolerance;
}
}
public sealed class InvoiceItemValidator : AbstractValidator<InvoiceItem>
{
public InvoiceItemValidator()
{
RuleFor(x => x.ProductId)
.NotEqual(Guid.Empty).WithMessage("Product is required");
RuleFor(x => x.Quantity)
.GreaterThan(0).WithMessage("Quantity must be greater than zero")
.LessThanOrEqualTo(1000).WithMessage("Quantity cannot exceed 1000");
RuleFor(x => x.UnitPrice)
.GreaterThanOrEqualTo(0).WithMessage("Unit price cannot be negative");
}
}
Async Validation (Database Checks)
using Microsoft.EntityFrameworkCore;
public sealed class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
private readonly IAppReadModels readModels;
public CreateUserCommandValidator(IAppReadModels readModels)
{
this.readModels = readModels;
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required")
.EmailAddress().WithMessage("Invalid email format")
.MustAsync(BeUniqueEmail)
.WithMessage("Email already in use");
RuleFor(x => x.Username)
.NotEmpty().WithMessage("Username is required")
.MinimumLength(3).WithMessage("Username must be at least 3 characters")
.MaximumLength(50).WithMessage("Username max 50 characters")
.Matches(@"^[a-zA-Z0-9_]+$").WithMessage("Username can only contain letters, numbers, and underscores")
.MustAsync(BeUniqueUsername)
.WithMessage("Username already taken");
}
private async Task<bool> BeUniqueEmail(string email, CancellationToken cancellationToken)
{
var exists = await readModels.Users
.AnyAsync(u => u.Email.ToLower() == email.ToLower(), cancellationToken);
return !exists;
}
private async Task<bool> BeUniqueUsername(string username, CancellationToken cancellationToken)
{
var exists = await readModels.Users
.AnyAsync(u => u.Username.ToLower() == username.ToLower(), cancellationToken);
return !exists;
}
}
Update Validators (Exclude Current Record)
public sealed class UpdateUserCommandValidator : AbstractValidator<UpdateUserCommand>
{
private readonly IAppReadModels readModels;
public UpdateUserCommandValidator(IAppReadModels readModels)
{
this.readModels = readModels;
RuleFor(x => x.UserId)
.NotEqual(Guid.Empty).WithMessage("User ID is required");
RuleFor(x => x)
.MustAsync(BeUniqueEmailForUser)
.WithMessage("Email already in use by another user");
}
private async Task<bool> BeUniqueEmailForUser(
UpdateUserCommand command,
CancellationToken cancellationToken)
{
var exists = await readModels.Users
.AnyAsync(u =>
u.Email.ToLower() == command.Email.ToLower() &&
u.Id != command.UserId,
cancellationToken);
return !exists;
}
}
Client-Side Validation (Blazor)
Basic EditForm with DataAnnotations
Component: ProductForm.razor
<EditForm Model="model" OnValidSubmit="HandleSubmit">
<DataAnnotationsValidator />
<ValidationSummary />
<div class="form-group">
<label for="name">Product Name</label>
<InputText id="name" class="form-control" @bind-Value="model.Name" />
<ValidationMessage For="@(() => model.Name)" />
</div>
<div class="form-group">
<label for="price">Price</label>
<InputNumber id="price" class="form-control" @bind-Value="model.Price" />
<ValidationMessage For="@(() => model.Price)" />
</div>
<div class="form-group">
<label for="category">Category</label>
<InputSelect id="category" class="form-control" @bind-Value="model.CategoryId">
<option value="">-- Select Category --</option>
@foreach (var category in categories)
{
<option value="@category.Id">@category.Name</option>
}
</InputSelect>
<ValidationMessage For="@(() => model.CategoryId)" />
</div>
<button type="submit" class="btn-primary" disabled="@isSubmitting">
@(isSubmitting ? "Saving..." : "Save Product")
</button>
</EditForm>
Model Class:
using System.ComponentModel.DataAnnotations;
public class ProductFormModel
{
[Required(ErrorMessage = "Product name is required")]
[StringLength(200, MinimumLength = 3,
ErrorMessage = "Name must be between 3 and 200 characters")]
public string Name { get; set; } = string.Empty;
[Required(ErrorMessage = "Price is required")]
[Range(0.01, 1000000, ErrorMessage = "Price must be between $0.01 and $1,000,000")]
public decimal Price { get; set; }
[Required(ErrorMessage = "Category is required")]
public Guid? CategoryId { get; set; }
[Required(ErrorMessage = "SKU is required")]
[RegularExpression(@"^[A-Z0-9-]+$",
ErrorMessage = "SKU must contain only uppercase letters, numbers, and hyphens")]
public string Sku { get; set; } = string.Empty;
}
Custom Validation Attributes
using System.ComponentModel.DataAnnotations;
/// <summary>
/// Validates that a date is not in the past
/// </summary>
public class NotPastDateAttribute : ValidationAttribute
{
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
{
if (value is DateTime dateTime && dateTime.Date < DateTime.Today)
{
return new ValidationResult(ErrorMessage ?? "Date cannot be in the past");
}
if (value is DateOnly dateOnly && dateOnly < DateOnly.FromDateTime(DateTime.Today))
{
return new ValidationResult(ErrorMessage ?? "Date cannot be in the past");
}
return ValidationResult.Success;
}
}
/// <summary>
/// Validates that the value matches another property
/// </summary>
public class CompareToAttribute : ValidationAttribute
{
private readonly string _comparisonProperty;
public CompareToAttribute(string comparisonProperty)
{
_comparisonProperty = comparisonProperty;
}
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
{
var comparisonProperty = validationContext.ObjectType.GetProperty(_comparisonProperty);
if (comparisonProperty == null)
{
throw new ArgumentException($"Property {_comparisonProperty} not found");
}
var comparisonValue = comparisonProperty.GetValue(validationContext.ObjectInstance);
if (!Equals(value, comparisonValue))
{
return new ValidationResult(ErrorMessage ??
$"Value must match {_comparisonProperty}");
}
return ValidationResult.Success;
}
}
// Usage:
public class ChangePasswordModel
{
[Required]
[StringLength(100, MinimumLength = 8)]
public string NewPassword { get; set; } = string.Empty;
[Required]
[CompareTo(nameof(NewPassword), ErrorMessage = "Passwords must match")]
public string ConfirmPassword { get; set; } = string.Empty;
}
Manual Validation in Code-Behind
public partial class ProductForm
{
private ProductFormModel model = new();
private ValidationMessageStore? validationMessages;
private EditContext? editContext;
protected override void OnInitialized()
{
editContext = new EditContext(model);
validationMessages = new ValidationMessageStore(editContext);
editContext.OnValidationRequested += HandleValidationRequested;
}
private void HandleValidationRequested(object? sender, ValidationRequestedEventArgs e)
{
validationMessages?.Clear();
// Custom validation logic
if (model.Price < 10 && model.CategoryId == expensiveCategoryId)
{
validationMessages?.Add(
editContext!.Field(nameof(model.Price)),
"Premium category products must be priced at $10 or more");
}
if (string.IsNullOrWhiteSpace(model.Sku) ||
!model.Sku.StartsWith("PROD-", StringComparison.OrdinalIgnoreCase))
{
validationMessages?.Add(
editContext!.Field(nameof(model.Sku)),
"SKU must start with 'PROD-'");
}
}
public void Dispose()
{
if (editContext != null)
{
editContext.OnValidationRequested -= HandleValidationRequested;
}
}
}
Server Validation Error Display
When server-side validation fails, display errors returned from the API:
public partial class ProductEditor
{
private ProductFormModel model = new();
private Dictionary<string, List<string>> serverErrors = new();
private async Task HandleSubmit()
{
serverErrors.Clear();
var command = new CreateProductCommand(
model.Name,
model.Price,
model.CategoryId!.Value,
model.Sku);
var result = await CommandRunner.Send(command);
if (result.IsSuccess)
{
Navigation.NavigateTo("/products");
}
else
{
// Parse validation errors from server response
if (result.Error?.ValidationErrors != null)
{
foreach (var error in result.Error.ValidationErrors)
{
if (!serverErrors.ContainsKey(error.PropertyName))
{
serverErrors[error.PropertyName] = new List<string>();
}
serverErrors[error.PropertyName].Add(error.ErrorMessage);
}
}
}
}
}
Display server errors in Razor:
@if (serverErrors.Any())
{
<div class="alert alert-danger">
<h4>Validation Errors:</h4>
<ul>
@foreach (var (field, errors) in serverErrors)
{
@foreach (var error in errors)
{
<li><strong>@field:</strong> @error</li>
}
}
</ul>
</div>
}
Best Practices
✅ DO
- Use FluentValidation on server - Primary validation layer
- Use DataAnnotations on client - Immediate user feedback
- Validate in both places - Defense in depth
- Return specific error messages - Help users fix issues
- Use async validation sparingly - Database checks only when necessary
- Test validators - Unit test complex validation rules
❌ DON'T
- Trust client-side validation alone - Always validate on server
- Put business logic in validators - Keep in domain aggregates
- Use validation for authorization - Use IAuthorizationHandler
- Over-validate - Balance security with user experience
- Forget to dispose EditContext - Causes memory leaks
Testing Validators
using FluentValidation.TestHelper;
using Xunit;
public class CreateProductCommandValidatorTests
{
private readonly CreateProductCommandValidator validator;
public CreateProductCommandValidatorTests()
{
validator = new CreateProductCommandValidator();
}
[Fact]
public void Should_HaveError_When_NameIsEmpty()
{
var command = new CreateProductCommand("", 10.00m, Guid.NewGuid(), "PROD-001");
var result = validator.TestValidate(command);
result.ShouldHaveValidationErrorFor(x => x.Name);
}
[Fact]
public void Should_HaveError_When_PriceIsZero()
{
var command = new CreateProductCommand("Product", 0, Guid.NewGuid(), "PROD-001");
var result = validator.TestValidate(command);
result.ShouldHaveValidationErrorFor(x => x.Price);
}
[Fact]
public void Should_NotHaveError_When_CommandIsValid()
{
var command = new CreateProductCommand(
"Valid Product",
99.99m,
Guid.NewGuid(),
"PROD-001");
var result = validator.TestValidate(command);
result.ShouldNotHaveAnyValidationErrors();
}
}
Next Steps
- Complete CRUD Feature - Full implementation example
- Search and Filtering - Query patterns
- Security & Authorization - Beyond validation
See FluentValidation docs: https://docs.fluentvalidation.net/