FlowValidate 1.6.1
dotnet add package FlowValidate --version 1.6.1
NuGet\Install-Package FlowValidate -Version 1.6.1
<PackageReference Include="FlowValidate" Version="1.6.1" />
<PackageVersion Include="FlowValidate" Version="1.6.1" />
<PackageReference Include="FlowValidate" />
paket add FlowValidate --version 1.6.1
#r "nuget: FlowValidate, 1.6.1"
#:package FlowValidate@1.6.1
#addin nuget:?package=FlowValidate&version=1.6.1
#tool nuget:?package=FlowValidate&version=1.6.1
<p align="center"> <img src="https://raw.githubusercontent.com/kadirdemirkaya/FlowValidate/main/assets/icon.png" alt="FlowValidate logo" width="112" /> </p>
NuGet Package Information
| Package | Downloads | License |
|---|---|---|
Repository
You can find the source code and contribute on GitHub
FlowValidate
FlowValidate is a lightweight, fluent-style validation library for .NET.
It provides an intuitive API for validating models, making it easy to add and enforce rules while reducing boilerplate code.
Targets net6.0, net7.0, net8.0, net9.0 and net10.0.
Features
- Property Validation: Validate standard properties, nested objects, and collections.
- Nested & Collection Support: Automatically validates complex types and lists.
- Custom Rules: Use
Should,Must,IsNotEmpty,IsEqualor define your own logic. - Typed Ranges:
IsInRange,IsGreaterThanandIsLessThanwork ondecimal,double,long,DateTimeand any otherIComparable<T>type, including nullable ones. - Bounded Regular Expressions:
MatchesRegexaccepts a match timeout or a pre-builtRegex, so a costly pattern on untrusted input is reported as a rule failure instead of occupying the thread. - Multi-error per Rule: Single property rules can produce multiple error messages.
- Reusable & Property-specific Validators: Create modular validators like
UserNameValidatorand apply them to properties. - Async / Task-based Validation: Rules can run asynchronously (
MustAsync/ShouldAsync) with a synchronous validation fallback bridge. - DI Support: Easy integration with dependency injection.
- Clear Error Messages: Provides detailed validation feedback.
- Descriptive Built-in Failures: Opt in with
UseDescriptiveMessages()and every built-in rule reports the property and the bound it enforces, plus its own error code (NotEmpty,Length,InRange, …). - Detailed Error Messages: Provides rich validation feedback with property name, attempted value, and optional error code.
- Lightweight & Fast: Optimized for high performance.
- Middleware Ready: Can validate models automatically on each request with the separate
FlowValidate.AspNetCorepackage.
Installation
You can install FlowValidate via NuGet Package Manager
dotnet add package FlowValidate
To validate ASP.NET Core requests automatically, also install the middleware package:
dotnet add package FlowValidate.AspNetCore
FlowValidate contains the validation API only. Console, worker and other non-web apps need just this package.
Injection
using FlowValidate.AspNetCore;
using FlowValidate.Extensions;
var builder = WebApplication.CreateBuilder(args);
builder.Services.FlowValidationService(AssemblyReference.Assembly);
var app = builder.Build();
app.UseFlowValidation();
app.Run();
Calling FlowValidationService more than once with the same assembly is safe. The repeated call does not add duplicate validator or assembly registrations. Validators stay Scoped. Calls with different assemblies register all of them, but UseFlowValidation() only scans the most recently registered assembly for controllers on its own.
For a modular app whose controllers live in more than one assembly, also call FlowValidationAssemblies for the earlier ones so UseFlowValidation() scans all of them instead of only the last one registered:
builder.Services.FlowValidationService(typeof(Program).Assembly);
builder.Services.FlowValidationAssemblies(typeof(OrdersModule).Assembly, typeof(PaymentsModule).Assembly);
FlowValidationAssemblies is safe to call more than once and with more than one assembly per call: every assembly passed across all calls is merged, and passing the same assembly again does not scan it twice.
When a controller action's model fails validation, UseFlowValidation() responds with 400 Bad Request and a JSON body:
{"Errors":[{"PropertyName":"Name","ErrorMessage":"Name is required.","AttemptedValue":null,"ErrorCode":"NAME_REQUIRED","Severity":2}]}
Which action parameter is validated
UseFlowValidation() reads the request body for a single parameter of the matched action: the one marked [FromBody], or — when no parameter is marked — the single complex-typed parameter that has no binding source attribute. Parameters bound from somewhere else ([FromQuery], [FromRoute], [FromHeader], [FromForm], [FromServices]) and simple types such as int or string are never deserialized from the body, so a mixed signature like Create([FromBody] Order order, [FromQuery] OrderFilter filter) validates order only. If the action has no parameter that binds from the body, the request passes through untouched and the body is not read at all. The body is buffered and rewound, so the model binder and later middleware still read it normally.
Middleware limits
Requires endpoint routing to have run first. The middleware reads the matched endpoint's
ControllerActionDescriptor(HttpContext.GetEndpoint()) to find the action and its parameters, soapp.UseFlowValidation()must be registered afterapp.UseRouting(). If the request matched no endpoint, or the matched endpoint is not a controller action — a minimal API handler, a static file — the request passes through untouched and no validation happens. Because the action comes from the endpoint itself rather than from thecontroller/actionroute values, anAsync-suffixed method (SaveAsync, whose action name ASP.NET Core shortens toSave), an action renamed with[ActionName("Submit")], and two controllers that share a class name in different namespaces are each resolved correctly. That limit now only remains in the obsoleteFlowValidationApp(), which still matches the route value against the method name and silently skips such actions.No registered validator, no error. If no
IBaseValidator<T>is registered in DI for the resolved body-parameter type, the middleware also passes the request through untouched; it does not throw or report a missing validator.Body deserialization uses
Newtonsoft.Jsonby default, independent of whatever JSON stack the host application uses for model binding (System.Text.Jsonby default in ASP.NET Core). A type that binds correctly through the host's own JSON settings can still be deserialized differently by the middleware if the two serializers disagree, so a model that relies on[JsonPropertyName], a naming policy or a custom converter is validated against values model binding never produced. Opt in to the host's ownSystem.Text.Jsonsettings to close that gap:app.UseFlowValidation(options => options.UseSystemTextJson = true);The body is then deserialized with the
JsonSerializerOptionsthe host registered throughMicrosoft.AspNetCore.Mvc.JsonOptions(the ASP.NET Core web defaults when MVC is not registered), so the middleware validates exactly the model the action receives. The parameterlessapp.UseFlowValidation()keeps theNewtonsoft.Jsonbehavior, and the400 Bad Requestresponse body is identical either way.A body the middleware cannot turn into a model is not validated. Malformed JSON, an empty or whitespace-only body, a literal
nullbody, and a body whose values do not fit the model type all make the request pass through untouched, so model binding answers it the way the framework normally would — on an[ApiController]that is400 Bad Requestwith the framework'sapplication/problem+jsonbody, not FlowValidate'sErrorsbody. The middleware never converts an unreadable body into a server error, and it never reports a validation failure for a model it never built.
Migrating from FlowValidationApp()
app.FlowValidationApp() and ModelValidationMiddleware in the core package are obsolete. They keep working in this version and will be removed from the core package in the next major version. To migrate, install FlowValidate.AspNetCore and replace app.FlowValidationApp() with app.UseFlowValidation(). The error body is identical. One difference: the obsolete FlowValidationApp() answers invalid requests with 200 OK, while UseFlowValidation() answers them with 400 Bad Request. If a client checks for 200 together with the Errors body, update the client when you migrate. The obsolete core middleware also still throws on a body it cannot deserialize; only UseFlowValidation() passes such a request on to model binding.
For example, we create a uservalidator
Reusable Registry Rule
public class UserNameValidator : BaseValidator<string>
{
public UserNameValidator()
{
RuleFor(name => name).IsNotEmpty().WithMessage("Name cannot be empty.")
.Length(3, 100).WithMessage("Name must be at least 3 characters.");
}
}
Nested Validator
public class UserDetailsValidator : BaseValidator<UserDetails>
{
public UserDetailsValidator()
{
RuleFor(x => x.Email).IsEmail().WithMessage("Email is invalid.");
RuleFor(x => x.Phone).MatchesRegex(@"^\d{10}$").WithMessage("Phone must be 10 digits.");
}
}
Collection Validator
public class UserBasketValidator : BaseValidator<UserBasket>
{
public UserBasketValidator()
{
RuleFor(x => x.Name).IsNotEmpty();
RuleFor(x => x.Count).IsGreaterThan(0);
}
}
Main User Validator
public class UserValidator : BaseValidator<User>
{
public UserValidator()
{
// Registry rule
ValidateRegistryRules(u => u.Name, new UserNameValidator());
// Nested validator
ValidateNested(u => u.Details, new UserDetailsValidator());
// Collection validator
ValidateCollection(u => u.Baskets, new UserBasketValidator(), item => item);
// Custom validation with Should
RuleFor(u => u.Nickname)
.Should((nickname, addError) =>
{
if (!string.IsNullOrEmpty(nickname))
{
if (nickname.Length < 3)
addError("Nickname must be at least 3 characters long.");
if (nickname.Contains(" "))
addError("Nickname cannot contain spaces.");
}
});
}
}
Builder Types Returned by ValidateNested, ValidateCollection and ValidateRegistryRules
ValidateNested, ValidateCollection and ValidateRegistryRules return ValidationNestedBuilder<T, TProperty>, ValidationCollectionBuilder<T, TCollection, TElement> and ValidationRegistryRules<T, TProperty>. All three expose Task<ValidationResult> ValidateAsync(T instance); ValidationCollectionBuilder<T, TCollection, TElement> additionally exposes WithIndexedPropertyNames(string collectionName) (see below), and ValidationNestedBuilder<T, TProperty> / ValidationRegistryRules<T, TProperty> expose WithPropertyPrefix(string prefix) (see below). RuleFor already wires the returned builder into the parent validator's rule pipeline, so Validate(user) / ValidateAsync(user) on UserValidator runs it automatically — you do not need to call ValidateAsync on the child builder yourself. It is there mainly so a test can exercise one composition step in isolation, for example await new UserDetailsValidator().ValidateAsync(details) directly, or await new ValidationNestedBuilder<User, UserDetails>(u => u.Details, new UserDetailsValidator()).ValidateAsync(user).
Indexed Property Names for Collection Failures
By default a failure coming from a collection element carries the element validator's own property name (Name) and only the error message tells you which element failed ("Element 2: ..."). Chain WithIndexedPropertyNames("Baskets") on the builder returned by ValidateCollection to also get the index into ValidationFailure.PropertyName, so a client can map the failure to an element programmatically:
ValidateCollection(u => u.Baskets, new UserBasketValidator(), item => item)
.WithIndexedPropertyNames("Baskets");
PropertyName : Baskets[1].Name
ErrorMessage : Element 2: Name is required.
- The index in
PropertyNameis zero-based; the"Element n: "message prefix stays one-based and byte-for-byte unchanged, as does every other part of the failure (AttemptedValue,ErrorCode,Severity). - The option is opt-in per collection. Without the call, property names and messages stay exactly as they were.
- The collection name is passed explicitly because
ValidateCollectiontakes a delegate, not an expression, so the name cannot be inferred from the selector. An empty or whitespace name throwsArgumentException. - Paths compose: a collection inside a collection that both opt in yields
Orders[0].Lines[2].Qty, and a nested validator inside an opted-in collection yieldsOrders[1].Street. - The resulting names go straight into
ValidationResult.ToDictionary()keys and into the400 Bad Requestbody written byapp.UseFlowValidation().
Property Prefix for Nested and Registry Validator Failures
By default a failure coming from ValidateNested or ValidateRegistryRules carries only the child validator's own property name (City), so two nested objects on the same parent (Billing and Shipping) produce indistinguishable failures. Chain WithPropertyPrefix("Billing") on the builder returned by either method to prefix the property name instead:
ValidateNested(x => x.Billing, new AddressValidator())
.WithPropertyPrefix("Billing");
ValidateNested(x => x.Shipping, new AddressValidator())
.WithPropertyPrefix("Shipping");
PropertyName : Billing.City
ErrorMessage : City is required.
- The option is opt-in per nested/registry validator. Without the call, property names and messages stay exactly as they were.
- A failure with no property name (
"<root>") is reported as the prefix alone (Billing), notBilling.<root>. - Paths compose with
WithIndexedPropertyNames: a nested validator opted into a prefix inside an opted-in collection yieldsOrders[0].Billing.City. - The resulting names go straight into
ValidationResult.ToDictionary()keys and into the400 Bad Requestbody written byapp.UseFlowValidation().
null Handling
- A
nullcollection selected byValidateCollectionis skipped, the same wayValidateNestedalready skips anullnested object — neither reports a failure and neither throws. - A
nullvalue selected byValidateRegistryRulesis skipped, the same contract asValidateNested— no failure, no throw. - A
nullelement inside a collection validated byValidateCollectionreports a single failure for that element instead of throwing — message"Element n: cannot be null."(one-based, the same prefix every other element failure uses),ErrorCode: BuiltInRuleCodes.NullElement, andPropertyNameset toItems[i](zero-based) whenWithIndexedPropertyNamesis on, or"<root>"otherwise. Every other element in the collection is still validated normally. - A
nullroot instance passed toValidateorValidateAsyncthrowsArgumentNullException. Both methods behave the same way, sinceValidatecallsValidateAsyncinternally. - If a nested or registry-validated property must not be
nullat all, add that as its own rule:RuleFor(x => x.Child).IsNotEmpty()alongsideValidateNested(x => x.Child, new ChildValidator())— composition itself always treatsnullas "nothing to validate", never as a failure.
Conditional Rules with RequiredIf
RequiredIf(Func<TProperty, bool> condition) gates the rules chained after it behind a check on the property's own value. When condition(value) is false, the rule fails immediately with "Property is required." (errorCode: "Required") and every rule chained after RequiredIf on that property is skipped (ValidationResult.SetSkipRemainingRules(true)) — other properties' RuleFor chains are unaffected. When condition(value) is true, RequiredIf itself only fails if the value is null or, for string, blank; on success the remaining chained rules run as usual.
public class PromoValidator : BaseValidator<Promo>
{
public PromoValidator()
{
RuleFor(x => x.PromoCode)
.RequiredIf(code => code is not null && code.StartsWith("PROMO"))
.Length(8, 12)
.Contains("-");
}
}
PromoCode = "ABC"→ condition isfalse→ fails with"Property is required.";Length/Containsdo not run.PromoCode = "PROMO12"→ condition istrue→Length/Containsrun and report their own failures.PromoCode = "PROMO-123"→ condition istrueand the remaining rules pass → valid.
Conditional Chains with When and Unless
When(Func<T, bool> condition) and Unless(Func<T, bool> condition) gate a RuleFor chain behind a condition on the root instance, so the condition can look at another property. When the condition is not met the chain's rules are skipped and no failure is produced — the property is not even read.
public class CustomerValidator : BaseValidator<Customer>
{
public CustomerValidator()
{
RuleFor(x => x.VatNumber)
.IsNotEmpty()
.WithMessage("VAT number is required for companies.", "VAT_REQUIRED")
.When(x => x.IsCompany);
RuleFor(x => x.Name)
.IsNotEmpty()
.Unless(x => x.IsDraft);
}
}
IsCompany = false→ theVatNumberchain is skipped entirely,Failuresstays empty.IsCompany = true, VatNumber = null→ fails with"VAT number is required for companies.".IsDraft = true→ theNamechain is skipped;IsDraft = false→ it runs.
Scope: the condition guards the whole chain, not just the rule written before it — .IsNotEmpty().When(...) and .When(...).IsNotEmpty() are equivalent. Start a second RuleFor chain on the same property when it needs a different condition. Several When/Unless calls on one chain are combined with AND.
Async: MustAsync and ShouldAsync are gated the same way and are never awaited when the condition is not met. A skipped chain still counts towards HasAsyncRules, so Validate() keeps throwing for a validator that declares async rules — use ValidateAsync().
Composition: When/Unless work inside any validator, including the ones passed to ValidateNested and ValidateCollection; there the "root instance" is the nested object or the individual element being validated.
When/Unless vs RequiredIf
When / Unless |
RequiredIf |
|
|---|---|---|
| Condition argument | the root instance (T) — can read other properties |
the property's own value (TProperty) |
| Condition not met | chain is skipped silently, no failure | fails with "Property is required." (errorCode: "Required") and skips the remaining rules |
| Condition met | the chain's rules run | the value must be non-null (non-blank for string), then the remaining rules run |
| Scope | the whole RuleFor chain |
the rules chained after it |
Use When/Unless when a rule set only applies in some cases and its absence is not an error. Use RequiredIf when the property must be present and you want the failure. RequiredIf is unchanged and can be used inside a When-gated chain.
Severity
ValidationFailure.Severity is a FlowValidate.Enums.Severity value (Info, Warning, Error). Every failure produced by the built-in rules and RequiredIf defaults to Severity.Error. Chain WithSeverity(Severity) after a rule to change it, exactly the way WithMessage targets the most recently added rule:
RuleFor(x => x.BackupEmail)
.IsNotEmpty()
.WithMessage("Backup email is missing.", "BACKUP_EMAIL_MISSING")
.WithSeverity(Severity.Warning);
WithSeverityhas no effect when no rule has been added yet, and can be chained together withWithMessagein either order.- It has no effect on failures raised from inside a
Should/ShouldAsynccallback — those build their ownValidationFailuredirectly and keepSeverity.Error, the same wayWithMessagealready leaves their messages untouched. ValidationResult.IsValidsemantics are unchanged: aWarningorInfofailure still makes the result invalid, exactly likeErrordoes today.
To report a severity without a RuleFor rule at all, construct the failure yourself, either with ValidationResult.Failure(message, propertyName, attemptedValue, errorCode, severity) or new ValidationFailure(...), and merge it into the validator's result:
var result = validator.Validate(user);
result.Merge(ValidationResult.Failure(
"Backup email is missing.",
propertyName: "BackupEmail",
severity: Severity.Warning));
Severity is also part of the middleware's JSON error body (see the Errors[].Severity field above).
Grouping Failures by Property with ToDictionary
ValidationResult.ToDictionary() groups Failures by PropertyName into an IDictionary<string, string[]>, in insertion order — handy for building an ASP.NET ValidationProblemDetails-style error body without writing your own GroupBy:
var result = validator.Validate(user);
IDictionary<string, string[]> errors = result.ToDictionary();
// { "Email": ["must not be empty", "must be a valid email"], "Age": ["must be positive"] }
A successful result returns an empty dictionary. A null/empty PropertyName is grouped under "<root>", and failures of every Severity are included, not only Severity.Error.
Asynchronous Validation (Async Support)
FlowValidate fully supports asynchronous validation rules for operations that require external or asynchronous calls (e.g., database queries or external API requests). You can use MustAsync and ShouldAsync inside your validators.
Asynchronous Rules Example
public class UserValidator : BaseValidator<User>
{
private readonly IUserRepository _userRepository;
public UserValidator(IUserRepository userRepository)
{
_userRepository = userRepository;
// Using MustAsync for custom async boolean conditions
RuleFor(u => u.Email)
.MustAsync(async email => !await _userRepository.ExistsAsync(email))
.WithMessage("This email address is already in use.");
// Using ShouldAsync with a multi-error delegate callback
RuleFor(u => u.Username)
.ShouldAsync(async (username, addError) =>
{
var isBlacklisted = await _userRepository.IsBlacklistedAsync(username);
if (isBlacklisted)
{
addError("Username is blacklisted.");
}
});
// Using ShouldAsync with an action exception boundary
RuleFor(u => u.Bio)
.ShouldAsync(async bio =>
{
await _userRepository.ValidateBioFormatAsync(bio); // Throws if invalid
}, "Bio format is invalid.");
}
}
Executing Asynchronously vs Synchronously
var validator = new UserValidator(userRepository);
// 1. Asynchronous execution (Recommended when using async rules)
var resultAsync = await validator.ValidateAsync(user);
// 2. Synchronous execution bridge (Executes async rules synchronously under the hood)
var resultSync = validator.Validate(user);
Cancellation
ValidateAsync(instance, cancellationToken) runs the same rules and passes the token to every rule registered through a MustAsync or ShouldAsync overload that takes one, so a database or HTTP call made inside a rule is aborted when the caller gives up. The token is also checked between rules and between collection elements. Calling ValidateAsync(instance) without a token is exactly the same as passing CancellationToken.None, and the rule overloads without a token are unchanged.
public class UserValidator : BaseValidator<User>
{
public UserValidator(IUserRepository userRepository)
{
RuleFor(u => u.Email)
.MustAsync(async (email, cancellationToken) => !await userRepository.ExistsAsync(email, cancellationToken))
.WithMessage("This email address is already in use.");
RuleFor(u => u.Username)
.ShouldAsync(async (username, addError, cancellationToken) =>
{
if (await userRepository.IsBlacklistedAsync(username, cancellationToken))
{
addError("Username is blacklisted.");
}
});
RuleFor(u => u.Bio)
.ShouldAsync(async (bio, cancellationToken) =>
{
await userRepository.ValidateBioFormatAsync(bio, cancellationToken);
}, "Bio format is invalid.");
}
}
var result = await validator.ValidateAsync(user, cancellationToken);
A cancelled token makes ValidateAsync throw OperationCanceledException; cancellation is not reported as a validation failure. An aborted run has no verdict about the instance, so turning it into a failure would tell the caller the instance is invalid when it may well be valid. This is the one case where ShouldAsync does not convert an exception into a failure: an OperationCanceledException raised after your token is cancelled is rethrown, while every other exception — including one from a token the rule owns itself — is still recorded as a failure as before.
The errorMessage argument is required on ShouldAsync(async (value, cancellationToken) => ..., errorMessage); pass null for the default message. It is not optional so that an existing ShouldAsync(async (value, addError) => ...) call keeps compiling to the callback overload.
UseFlowValidation() passes HttpContext.RequestAborted, so validation for a request whose client has disconnected stops instead of running to completion. The obsolete FlowValidationApp() middleware is frozen and keeps validating without a token.
IBaseValidator<T>.ValidateAsync(T, CancellationToken) has a default implementation, so a type that implements the interface by hand keeps compiling; the default observes the token once and then delegates to ValidateAsync(T). Code that resolves the method by name through reflection must select it by signature — GetMethod("ValidateAsync", new[] { typeof(T) }) — because the name alone is now ambiguous.
Using the Validator
var user = new User
{
Name = "Jo",
Age = 25,
Details = new UserDetails { Email = "invalid-email", Phone = "12345" },
Baskets = new List<UserBasket>
{
new UserBasket { Name = "", Count = 0 },
new UserBasket { Name = "Apple", Count = 3 }
}
};
var validator = new UserValidator();
var result = validator.Validate(user);
Property Names in Failures
Every ValidationFailure.PropertyName is non-null. A plain member access reports the member name; any other expression reports its expression text.
| Rule | PropertyName |
|---|---|
RuleFor(x => x.Name) |
Name |
RuleFor(x => x.Details.Email) |
Email |
RuleFor(x => x.Tags[0]) (array) |
x.Tags[0] |
RuleFor(x => x.Baskets[0]) (list) |
x.Baskets.get_Item(0) |
RuleFor(x => x.Name.Trim()) |
x.Name.Trim() |
RuleFor(x => x) |
x |
WithMessage Placement
WithMessage(...) overrides the message and code of the rule that was just added, and must be chained
directly after that rule. Calling it before any rule has been added on the current RuleFor(...) chain
is a no-op: the call is silently ignored, and no rule is added or changed.
RuleFor(x => x.Name).WithMessage("This is ignored");
ValidationFailure.AttemptedValue on a WithMessage-annotated failure is always the real value that
was validated, the same as when WithMessage is omitted.
Descriptive Messages for Built-in Rules
Without WithMessage(...), a failing built-in rule reports "Validation failed for property." with the error code DefaultRule. Call UseDescriptiveMessages() in the validator to have every built-in rule on it report the property it guards, the bound it enforces, and its own error code instead:
public class UserValidator : BaseValidator<User>
{
public UserValidator()
{
UseDescriptiveMessages();
RuleFor(x => x.Name).Length(3, 100);
RuleFor(x => x.Email).IsEmail();
RuleFor(x => x.Age).IsInRange(18, 65);
}
}
Name must be between 3 and 100 characters. [Length]
Email must be a valid email address. [Email]
Age must be between 18 and 65. [InRange]
- Opt-in and additive. Without the call, every message and code is exactly what it was before. It can be written anywhere in the constructor — it is read when validation runs, so the rules registered before it are covered too.
WithMessagealways wins. Its message replaces the descriptive one, and its error code replaces the rule's code when one is passed:Length(3, 100).WithMessage("Name is too short.")reports that message with the codeLength.- Per chain, too.
RuleFor(x => x.Name).Length(3, 100).WithDescriptiveMessages()opts one chain in without touching the rest of the validator, andWithDescriptiveMessages(false)opts one chain back out of a validator-wideUseDescriptiveMessages(). - Not propagated. The setting belongs to the validator it is called on. Validators passed to
ValidateNested,ValidateCollectionandValidateRegistryRulesare independent instances that may be shared, so call it on each one whose messages should be descriptive. Collection failures keep their"Element n: "prefix:Element 1: Sku must be between 3 and 10 characters. - Custom rules are unaffected.
Must,MustAsync,ShouldandShouldAsynckeep their own messages and codes, andRequiredIfkeeps"Property is required."withRequired. - The codes are available as constants on
BuiltInRuleCodes, so a client can branch onfailure.ErrorCode == BuiltInRuleCodes.Lengthwithout a magic string.
| Rule | Error code | Message with descriptive failures on |
|---|---|---|
IsNotEmpty() |
NotEmpty |
Name must not be empty. |
IsEqual(value) |
Equal |
Name must be equal to 'value'. |
Contains(substring) |
Contains |
Name must contain 'substring'. |
Length(min, max) |
Length |
Name must be between 3 and 100 characters. |
IsEmail() |
Email |
Email must be a valid email address. |
MatchesRegex(...) (all overloads) |
RegexMatch |
Code must match the required pattern. |
IsInRange(min, max) (all overloads) |
InRange |
Age must be between 18 and 65. |
IsGreaterThan(min) (all overloads) |
GreaterThan |
Age must be greater than 18. |
IsLessThan(max) (all overloads) |
LessThan |
Age must be less than 65. |
IsDateInFuture() |
DateInFuture |
StartsAt must be a date in the future. |
IsInFuture() |
InFuture |
Renewal must be a date in the future. |
IsDateInPast() |
DateInPast |
EndedAt must be a date in the past. |
IsUnique() |
Unique |
Tags must contain unique items. |
NoConsecutiveRepeats(n) |
NoConsecutiveRepeats |
Password must not repeat the same character 3 or more times in a row. |
IsPalindrome() |
Palindrome |
Word must be a palindrome. |
RequiredIf(condition) |
Required |
Property is required. (unchanged either way) |
Must / MustAsync |
DefaultRule |
Validation failed for property. (unchanged either way) |
Should / ShouldAsync |
ShouldRule |
the message the callback raised (unchanged either way) |
A MatchesRegex match that exceeds its timeout keeps reporting RegexTimeout with its own message, descriptive failures on or off.
IsDateInFuture, IsDateInPast and IsInFuture support DateTime, DateTimeOffset and DateOnly (and their nullable forms). A DateTime with Kind == DateTimeKind.Utc is compared against DateTime.UtcNow; Local or Unspecified is compared against DateTime.Now. DateTimeOffset is always compared against DateTimeOffset.UtcNow, and DateOnly against today's local date. A null value, or any other property type, always fails.
Stopping a Chain at the First Failure
By default every rule on a RuleFor chain runs, even after an earlier one has already failed, so
RuleFor(x => x.Email).IsNotEmpty().IsEmail() against an empty Email reports two failures
(NotEmpty and Email). Call StopOnFirstFailure() anywhere on the chain to stop it after the first
failing rule instead:
public class UserValidator : BaseValidator<User>
{
public UserValidator()
{
RuleFor(x => x.Email).IsNotEmpty().IsEmail().StopOnFirstFailure();
}
}
With Email = "", this reports only Email must not be empty. — IsEmail() never runs.
- Opt-in and additive. Without the call every rule on the chain still runs, exactly as before.
- Applies to the whole chain, independent of placement.
StopOnFirstFailure()written first, last, or in the middle of the chain behaves identically — the same ruleWithDescriptiveMessagesfollows. - Validator-wide default.
UseStopOnFirstFailure()on the validator opts everyRuleForchain in at once; a single chain overrides it in either direction with its ownStopOnFirstFailure(bool). - Async rules after the failure are not awaited. Once a rule fails, the chain stops before reaching
the next
MustAsync/ShouldAsyncrule — it is skipped entirely, not merely ignored after running. Should/ShouldAsynccan still raise more than one error per rule. When such a rule fails, every error it raised through the callback is recorded before the chain stops; only the rules after it are skipped.RequiredIfis unaffected. It already stops the remaining rules on its own viaValidationResult.SkipRemainingRules, regardless ofStopOnFirstFailure.
Ranges and Comparisons for Any Ordered Type
IsInRange, IsGreaterThan and IsLessThan work on any property type that implements IComparable<T>: decimal, double, long, DateTime, DateOnly, string and their nullable forms. Pass bounds of the property's own type; a bound of another type does not compile.
public class ProductValidator : BaseValidator<Product>
{
public ProductValidator()
{
RuleFor(x => x.Price).IsInRange(0.01m, 999.99m);
RuleFor(x => x.Rating).IsGreaterThan(0.0).IsLessThan(5.5);
RuleFor(x => x.Views).IsLessThan(10_000_000_000L);
RuleFor(x => x.ReleasedAt).IsGreaterThan(new DateTime(2020, 1, 1));
RuleFor(x => x.Discount).IsInRange(0m, 0.5m);
}
}
IsInRangeincludes both bounds;IsGreaterThanandIsLessThanare strict.- A
nullvalue fails these rules. For an optional property that may benull, useMust, for exampleRuleFor(x => x.Discount).Must(d => d is null or (>= 0m and <= 0.5m)). - Integer bounds such as
IsInRange(1, 10)still bind to the originalintoverloads, which behave exactly as before and only acceptint-convertible values. On adecimal,doubleorlongproperty, write the bounds with the matching literal suffix (1m,1.0,1L). - The
intoverloads ofIsGreaterThanandIsLessThanround the value to anintbefore comparing, so on adecimalordoubleproperty they compare the rounded value, not the real one:IsGreaterThan(5)on5.4mfails andIsLessThan(6)on5.6mfails. This is why the matching literal suffix matters —IsGreaterThan(5m)andIsLessThan(6m)compare the value itself and both pass. - A value that cannot be converted to
intat all — alongoutside theintrange, a non-numericstring, aDateTime— now fails the rule and is reported as a normal validation failure. Earlier versions let the conversion exception escapeValidate/ValidateAsync.
Regular Expressions with a Match Timeout
MatchesRegex(pattern) matches without a time limit. On untrusted input — anything coming from a request body — use the timed overloads instead, so a pattern that backtracks catastrophically cannot occupy the thread while a single value is validated:
public class SignUpValidator : BaseValidator<SignUp>
{
private static readonly Regex UsernamePattern =
new Regex("^[a-z0-9_]{3,20}$", RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(100));
public SignUpValidator()
{
RuleFor(x => x.Phone).MatchesRegex(@"^\d{10}$", TimeSpan.FromMilliseconds(100));
RuleFor(x => x.Username).MatchesRegex(UsernamePattern);
}
}
MatchesRegex(string pattern, TimeSpan matchTimeout)compiles the pattern once, when the rule is added, and applies the timeout to every match attempt.MatchesRegex(Regex regex)takes a pre-built expression that carries its ownRegexOptionsand its ownMatchTimeout— useful for sharing onestatic readonly Regexacross validators.- A timed-out match does not throw: the property is reported invalid with the error code
RegexTimeoutand a message naming the timeout, so it can be told apart from a value that simply did not match.WithMessageoverrides the ordinary no-match failure; the timeout failure keeps its own message. - A
nullor non-string value fails exactly as it does withMatchesRegex(pattern). - The existing
MatchesRegex(pattern)overload is unchanged and still has no timeout.
For more examples and unit tests, check the FlowValidate.Test project in the repository.
Want to contribute? See CONTRIBUTING.md.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net6.0 is compatible. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 is compatible. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.AspNetCore.Http (>= 2.1.34)
- Microsoft.AspNetCore.Routing (>= 2.1.1)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.12)
- Newtonsoft.Json (>= 13.0.3)
-
net6.0
- Microsoft.AspNetCore.Http (>= 2.1.34)
- Microsoft.AspNetCore.Routing (>= 2.1.1)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.2)
- Newtonsoft.Json (>= 13.0.3)
- System.Text.Encodings.Web (>= 8.0.0)
-
net7.0
- Microsoft.AspNetCore.Http (>= 2.1.34)
- Microsoft.AspNetCore.Routing (>= 2.1.1)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.2)
- Newtonsoft.Json (>= 13.0.3)
- System.Text.Encodings.Web (>= 8.0.0)
-
net8.0
- Microsoft.AspNetCore.Http (>= 2.1.34)
- Microsoft.AspNetCore.Routing (>= 2.1.1)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.2)
- Newtonsoft.Json (>= 13.0.3)
-
net9.0
- Microsoft.AspNetCore.Http (>= 2.1.34)
- Microsoft.AspNetCore.Routing (>= 2.1.1)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.20)
- Newtonsoft.Json (>= 13.0.3)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on FlowValidate:
| Package | Downloads |
|---|---|
|
FlowValidate.AspNetCore
ASP.NET Core request validation middleware for FlowValidate. Validates controller action models with the registered FlowValidate validators and answers invalid requests with 400 Bad Request and the validation failures. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.6.1 | 126 | 9/27/2026 |
| 1.6.0 | 125 | 9/25/2026 |
| 1.5.0 | 118 | 9/25/2026 |
| 1.4.0 | 117 | 9/24/2026 |
| 1.3.0 | 126 | 9/23/2026 |
| 1.2.0 | 139 | 5/20/2026 |
| 1.1.6 | 255 | 9/23/2025 |
| 1.1.5 | 251 | 9/22/2025 |
| 1.1.4 | 249 | 9/22/2025 |
| 1.1.3 | 264 | 9/21/2025 |
| 1.1.2 | 263 | 5/18/2025 |
| 1.1.1 | 239 | 5/18/2025 |
| 1.1.0 | 264 | 4/24/2025 |
| 1.0.9 | 255 | 3/19/2025 |
| 1.0.8 | 246 | 3/19/2025 |
| 1.0.7 | 259 | 3/10/2025 |
| 1.0.6 | 260 | 3/10/2025 |
1.6.1: Maintenance release with no API or behaviour changes; contents are identical to 1.6.0.
1.6.0: Added WithPropertyPrefix(string) on ValidationNestedBuilder and ValidationRegistryRules for opt-in nested/registry failure prefixes, and StopOnFirstFailure()/UseStopOnFirstFailure() to opt a rule chain or validator out of running every rule after the first failure. Fixed IsDateInFuture/IsDateInPast/IsInFuture comparing a UTC DateTime against local time and added DateTimeOffset support. Fixed ValidateRegistryRules and ValidateCollection throwing ArgumentNullException for a null composed value or null element instead of skipping/reporting a failure.