Skip to main content

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


See FluentValidation docs: https://docs.fluentvalidation.net/