| name | dotnet-fluent-validation |
| description | Generates FluentValidation validators for commands and queries. Includes common validation rules, custom validators, async validation, and integration with MediatR pipeline behaviors. |
| version | 1.0.0 |
| language | C# |
| framework | .NET 8+ |
| dependencies | FluentValidation, FluentValidation.DependencyInjectionExtensions |
FluentValidation Rules Generator
Overview
FluentValidation provides a fluent interface for building strongly-typed validation rules:
- Declarative rules - Readable, maintainable validation logic
- Separation of concerns - Validation separate from domain
- Integration with MediatR - Automatic validation via pipeline behavior
- Custom validators - Reusable validation components
Quick Reference
| Validator Type | Purpose | Example |
|---|
| Built-in | Common validations | NotEmpty(), MaximumLength() |
| Custom | Reusable rules | Must(BeValidEmail) |
| Async | Database checks | MustAsync(BeUniqueEmail) |
| Child | Nested objects | SetValidator(new AddressValidator()) |
| Collection | List items | RuleForEach(x => x.Items) |
Validator Structure
/Application/{Feature}/
├── Create{Entity}/
│ ├── Create{Entity}Command.cs
│ └── Create{Entity}CommandValidator.cs # Or inline in Command.cs
├── Update{Entity}/
│ └── Update{Entity}Command.cs # Validator inline
└── Validators/
├── EmailValidator.cs # Reusable validators
└── PhoneNumberValidator.cs
Template: Basic Command Validator
using FluentValidation;
namespace {name}.application.{feature}.Create{Entity};
public sealed class Create{Entity}CommandValidator : AbstractValidator<Create{Entity}Command>
{
public Create{Entity}CommandValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.WithMessage("{Entity} name is required")
.MaximumLength(100)
.WithMessage("{Entity} name must not exceed 100 characters")
.MinimumLength(2)
.WithMessage("{Entity} name must be at least 2 characters");
RuleFor(x => x.Description)
.MaximumLength(500)
.WithMessage("Description must not exceed 500 characters")
.When(x => !string.IsNullOrEmpty(x.Description));
RuleFor(x => x.OrganizationId)
.NotEmpty()
.WithMessage("Organization ID is required")
.NotEqual(Guid.Empty)
.WithMessage("Organization ID cannot be empty GUID");
RuleFor(x => x.ParentId)
.NotEqual(Guid.Empty)
.WithMessage("Parent ID cannot be empty GUID")
.When(x => x.ParentId.HasValue);
}
}
Template: Inline Validator (Preferred Pattern)
using FluentValidation;
using {name}.application.abstractions.messaging;
using {name}.domain.abstractions;
namespace {name}.application.{feature}.Create{Entity};
public sealed record Create{Entity}Command(
string Name,
string? Description,
Guid OrganizationId,
string Email,
decimal Amount,
List<CreateItemRequest> Items) : ICommand<Guid>;
public sealed class CreateItemRequest
{
public required string Name { get; init; }
public int Quantity { get; init; }
}
internal sealed class Create{Entity}CommandValidator : AbstractValidator<Create{Entity}Command>
{
public Create{Entity}CommandValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(100);
RuleFor(x => x.Email)
.NotEmpty()
.EmailAddress()
.WithMessage("A valid email address is required");
RuleFor(x => x.Amount)
.GreaterThan(0)
.WithMessage("Amount must be positive")
.LessThanOrEqualTo(1_000_000)
.WithMessage("Amount cannot exceed 1,000,000");
RuleFor(x => x.Items)
.NotEmpty()
.WithMessage("At least one item is required")
.Must(items => items.Count <= 100)
.WithMessage("Cannot have more than 100 items");
RuleForEach(x => x.Items)
.ChildRules(item =>
{
item.RuleFor(i => i.Name)
.NotEmpty()
.MaximumLength(200);
item.RuleFor(i => i.Quantity)
.GreaterThan(0)
.LessThanOrEqualTo(10000);
});
}
}
internal sealed class Create{Entity}CommandHandler
: ICommandHandler<Create{Entity}Command, Guid>
{
}
Template: Async Validator with Database Check
using FluentValidation;
using {name}.domain.{aggregate};
namespace {name}.application.{feature}.Create{Entity};
internal sealed class Create{Entity}CommandValidator : AbstractValidator<Create{Entity}Command>
{
private readonly I{Entity}Repository _{entity}Repository;
private readonly IOrganizationRepository _organizationRepository;
public Create{Entity}CommandValidator(
I{Entity}Repository {entity}Repository,
IOrganizationRepository organizationRepository)
{
_{entity}Repository = {entity}Repository;
_organizationRepository = organizationRepository;
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(100);
RuleFor(x => x.OrganizationId)
.NotEmpty();
RuleFor(x => x.Name)
.MustAsync(BeUniqueName)
.WithMessage("A {entity} with this name already exists");
RuleFor(x => x.OrganizationId)
.MustAsync(OrganizationExists)
.WithMessage("Organization does not exist");
}
private async Task<bool> BeUniqueName(string name, CancellationToken ct)
{
var existing = await _{entity}Repository.GetByNameAsync(name, ct);
return existing is null;
}
private async Task<bool> OrganizationExists(Guid organizationId, CancellationToken ct)
{
return await _organizationRepository.ExistsAsync(organizationId, ct);
}
}
Note: Prefer doing existence checks in the Handler rather than Validator for better separation of concerns and testability. Use async validation sparingly.
Template: Reusable Custom Validator
using FluentValidation;
using FluentValidation.Validators;
namespace {name}.application.validators;
public sealed class StrictEmailValidator<T> : PropertyValidator<T, string>
{
public override string Name => "StrictEmailValidator";
public override bool IsValid(ValidationContext<T> context, string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return true;
}
value = value.Trim().ToLowerInvariant();
if (value.Length > 254)
return false;
var atIndex = value.IndexOf('@');
if (atIndex <= 0)
return false;
var dotIndex = value.LastIndexOf('.');
if (dotIndex <= atIndex + 1)
return false;
if (dotIndex >= value.Length - 1)
return false;
if (value.Contains("..") || value.Contains(".@") || value.Contains("@."))
return false;
return true;
}
protected override string GetDefaultMessageTemplate(string errorCode)
=> "{PropertyName} must be a valid email address";
}
public static class ValidatorExtensions
{
public static IRuleBuilderOptions<T, string> StrictEmail<T>(
this IRuleBuilder<T, string> ruleBuilder)
{
return ruleBuilder.SetValidator(new StrictEmailValidator<T>());
}
}
Using Custom Validator
public sealed class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
public CreateUserCommandValidator()
{
RuleFor(x => x.Email)
.NotEmpty()
.StrictEmail();
}
}
Template: Nested Object Validator
using FluentValidation;
namespace {name}.application.validators;
public sealed class AddressValidator : AbstractValidator<AddressRequest>
{
public AddressValidator()
{
RuleFor(x => x.Street)
.NotEmpty()
.MaximumLength(200);
RuleFor(x => x.City)
.NotEmpty()
.MaximumLength(100);
RuleFor(x => x.State)
.NotEmpty()
.MaximumLength(50);
RuleFor(x => x.ZipCode)
.NotEmpty()
.Matches(@"^\d{5}(-\d{4})?$")
.WithMessage("Invalid ZIP code format");
RuleFor(x => x.Country)
.NotEmpty()
.MaximumLength(100);
}
}
public sealed class CreateCustomerCommandValidator : AbstractValidator<CreateCustomerCommand>
{
public CreateCustomerCommandValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(100);
RuleFor(x => x.BillingAddress)
.NotNull()
.WithMessage("Billing address is required")
.SetValidator(new AddressValidator());
RuleFor(x => x.ShippingAddress)
.SetValidator(new AddressValidator()!)
.When(x => x.ShippingAddress is not null);
}
}
Template: Collection Validator
using FluentValidation;
namespace {name}.application.orders.createorder;
public sealed class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(x => x.Items)
.NotEmpty()
.WithMessage("Order must contain at least one item")
.Must(items => items.Count <= 50)
.WithMessage("Order cannot contain more than 50 items");
RuleForEach(x => x.Items)
.ChildRules(item =>
{
item.RuleFor(i => i.ProductId)
.NotEmpty();
item.RuleFor(i => i.Quantity)
.GreaterThan(0)
.LessThanOrEqualTo(1000);
item.RuleFor(i => i.UnitPrice)
.GreaterThan(0);
});
RuleFor(x => x.Items)
.Must(HaveUniqueProducts)
.WithMessage("Order cannot contain duplicate products");
RuleFor(x => x.Items)
.Must(items => items.Sum(i => i.Quantity * i.UnitPrice) <= 100_000)
.WithMessage("Order total cannot exceed $100,000");
}
private bool HaveUniqueProducts(List<OrderItemRequest> items)
{
return items.Select(i => i.ProductId).Distinct().Count() == items.Count;
}
}
Template: Conditional Validation
using FluentValidation;
namespace {name}.application.payments.updatepayment;
public sealed class UpdatePaymentCommandValidator : AbstractValidator<UpdatePaymentCommand>
{
public UpdatePaymentCommandValidator()
{
RuleFor(x => x.PaymentMethod)
.NotEmpty()
.IsInEnum();
When(x => x.PaymentMethod == PaymentMethod.CreditCard, () =>
{
RuleFor(x => x.CardNumber)
.NotEmpty()
.WithMessage("Card number is required for credit card payments")
.CreditCard()
.WithMessage("Invalid credit card number");
RuleFor(x => x.ExpiryMonth)
.InclusiveBetween(1, 12);
RuleFor(x => x.ExpiryYear)
.GreaterThanOrEqualTo(DateTime.UtcNow.Year)
.WithMessage("Card has expired");
RuleFor(x => x.Cvv)
.NotEmpty()
.Matches(@"^\d{3,4}$")
.WithMessage("CVV must be 3 or 4 digits");
});
When(x => x.PaymentMethod == PaymentMethod.BankTransfer, () =>
{
RuleFor(x => x.AccountNumber)
.NotEmpty()
.WithMessage("Account number is required for bank transfers")
.Matches(@"^\d{8,17}$")
.WithMessage("Invalid account number format");
RuleFor(x => x.RoutingNumber)
.NotEmpty()
.Matches(@"^\d{9}$")
.WithMessage("Routing number must be 9 digits");
});
RuleFor(x => x.BillingAddress)
.NotNull()
.Unless(x => x.UseSavedAddress);
}
}
Built-in Validators Reference
String Validators
RuleFor(x => x.Name)
.NotEmpty()
.NotNull()
.Length(min, max)
.MinimumLength(5)
.MaximumLength(100)
.Matches(@"^[a-zA-Z]+$")
.EmailAddress()
.CreditCard()
.Equal("expected")
.NotEqual("forbidden");
Numeric Validators
RuleFor(x => x.Amount)
.GreaterThan(0)
.GreaterThanOrEqualTo(1)
.LessThan(100)
.LessThanOrEqualTo(99)
.InclusiveBetween(1, 100)
.ExclusiveBetween(0, 100)
.PrecisionScale(10, 2, true);
Collection Validators
RuleFor(x => x.Items)
.NotEmpty()
.NotNull()
.Must(x => x.Count <= 10)
.ForEach(item => item.NotNull());
Comparison Validators
RuleFor(x => x.EndDate)
.GreaterThan(x => x.StartDate)
.NotEqual(x => x.StartDate);
RuleFor(x => x.ConfirmPassword)
.Equal(x => x.Password)
.WithMessage("Passwords must match");
Enum Validators
RuleFor(x => x.Status)
.NotEmpty()
.IsInEnum()
.NotEqual(Status.Unknown);
Registering Validators
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
namespace {name}.application;
public static class DependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddValidatorsFromAssembly(
typeof(DependencyInjection).Assembly,
includeInternalTypes: true);
services.AddMediatR(configuration =>
{
configuration.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly);
configuration.AddOpenBehavior(typeof(ValidationBehavior<,>));
});
return services;
}
}
Error Messages Best Practices
public sealed class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
public CreateUserCommandValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.WithMessage("{PropertyName} is required")
.MaximumLength(100)
.WithMessage("{PropertyName} must not exceed {MaxLength} characters");
RuleFor(x => x.Email)
.NotEmpty()
.WithMessage("Email is required")
.WithErrorCode("USER_EMAIL_REQUIRED")
.EmailAddress()
.WithMessage("Invalid email format")
.WithErrorCode("USER_EMAIL_INVALID");
RuleFor(x => x.Age)
.GreaterThanOrEqualTo(18)
.WithState(x => new { MinAge = 18, ProvidedAge = x.Age });
}
}
Critical Rules
- Validators are internal - Not exposed outside Application layer
- Sync rules first - Fast validations before database calls
- Use async sparingly - Prefer checking in handler
- One validator per command - Keep validation focused
- Reuse with SetValidator - Extract common validators
- Clear error messages - User-friendly, actionable
- Error codes for APIs - Machine-readable codes
- Conditional validation - Use When/Unless appropriately
- Collection bounds - Always limit collection sizes
- Don't duplicate domain rules - Domain validates invariants
Anti-Patterns to Avoid
RuleFor(x => x.Amount)
.MustAsync(async (amount, ct) =>
{
var balance = await _accountService.GetBalance();
return balance >= amount;
});
RuleFor(x => x.Amount)
.GreaterThan(0)
.LessThanOrEqualTo(1_000_000);
RuleFor(x => x.Email)
.Must(email =>
{
x.Email = email.ToLower();
return true;
});
RuleFor(x => x.Email)
.EmailAddress();
RuleFor(x => x.Id)
.Must(id =>
{
try { return Guid.Parse(id) != Guid.Empty; }
catch { return false; }
});
public record Command(Guid Id);
RuleFor(x => x.Id).NotEmpty();
Related Skills
dotnet-cqrs-command-generator - Commands with validators
dotnet-pipeline-behaviors - ValidationBehavior integration
dotnet-result-pattern - ValidationResult type
api-controller-generator - Error responses