Validot
Tiny lib for advanced model validation. With performance in mind.
🔥⚔️ Validot vs FluentValidation ⚔️🔥
Built with 🤘🏻by Bartosz Lenar
Quickstart
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")
.MaxLength(100)
)
.Member(m => m.Name, m => m
.Optional()
.LengthBetween(8, 100)
.Rule(name => name.All(char.IsLetterOrDigit)).WithMessage("Must contain only letter or digits")
)
.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 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
Features
Advanced fluent API, inline
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()
.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")
.Member(m => m.PrimaryEmail, emailSpecification)
.Member(m => m.AlternativeEmails, m => m
.Optional()
.MaxCollectionSize(3).WithMessage("Must not contain more than 3 addresses")
.AsCollection(emailSpecification)
)
.Rule(user => {
return user.PrimaryEmail == null || user.AlternativeEmails?.Contains(user.PrimaryEmail) == false;
}).WithMessage("Alternative emails must not contain the primary email address");
- Guide through Validot's fluent API
- If you prefer the approach of having a separate class for just validation logic, it's also fully supported
Validators
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
Results
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,
};
}
Rules
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
Translations
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
Validot vs FluentValidation
A 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.
Validot is faster and consumes less memory
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
(around 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 ~2.5x faster while consuming ~3.5x less memory. Especially when it comes to memory consumption, Validot is usually far, far better than that (depending on the use case it might be even ~15x more efficient comparing to FluentValidation):
Test | Data set | Library | Mean [ms] | Allocated [MB] |
---|---|---|---|---|
Validate | ManyErrors |
FluentValidation | 747.66 |
686.80 |
Validate | ManyErrors |
Validot | 321.00 |
183.19 |
FailFast | ManyErrors |
FluentValidation | 748.11 |
686.80 |
FailFast | ManyErrors |
Validot | 14.20 |
31.9 |
Validate | HalfErrors |
FluentValidation | 658.10 |
684.60 |
Validate | HalfErrors |
Validot | 273.51 |
85.10 |
FailFast | HalfErrors |
FluentValidation | 666.12 |
684.60 |
FailFast | HalfErrors |
Validot | 185.19 |
64.96 |
- Validate benchmark - objects are validated.
- FailFast benchmark - objects are validated, the process stops on the first error.
FluentValidation's IsValid
is a property that wraps a simple check whether the validation result contains errors or not. Validot has AnyErrors that act exactly 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 | 750.33 |
686.80 |
IsValid | ManyErrors |
Validot | 14.43 |
31.21 |
IsValid | HalfErrors |
FluentValidation | 647.11 |
684.60 |
IsValid | HalfErrors |
Validot | 181.90 |
64.57 |
IsValid | NoErrors |
FluentValidation | 652.64 |
668.51 |
IsValid | NoErrors |
Validot | 266.63 |
78.82 |
- IsValid benchmark - objects are validated, but only to check if they are valid or not.
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 | 753.50 |
721.01 |
Reporting | ManyErrors |
Validot | 419.60 |
335.99 |
Reporting | HalfErrors |
FluentValidation | 651.90 |
685.22 |
Reporting | HalfErrors |
Validot | 364.80 |
123.74 |
- Reporting benchmark:
- FluentValidation validates model, and
ToString()
is called if errors are detected. - Validot processes the model twice - at first, with its special mode, IsValid. Secondly - in case of errors detected - with the standard method, gathering all errors and printing them with
ToString()
.
- FluentValidation validates model, and
Benchmarks environment: Validot 1.0.0, FluentValidation 8.6.2, .NET Core 3.1.4, i7-9750H (2.60GHz, 1 CPU, 12 logical and 6 physical cores), X64 RyuJIT, macOS Catalina.
Validot handles nulls on its own
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
)
Validot treats null as an error by default
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.
Validot's Validator is immutable
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.StopOnFirstFailure
), use the flag:
validator.Validate(model, failFast: true);
FluentValidation's features that Validot is missing
Features that might be in the scope and are technically possible to implement in the future:
await
/async
support (discuss it on GitHub Issues)- transforming values (discuss it on GitHub Issues)
- severities (discuss it on GitHub Issues)
- failing fast only in a single scope (discuss it on GitHub Issues)
- validated value in the error message (discuss it on GitHub Issues)
- "smart paths" in the error message, e.g.
RootUserCollection
member becomesRoot User Collection
(discuss it on GitHub Issues)
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:
- Access to any stateful context in the rule condition predicate:
- It implicates a lack of support for dynamic message content and/or amount.
- Integration with ASP.NET or other frameworks:
- Making such a thing wouldn't be a difficult task at all, but Validot tries to remain a single-purpose library, and all integrations need to be done individually
- Callbacks:
- Please react on failure/success after getting validation result.
- Pre-validation:
- All cases can be handled by additional validation and a proper if-else.
- Also, the problem of the root being null doesn't exist in Validot (it's a regular case, covered entirely with fluent api)
- Rule sets
- workaround; multiple validators for different parts of the object.
Project info
Requirements
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.
Versioning
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.
Reliability
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 versions of operating systems:
- macOS
- Ubuntu
- Windows Server
using the LTS versions of the underlying frameworks:
- .NET Core 3.1
- .NET Core 2.1
- .NET Framework 4.8 (Windows only)
Performance
Benchmarks exist in the form of the console app project based on BenchmarkDotNet. Also, you can trigger performance tests from the build script.
Documentation
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.
Development
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.
Licencing
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.