| name | csharpessentials-validation |
| description | Use when writing model validation with Result<T> integration — Validator<T> base class, rules.For().NotEmpty()/.MaxLength()/.GreaterThan() chains, SetValidator for nested objects, ForEach for collections, native C# if/switch for conditional rules. Also use when migrating FROM FluentValidation — this skill contains a full side-by-side migration guide. |
CSharpEssentials.Validation
High-performance, model-first validation. Zero reflection at runtime — no expression trees, no deferred builds. Returns Result<T> natively.
Migrating from FluentValidation? Jump to Migration Guide at the bottom.
Installation
dotnet add package CSharpEssentials.Validation
Namespace
using CSharpEssentials.Validation;
using CSharpEssentials.Validation.Validators;
Defining a Validator
Extend Validator<T> and override Configure. The model instance is available directly — no expression trees, no lambdas over x =>.
public class CreateUserCommandValidator : Validator<CreateUserCommand>
{
protected override ValueTask Configure(CreateUserCommand model, RuleContext<CreateUserCommand> rules, CancellationToken ct = default)
{
rules.For(() => model.Email).NotEmpty().EmailAddress();
rules.For(() => model.Name).NotEmpty().MaxLength(100);
rules.For(() => model.Age).GreaterThan(0).LessThan(120);
return ValueTask.CompletedTask;
}
}
Invoke directly or via DI:
var validator = new CreateUserCommandValidator();
Result<CreateUserCommand> result = await validator.ValidateAsync(command);
if (result.IsFailure)
foreach (var error in result.Errors)
Console.WriteLine($"{error.Code}: {error.Description}");
Inline (Static) Usage
For one-off validations without a dedicated class, use the static Validator.ValidateAsync:
Result<CreateUserCommand> result = await Validator.ValidateAsync(command, (m, rules) =>
{
rules.For(() => m.Email).NotEmpty().EmailAddress();
rules.For(() => m.Name).NotEmpty().MaxLength(100);
});
Result<CreateUserCommand> result = await Validator.ValidateAsync(command, async (m, rules, ct) =>
{
rules.For(() => m.Name).NotEmpty();
await rules.For(() => m.Email)
.MustAsync(async (email, c) => await _db.IsUniqueAsync(email, c),
"Email.NotUnique", "Email is already taken.", c);
}, cancellationToken);
Validator.ValidateAsync (static utility class) and Validator<T> (abstract base class) are two separate types defined in the same file. They are fully independent — the static form does not delegate to Validator<T> internally.
String Validators
rules.For(() => model.Name)
.NotEmpty()
.NotNull()
.MinLength(2)
.MaxLength(100)
.Length(2, 100)
.EmailAddress()
.Matches(@"^\d+$")
.Contains("@")
.StartsWith("Mr")
.EndsWith(".com");
Null behaviour: NotEmpty / NotNull fail on null. All other string validators skip null (no error).
Comparable Validators (int, decimal, DateTime, …)
rules.For(() => model.Age)
.GreaterThan(0)
.GreaterThanOrEqualTo(18)
.LessThan(150)
.LessThanOrEqualTo(120)
.InclusiveBetween(18, 65)
.ExclusiveBetween(0, 100)
.Equal(42)
.NotEqual(0);
Collection Validators
Works with any nullable collection type: List<T>?, IEnumerable<T>?, IList<T>?, IReadOnlyList<T>?, T[]?, and any type implementing IEnumerable.
rules.For(() => model.Tags)
.NotEmpty()
.NotNull()
.MinCount(1)
.MaxCount(10)
.CountBetween(1, 10);
Nullable Struct Validators (int?, DateTime?, …)
All comparable validators work on nullable value types — null is silently skipped.
rules.For(() => model.ExpiresAt).GreaterThan(DateTime.UtcNow);
CascadeMode
Default (Stop): first failure stops the chain. Switch to Continue to collect all errors for a property.
rules.For(() => model.Password)
.Cascade(CascadeMode.Continue)
.MinLength(8)
.Matches(@"[A-Z]", message: "Must contain an uppercase letter.")
.Matches(@"[0-9]", message: "Must contain a digit.");
Custom Predicates
rules.For(() => model.Username)
.Must(name => name != "admin", "Username.Reserved", "The name 'admin' is reserved.");
await rules.For(() => model.Email)
.MustAsync(async (email, ct) => await _db.IsUniqueAsync(email, ct),
"Email.NotUnique", "Email is already taken.");
Nested Object Validation
Works with both non-nullable and nullable reference type properties — no null-forgiving operator needed.
SetValidatorAsync skips null values automatically.
await rules.For(() => model.Address).SetValidatorAsync(new AddressValidator(), ct);
await rules.For(() => model.BillingAddress).SetValidatorAsync(new AddressValidator(), ct);
Collection Item Validation
rules.ForEach(() => model.Tags, (tag, tagRules) =>
tagRules.For(() => tag).NotEmpty().MaxLength(50));
Async variant:
await rules.ForEachAsync(() => model.Items, async (item, itemRules, ct) =>
{
itemRules.For(() => item.Sku).NotEmpty();
await itemRules.For(() => item.Sku)
.MustAsync(async (sku, c) => await _db.SkuExistsAsync(sku, c), "Sku.NotFound", "SKU not found.");
}, ct);
Native C# Conditional Rules
Configure receives the live model — any C# control flow works directly. No When()/Unless() DSL needed.
protected override ValueTask Configure(Order model, RuleContext<Order> rules, CancellationToken ct = default)
{
rules.For(() => model.CustomerId).NotEmpty();
if (model.OrderType == OrderType.Business)
{
rules.For(() => model.CompanyName).NotEmpty().MaxLength(200);
rules.For(() => model.TaxId).Matches(@"^\d{10}$");
}
else
{
rules.For(() => model.FirstName).NotEmpty().MaxLength(100);
}
switch (model.Country)
{
case "TR": rules.For(() => model.NationalId).MinLength(11); break;
case "US": rules.For(() => model.SSN).Matches(@"^\d{3}-\d{2}-\d{4}$"); break;
}
if (!model.AcceptsTerms) return ValueTask.CompletedTask;
rules.For(() => model.Signature).NotEmpty();
return ValueTask.CompletedTask;
}
CSharpEssentials.Core Integration
CSharpEssentials.Core is transitively available (via CSharpEssentials.Errors). Its conditional helpers compose naturally inside Configure.
model.Coupon.IfNotNull(coupon =>
rules.For(() => coupon.Code).NotEmpty().Matches(@"^COUP-\d{6}$"));
model.IsInternational.IfTrue(() =>
rules.For(() => model.PassportNumber).NotEmpty());
if (model.PromoCode.IsNotEmpty())
rules.For(() => model.PromoCode).Matches(@"^PROMO-\d{4}$");
rules.ForEach(
() => model.Items.WhereIf(model.OnlyActiveItems, i => i.IsActive),
(item, itemRules) => itemRules.For(() => item.Sku).NotEmpty());
rules.ForEach(
() => model.Tags.WithoutNulls(),
(tag, tagRules) => tagRules.For(() => tag).NotEmpty().MaxLength(50));
Validator Composition (Include)
Use rules.Include() to merge another validator's rules in-line. All errors are merged into the same Result<T>.
public class BaseOrderValidator : Validator<Order>
{
protected override ValueTask Configure(Order model, RuleContext<Order> rules, CancellationToken ct = default)
{
rules.For(() => model.CustomerId).NotEmpty();
rules.For(() => model.Items).NotEmpty();
return ValueTask.CompletedTask;
}
}
public class PaidOrderValidator : Validator<Order>
{
protected override async ValueTask Configure(Order model, RuleContext<Order> rules, CancellationToken ct = default)
{
await Include(new BaseOrderValidator(), model, rules, ct);
rules.For(() => model.PaymentReference).NotEmpty();
}
}
DI Registration
services.AddValidator<CreateUserCommand, CreateUserCommandValidator>();
services.AddValidatorsFromAssembly(typeof(CreateUserCommandValidator).Assembly);
services.AddValidatorsFromAssemblies([
typeof(CreateUserCommandValidator).Assembly,
typeof(UpdateProductValidator).Assembly
]);
Default lifetime: Scoped. Override with the lifetime parameter.
Multiple IValidator<T> registrations for the same T are supported — ValidationBehavior aggregates and deduplicates results from all of them automatically.
Validator Ordering
Override Order on Validator<T> to control execution sequence when multiple validators are registered for the same model.
public class FormatValidator : Validator<CreateUserCommand>
{
public override int Order => 0;
protected override ValueTask Configure(CreateUserCommand model, RuleContext<CreateUserCommand> rules, CancellationToken ct = default)
{
rules.For(() => model.Email).NotEmpty().EmailAddress();
return ValueTask.CompletedTask;
}
}
public class BusinessRulesValidator : Validator<CreateUserCommand>
{
public override int Order => 1;
protected override ValueTask Configure(CreateUserCommand model, RuleContext<CreateUserCommand> rules, CancellationToken ct = default)
{
rules.For(() => model.Email).Must(email => !_blocklist.Contains(email), "Email.Blocked", "Email is blocked.");
return ValueTask.CompletedTask;
}
}
Rules:
- Validators sharing the same
Order run concurrently within their group.
- Groups with lower
Order run sequentially before groups with higher Order.
- All groups execute regardless of earlier failures — errors from all groups are accumulated and deduplicated.
- Default
Order is 0 — validators with no override all run concurrently in a single group.
Mediator Pipeline Integration
services.AddMediatorValidationBehavior();
services.AddMediatorBehaviors();
Validation runs before the handler. On failure, the handler is never invoked. Errors are surfaced based on the handler return type: Result and Result<T> handlers receive Result.Failure directly; handlers with any other return type cause EnhancedValidationException to be thrown (caught by GlobalExceptionHandler). See csharpessentials-mediator skill for full pipeline docs.
Exception isolation: If a validator throws a non-OperationCanceledException exception, ValidationBehavior catches it and converts it to Error.Exception("Validator.Exception", ex) — the pipeline never rethrows validator bugs. OperationCanceledException always propagates so cancellation is respected.
Best Practices
- Chain guard validators first (
NotEmpty / NotNull) — stop mode prevents subsequent validators from running on null/empty values.
- Use native
if / switch for conditional rules — no DSL required.
- Use
SetValidator for nested objects; use ForEach for collections.
- Validators with no scoped dependencies may be registered as
Singleton. Validators that inject scoped services (e.g. DbContext, ICurrentUser) must be registered as Scoped. ValidationBehavior is always Scoped — it adapts to the lifetime of injected validators.
- Use
CascadeMode.Continue only when the client needs all errors for a field simultaneously (e.g. password strength rules).
FluentValidation Migration Guide
1 — Swap the Package
dotnet remove package FluentValidation
dotnet remove package FluentValidation.DependencyInjectionExtensions
dotnet add package CSharpEssentials.Validation
2 — Validator Class
| FluentValidation | CSharpEssentials.Validation |
|---|
AbstractValidator<T> | Validator<T> |
Constructor + RuleFor(x => x.Name) | Configure(T model, RuleContext<T> rules) override |
IValidator<T> | IValidator<T> (same interface name, different namespace) |
public class CreateUserValidator : AbstractValidator<CreateUserCommand>
{
public CreateUserValidator()
{
RuleFor(x => x.Email).NotEmpty().EmailAddress();
RuleFor(x => x.Name).NotEmpty().MaxLength(100);
RuleFor(x => x.Age).GreaterThan(0);
}
}
public class CreateUserValidator : Validator<CreateUserCommand>
{
protected override ValueTask Configure(CreateUserCommand model, RuleContext<CreateUserCommand> rules, CancellationToken ct = default)
{
rules.For(() => model.Email).NotEmpty().EmailAddress();
rules.For(() => model.Name).NotEmpty().MaxLength(100);
rules.For(() => model.Age).GreaterThan(0);
return ValueTask.CompletedTask;
}
}
3 — Conditional Rules
When(x => x.IsInternational, () =>
{
RuleFor(x => x.PassportNumber).NotEmpty();
RuleFor(x => x.VisaType).NotEmpty();
});
if (model.IsInternational)
{
rules.For(() => model.PassportNumber).NotEmpty();
rules.For(() => model.VisaType).NotEmpty();
}
RuleFor(x => x.DriversLicense).NotEmpty().When(x => x.Age >= 18);
if (model.Age >= 18)
rules.For(() => model.DriversLicense).NotEmpty();
4 — Nested Object Validation
RuleFor(x => x.Address).SetValidator(new AddressValidator());
await rules.For(() => model.Address).SetValidatorAsync(new AddressValidator(), ct);
5 — Collection Item Validation
RuleForEach(x => x.Tags).NotEmpty().MaximumLength(50);
rules.ForEach(() => model.Tags, (tag, tagRules) =>
tagRules.For(() => tag).NotEmpty().MaxLength(50));
6 — Validator Composition (Include)
Include(new BaseValidator());
await Include(new BaseValidator(), model, rules, ct);
7 — DI Registration
services.AddValidatorsFromAssemblyContaining<CreateUserValidator>();
services.AddValidatorsFromAssembly(typeof(CreateUserValidator).Assembly);
8 — Reading Validation Errors
ValidationResult result = validator.Validate(command);
if (!result.IsValid)
foreach (var failure in result.Errors)
Console.WriteLine($"{failure.PropertyName}: {failure.ErrorMessage}");
Result<CreateUserCommand> result = await validator.ValidateAsync(command);
if (result.IsFailure)
foreach (var error in result.Errors)
Console.WriteLine($"{error.Code}: {error.Description}");
9 — Async Validation
ValidationResult result = await validator.ValidateAsync(command, ct);
Result<CreateUserCommand> result = await validator.ValidateAsync(command, ct);
10 — CascadeMode
RuleFor(x => x.Password)
.Cascade(CascadeMode.Continue)
.MinimumLength(8)
.Matches("[A-Z]");
rules.For(() => model.Password)
.Cascade(CascadeMode.Continue)
.MinLength(8)
.Matches(@"[A-Z]");
11 — Validator Name Changes
| FluentValidation | CSharpEssentials.Validation | Notes |
|---|
NotEmpty() | NotEmpty() | identical |
NotNull() | NotNull() | identical |
MinimumLength(n) | MinLength(n) | renamed |
MaximumLength(n) | MaxLength(n) | renamed |
Length(min, max) | Length(min, max) | identical |
EmailAddress() | EmailAddress() | identical |
Matches(pattern) | Matches(pattern) | identical |
GreaterThan(n) | GreaterThan(n) | identical |
LessThan(n) | LessThan(n) | identical |
InclusiveBetween(a,b) | InclusiveBetween(a,b) | identical |
ExclusiveBetween(a,b) | ExclusiveBetween(a,b) | identical |
Equal(v) | Equal(v) | identical |
NotEqual(v) | NotEqual(v) | identical |
Must(predicate) | Must(predicate, code, msg) | requires explicit error code |
MustAsync(predicate) | MustAsync(predicate, code, msg) | requires explicit error code |
SetValidator(v) | await SetValidatorAsync(v, ct) | async; inside async Configure |
RuleForEach(…) | rules.ForEach(…) | different shape — see §5 |
.When(condition) | native if (condition) | no DSL needed |
.WithMessage(msg) | NotEmpty(message: "…") | pass message as parameter |
Common Pitfalls During Migration
When() blocks: Delete them and replace with plain if. The model is available directly.
.WithMessage(): Pass the custom message as the last parameter of the validator method: .NotEmpty(message: "Custom message").
.WithErrorCode(): Error codes in CSharpEssentials are auto-generated as "PropertyName.ValidatorName" (e.g. "Email.NotEmpty"). There is no .WithErrorCode() — use .Must(pred, "MyCode", "My message") for custom codes.
IRuleBuilder extensions: Custom validators written as IRuleBuilderOptions<T, TProperty> extensions must be rewritten as RuleChain<T, TProp> extensions.
ValidationContext: No equivalent — Configure receives the model directly. Contextual data should be passed via constructor injection on the validator.
RuleSet: No built-in equivalent. Use validator composition (Include) or separate validator classes per scenario.
- No sync
Validate(): Use ValidateAsync().GetAwaiter().GetResult() at sync call sites. This is safe when the validator has no actual async operations (returns a pre-completed ValueTask).
Configure returns ValueTask: Sync-only validators must return ValueTask.CompletedTask;. Async validators use async ValueTask and can await freely.
SetValidator removed: Use await SetValidatorAsync(validator, ct) inside an async ValueTask Configure(...).
Include must be awaited: await Include(new BaseValidator(), model, rules, ct) inside async Configure.