Validot is a performance-first, compact library for advanced model validation. Using a simple declarative fluent interface, it efficiently handles classes, structs, nested members, collections, nullables, plus any relation or combination of them. It also supports translations, custom logic extensions with tests, and DI containers.
🔥⚔️ Validot vs FluentValidation ⚔️🔥 📜 Blogged: Validot's performance explained 📜 Blogged: Crafting model specifications using Validot
Built with 🤘🏻by Bartosz Lenar
</br>
Brief update on the current state of the project:
Add the Validot nuget package to your project using dotnet CLI:
dotnet add package Validot
All the features are accessible after referencing single namespace:
using Validot;
And you're good to go! At first, create a specification for your model with the fluent api.
Specification<UserModel> specification = _ => _
.Member(m => m.Email, m => m
.Email()
.WithExtraCode("ERR_EMAIL")
.And()
.MaxLength(100)
)
.Member(m => m.Name, m => m
.Optional()
.And()
.LengthBetween(8, 100)
.And()
.Rule(name => name.All(char.IsLetterOrDigit))
.WithMessage("Must contain only letter or digits")
)
.And()
.Rule(m => m.Age >= 18 || m.Name != null)
.WithPath("Name")
.WithMessage("Required for underaged user")
.WithExtraCode("ERR_NAME");
The next step is to create a validator. As its name stands - it validates objects according to the specification. It's also thread-safe so you can seamlessly register it as a singleton in your DI container.
var validator = Validator.Factory.Create(specification);
Validate the object!
var model = new UserModel(email: "inv@lidv@lue", age: 14);
var result = validator.Validate(model);
The result object contains all information about the errors. Without retriggering the validation process, you can extract the desired form of an output.
result.AnyErrors; // bool flag:
// true
result.MessageMap["Email"] // collection of messages for "Email":
// [ "Must be a valid email address" ]
result.Codes; // collection of all the codes from the model:
// [ "ERR_EMAIL", "ERR_NAME" ]
result.ToString(); // compact printing of codes and messages:
// ERR_EMAIL, ERR_NAME
//
// Email: Must be a valid email address
// Name: Required for underaged user
No more obligatory if-ology around input models or separate classes wrapping just validation logic. Write specifications inline with simple, human-readable fluent API. Native support for properties and fields, structs and classes, nullables, collections, nested members, and possible combinations.
Specification<string> nameSpecification = s => s
.LengthBetween(5, 50)
.SingleLine()
.Rule(name => name.All(char.IsLetterOrDigit));
Specification<string> emailSpecification = s => s
.Email()
.And()
.Rule(email => email.All(char.IsLower))
.WithMessage("Must contain only lower case characters");
Specification<UserModel> userSpecification = s => s
.Member(m => m.Name, nameSpecification)
.WithMessage("Must comply with name rules")
.And()
.Member(m => m.PrimaryEmail, emailSpecification)
.And()
.Member(m => m.AlternativeEmails, m => m
.Optional()
.And()
.MaxCollectionSize(3)
.WithMessage("Must not contain more than 3 addresses")
.And()
.AsCollection(emailSpecification)
)
.And()
.Rule(user => {
return user.PrimaryEmail is null || user.AlternativeEmails?.Contains(user.PrimaryEmail) == false;
})
.WithMessage("Alternative emails must not contain the primary email address");
Compact, highly optimized, and thread-safe objects to handle the validation.
Specification<BookModel> bookSpecification = s => s
.Optional()
.Member(m => m.AuthorEmail, m => m.Optional().Email())
.Member(m => m.Title, m => m.NotEmpty().LengthBetween(1, 100))
.Member(m => m.Price, m => m.NonNegative());
var bookValidator = Validator.Factory.Create(bookSpecification);
services.AddSingleton<IValidator<BookModel>>(bookValidator);
var bookModel = new BookModel() { AuthorEmail = "inv@lid_em@il", Price = 10 };
bookValidator.IsValid(bookModel);
// false
bookValidator.Validate(bookModel).ToString();
// AuthorEmail: Must be a valid email address
// Title: Required
bookValidator.Validate(bookModel, failFast: true).ToString();
// AuthorEmail: Must be a valid email address
bookValidator.Template.ToString(); // Template contains all of the possible errors:
// AuthorEmail: Must be a valid email address
// Title: Required
// Title: Must not be empty
// Title: Must be between 1 and 100 characters in length
// Price: Must not be negative
Whatever you want. Error flag, compact list of codes, or detailed maps of messages and codes. With sugar on top: friendly ToString() printing that contains everything, nicely formatted.
var validationResult = validator.Validate(signUpModel);
if (validationResult.AnyErrors)
{
// check if a specific code has been recorded for Email property:
if (validationResult.CodeMap["Email"].Contains("DOMAIN_BANNED"))
{
_actions.NotifyAboutDomainBanned(signUpModel.Email);
}
var errorsPrinting = validationResult.ToString();
// save all messages and codes printing into the logs
_logger.LogError("Errors in incoming SignUpModel: {errors}", errorsPrinting);
// return all error codes to the frontend
return new SignUpActionResult
{
Success = false,
ErrorCodes = validationResult.Codes,
};
}
Tons of rules available out of the box. Plus, an easy way to define your own with the full support of Validot internal features like formattable message arguments.
public static IRuleOut<string> ExactLinesCount(this IRuleIn<string> @this, int count)
{
return @this.RuleTemplate(
value => value.Split(Environment.NewLine).Length == count,
"Must contain exactly {count} lines",
Arg.Number("count", count)
);
}
.ExactLinesCount(4)
// Must contain exactly 4 lines
.ExactLinesCount(4).WithMessage("Required lines count: {count}")
// Required lines count: 4
.ExactLinesCount(4).WithMessage("Required lines count: {count|format=000.00|culture=pl-PL}")
// Required lines count: 004,00
Pass errors directly to the end-users in the language of your application.
Specification<UserModel> specification = s => s
.Member(m => m.PrimaryEmail, m => m.Email())
.Member(m => m.Name, m => m.LengthBetween(3, 50));
var validator = Validator.Factory.Create(specification, settings => settings.WithPolishTranslation());
var model = new UserModel() { PrimaryEmail = "in@lid_em@il", Name = "X" };
var result = validator.Validate(model);
result.ToString();
// Email: Must be a valid email address
// Name: Must be between 3 and 50 characters in length
result.ToString(translationName: "Polish");
// Email: Musi być poprawnym adresem email
// Name: Musi być długości pomiędzy 3 a 50 znaków
At the moment Validot delivers the following translations out of the box: Polish, Spanish, Russian, Portuguese and German.
Although Validot doesn't contain direct support for the dependency injection containers (because it aims to rely solely on the .NET Standard 2.0), it includes helpers that can be used with any DI/IoC system.
For example, if you're working with ASP.NET Core and looking for an easy way to register all of your validators with a single call (something like services.AddValidators()
), wrap your specifications in the specification holders, and use the following snippet:
public void ConfigureServices(IServiceCollection services)
{
// ... registering other dependencies ...
// Registering Validot's validators from the current domain's loaded assemblies
var holderAssemblies = AppDomain.CurrentDomain.GetAssemblies();
var holders = Validator.Factory.FetchHolders(holderAssemblies)
.GroupBy(h => h.SpecifiedType)
.Select(s => new
{
ValidatorType = s.First().ValidatorType,
ValidatorInstance = s.First().CreateValidator()
});
foreach (var holder in holders)
{
services.AddSingleton(holder.ValidatorType, holder.ValidatorInstance);
}
// ... registering other dependencies ...
}
AddValidators
extension step-by-stepA short statement to start with - @JeremySkinner's FluentValidation is an excellent piece of work and has been a huge inspiration for this project. True, you can call Validot a direct competitor, but it differs in some fundamental decisions, and lot of attention has been focused on entirely different aspects. If - after reading this section - you think you can bear another approach, api and limitations, at least give Validot a try. You might be positively surprised. Otherwise, FluentValidation is a good, safe choice, as Validot is certainly less hackable, and achieving some particular goals might be either difficult or impossible.
This document shows oversimplified results of BenchmarkDotNet execution, but the intention is to present the general trend only. To have truly reliable numbers, I highly encourage you to run the benchmarks yourself.
There are three data sets, 10k models each; ManyErrors
(every model has many errors), HalfErrors
(circa 60% have errors, the rest are valid), NoErrors
(all are valid) and the rules reflect each other as much as technically possible. I did my best to make sure that the tests are just and adequate, but I'm a human being and I make mistakes. Really, if you spot errors in the code, framework usage, applied methodology... or if you can provide any counterexample proving that Validot struggles with some particular scenarios - I'd be very very very happy to accept a PR and/or discuss it on GitHub Issues.
To the point; the statement in the header is true, but it doesn't come for free. Wherever possible and justified, Validot chooses performance and less allocations over flexibility and extra features. Fine with that kind of trade-off? Good, because the validation process in Validot might be ~1.6x faster while consuming ~4.7x less memory (in the most representational, Validate
tests using HalfErrors
data set). Especially when it comes to memory consumption, Validot could be even 13.3x better than FluentValidation (IsValid
tests with HalfErrors
data set) . What's the secret? Read my blog post: Validot's performance explained.
Test | Data set | Library | Mean [ms] | Allocated [MB] |
---|---|---|---|---|
Validate | ManyErrors |
FluentValidation | 703.83 |
453 |
Validate | ManyErrors |
Validot | 307.04 |
173 |
FailFast | ManyErrors |
FluentValidation | 21.63 |
21 |
FailFast | ManyErrors |
Validot | 16.76 |
32 |
Validate | HalfErrors |
FluentValidation | 563.92 |
362 |
Validate | HalfErrors |
Validot | 271.62 |
81 |
FailFast | HalfErrors |
FluentValidation | 374.90 |
249 |
FailFast | HalfErrors |
Validot | 173.41 |
62 |
Validate | NoErrors |
FluentValidation | 559.77 |
354 |
Validate | NoErrors |
Validot | 260.99 |
75 |
FluentValidation's IsValid
is a property that wraps a simple check whether the validation result contains errors or not. Validot has AnyErrors that acts the same way, and IsValid is a special mode that doesn't care about anything else but the first rule predicate that fails. If the mission is only to verify the incoming model whether it complies with the rules (discarding all of the details), this approach proves to be better up to one order of magnitude:
Test | Data set | Library | Mean [ms] | Allocated [MB] |
---|---|---|---|---|
IsValid | ManyErrors |
FluentValidation | 20.91 |
21 |
IsValid | ManyErrors |
Validot | 8.21 |
6 |
IsValid | HalfErrors |
FluentValidation | 367.59 |
249 |
IsValid | HalfErrors |
Validot | 106.77 |
20 |
IsValid | NoErrors |
FluentValidation | 513.12 |
354 |
IsValid | NoErrors |
Validot | 136.22 |
24 |
Combining these two methods in most cases could be quite beneficial. At first, IsValid quickly verifies the object, and if it contains errors - only then Validate is executed to report the details. Of course in some extreme cases (megabyte-size data? millions of items in the collection? dozens of nested levels with loops in reference graphs?) traversing through the object twice could neglect the profit. Still, for the regular web api input validation, it will undoubtedly serve its purpose:
if (!validator.IsValid(model))
{
errorMessages = validator.Validate(model).ToString();
}
Test | Data set | Library | Mean [ms] | Allocated [MB] |
---|---|---|---|---|
Reporting | ManyErrors |
FluentValidation | 768.00 |
464 |
Reporting | ManyErrors |
Validot | 379.50 |
294 |
Reporting | HalfErrors |
FluentValidation | 592.50 |
363 |
Reporting | HalfErrors |
Validot | 294.60 |
76 |
ToString()
is called if errors are detected.ToString()
.Benchmarks environment: Validot 2.3.0, FluentValidation 11.2.0, .NET 6.0.7, i7-9750H (2.60GHz, 1 CPU, 12 logical and 6 physical cores), X64 RyuJIT, macOS Monterey.
In Validot, null is a special case handled by the core engine. You don't need to secure the validation logic from null as your predicate will never receive it.
Member(m => m.LastName, m => m
.Rule(lastName => lastName.Length < 50) // 'lastName' is never null
.Rule(lastName => lastName.All(char.IsLetter)) // 'lastName' is never null
)
All values are marked as required by default. In the above example, if LastName
member were null, the validation process would exit LastName
scope immediately only with this single error message:
LastName: Required
The content of the message is, of course, customizable.
If null should be allowed, place Optional command at the beginning:
Member(m => m.LastName, m => m
.Optional()
.Rule(lastName => lastName.Length < 50) // 'lastName' is never null
.Rule(lastName => lastName.All(char.IsLetter)) // 'lastName' is never null
)
Again, no rule predicate is triggered. Also, null LastName
member doesn't result with errors.
Once validator instance is created, you can't modify its internal state or settings. If you need the process to fail fast (FluentValidation's CascadeMode.Stop
), use the flag:
validator.Validate(model, failFast: true);
Features that might be in the scope and are technically possible to implement in the future:
Features that are very unlikely to be in the scope as they contradict the project's principles, and/or would have a very negative impact on performance, and/or are impossible to implement:
await
/async
support
Validot is a dotnet class library targeting .NET Standard 2.0. There are no extra dependencies.
Please check the official Microsoft document that lists all the platforms that can use it on.
Semantic versioning is being used very strictly. The major version is updated only when there is a breaking change, no matter how small it might be (e.g., adding extra method to the public interface). On the other hand, a huge pack of new features will bump the minor version only.
Before every major version update, at least one preview version is published.
Unit tests coverage hits 100% very close, and it can be detaily verified on codecov.io.
Before publishing, each release is tested on the "latest" version of the following operating systems:
using the upcoming, the current and all also the supported LTS versions of the underlying frameworks:
Benchmarks exist in the form of the console app project based on BenchmarkDotNet. Also, you can trigger performance tests from the build script.
The documentation is hosted alongside the source code, in the git repository, as a single markdown file: DOCUMENTATION.md.
Code examples from the documentation live as functional tests.
The entire project (source code, issue tracker, documentation, and CI workflows) is hosted here on github.com.
Any contribution is more than welcome. If you'd like to help, please don't forget to check out the CONTRIBUTING file and issues page.
Validot uses the MIT license. Long story short; you are more than welcome to use it anywhere you like, completely free of charge and without oppressive obligations.