FullStackHero uses FluentValidation to validate commands and queries. Validation happens automatically in the mediator pipeline before the handler executes.
Every command must have a corresponding validator. This is enforced by architecture tests.
using FluentValidation;using FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup;namespace FSH.Modules.Identity.Features.v1.Groups.CreateGroup;public sealed class CreateGroupCommandValidator : AbstractValidator<CreateGroupCommand>{ public CreateGroupCommandValidator() { RuleFor(x => x.Name) .NotEmpty().WithMessage("Group name is required.") .MaximumLength(256).WithMessage("Group name must not exceed 256 characters."); RuleFor(x => x.Description) .MaximumLength(1024).WithMessage("Description must not exceed 1024 characters."); }}
using FluentValidation;using FSH.Modules.Identity.Contracts.v1.Groups.UpdateGroup;namespace FSH.Modules.Identity.Features.v1.Groups.UpdateGroup;public sealed class UpdateGroupCommandValidator : AbstractValidator<UpdateGroupCommand>{ public UpdateGroupCommandValidator() { RuleFor(x => x.Id) .NotEmpty().WithMessage("Group ID is required."); RuleFor(x => x.Name) .NotEmpty().WithMessage("Group name is required.") .MaximumLength(256).WithMessage("Group name must not exceed 256 characters."); RuleFor(x => x.Description) .MaximumLength(1024).WithMessage("Description must not exceed 1024 characters."); }}
public sealed class AddUsersToGroupCommandValidator : AbstractValidator<AddUsersToGroupCommand>{ public AddUsersToGroupCommandValidator() { RuleFor(x => x.GroupId) .NotEmpty() .WithMessage("Group ID is required."); RuleFor(x => x.UserIds) .NotNull() .NotEmpty() .WithMessage("At least one user ID is required."); RuleForEach(x => x.UserIds) .NotEmpty() .WithMessage("User ID cannot be empty."); }}
public sealed class UpdateUserCommandValidator : AbstractValidator<UpdateUserCommand>{ public UpdateUserCommandValidator() { RuleFor(x => x.Email) .NotEmpty() .EmailAddress(); // Only validate password if it's being changed RuleFor(x => x.NewPassword) .MinimumLength(8) .When(x => !string.IsNullOrEmpty(x.NewPassword)) .WithMessage("Password must be at least 8 characters."); // Require confirmation when password is provided RuleFor(x => x.ConfirmPassword) .Equal(x => x.NewPassword) .When(x => !string.IsNullOrEmpty(x.NewPassword)) .WithMessage("Passwords must match."); }}
Test validators using FluentValidation’s TestValidate() method:
CreateGroupCommandValidatorTests.cs
using FluentValidation.TestHelper;using FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup;using FSH.Modules.Identity.Features.v1.Groups.CreateGroup;using Xunit;namespace Identity.Tests.Validators;public sealed class CreateGroupCommandValidatorTests{ private readonly CreateGroupCommandValidator _validator; public CreateGroupCommandValidatorTests() { _validator = new CreateGroupCommandValidator(); } [Fact] public void Should_Have_Error_When_Name_Is_Empty() { // Arrange var command = new CreateGroupCommand("", "Description", false, null); // Act var result = _validator.TestValidate(command); // Assert result.ShouldHaveValidationErrorFor(x => x.Name) .WithErrorMessage("Group name is required."); } [Fact] public void Should_Have_Error_When_Name_Exceeds_MaxLength() { // Arrange var longName = new string('A', 257); var command = new CreateGroupCommand(longName, null, false, null); // Act var result = _validator.TestValidate(command); // Assert result.ShouldHaveValidationErrorFor(x => x.Name) .WithErrorMessage("Group name must not exceed 256 characters."); } [Fact] public void Should_Not_Have_Error_When_Command_Is_Valid() { // Arrange var command = new CreateGroupCommand("Admins", "Admin group", false, null); // Act var result = _validator.TestValidate(command); // Assert result.ShouldNotHaveAnyValidationErrors(); }}