View on GitHub
Clean Architecture Toolkit for .NET
Clean Architecture Toolkit is a clean, modular architecture foundation for .NET applications.
It embraces SOLID principles and focuses on maintainability, testability, and scalability through clear boundaries and pluggable design.

|   HOME   |   MODULE OVERVIEW   |   DEMO   |

Cat.Validators

Cat.Validators checks a model against a set of rules and tells you everything that is wrong with it. You describe the rules for a model in a validator class. The processor runs the rules and returns an ICatResponse: Ok when the model is valid, BadRequest with one error per failure when it is not.

The rules are fluent and read like a sentence. Every rule has a stable error code and a message template, so clients can react to a failure without parsing text. You can replace the field name, the message and the code per rule, and run a rule only when a condition is true.

Rules never stop at the first failure. One call reports every problem.

Table of contents

NuGet packages

NuGet Version

Package Role Install Downloads
Cat.Validators.Abstractions Contracts dotnet add package gmarginet.Cat.Validators.Abstractions NuGet Downloads
Cat.Validators Implementation dotnet add package gmarginet.Cat.Validators NuGet Downloads

Namespaces

Namespace What it holds
Cat.Validators.Abstractions Validator and rule interfaces.
Cat.Validators The fluent rules, the processor and the module registration.

Dependencies

Module Why Cat.Validators needs it
Cat.Responses Validation returns an ICatResponse with one error per failure.
Cat.Expressions Rules read property values through compiled expressions.
Cat.Extensions Small helpers for service registration.

Usage

Register the module

Call AddCatValidatorsModule and pass one type from each assembly that holds validators. The module scans those assemblies and registers every non-generic validator with a scoped lifetime. It also registers ICatValidatorProcessor.

The rules read property values through the compiled member expression cache from Cat.Expressions, so register that too.

using Cat.Expressions.Extensions;
using Cat.Validators.Extensions;

builder.Services.AddCatCompiledMemberExpressionCache();
builder.Services.AddCatValidatorsModule(typeof(TodoValidator));

Write a validator

A validator implements ICatValidator<TModel> for one model type. In Build, pick a property with a lambda and add rules to it. Chain as many rules as you like.

using Cat.Validators.Abstractions;
using Cat.Validators.Extensions;

public class TodoValidator : ICatValidator<Todo>
{
    public void Build(ICatValidatorBuilder<Todo> validator)
    {
        validator.AddPropertyRules(m => m.Title, b =>
        {
            b.NotEmpty();
            b.MaximumLength(100);
        });

        validator.AddPropertyRules(m => m.Priority, b =>
        {
            b.InclusiveBetween(1, 5);
        });
    }
}

Validate a model

Inject ICatValidatorProcessor and pass it the model.

using Cat.Validators.Abstractions;

public class TodoService
{
    private readonly ICatValidatorProcessor _validatorProcessor;

    public TodoService(ICatValidatorProcessor validatorProcessor)
    {
        _validatorProcessor = validatorProcessor;
    }

    public async Task<ICatResponse> CheckAsync(Todo todo, CancellationToken cancellationToken)
    {
        return await _validatorProcessor.ValidateAsync(todo, cancellationToken: cancellationToken);
    }
}

What you get back:

Prefer ValidateAsync. Validate blocks until the rules finish, so keep it for places where you can’t await.

The error you get back

Every failure becomes an error with a code, a message template and arguments:

The field name is the name of the property, like Title. Nested validation adds a prefix, like Todo.Title or Items[0].Title.

Rules

Every rule below is an extension method you call on the builder inside AddPropertyRules. Each failed rule adds one error. Placeholders are listed in the order of the arguments, after the field name.

Null and empty rules

Rule Passes when Code Message
NotNull() The value is not null. CAT-VAL-1001 '{propertyName}' should not be Null (Empty).
Null() The value is null. CAT-VAL-1002 '{propertyName}' should be Null (Empty).
NotEmpty() The value is not null, not a blank string, not an empty collection and not the default of its type, like 0 or Guid.Empty. CAT-VAL-1003 '{propertyName}' should not be empty.
Empty() The value is null, a blank string, an empty collection or the default of its type. CAT-VAL-1004 '{propertyName}' should be empty.

These rules have no extra arguments.

validator.AddPropertyRules(m => m.Title, b => b.NotEmpty());
validator.AddPropertyRules(m => m.DeletedOn, b => b.Null());

Comparison rules

Each comparison rule has two forms. Pass a fixed value, or pass a lambda that picks another property of the same model.

Rule Passes when Code Message Extra arguments
Equal The value equals the other value. CAT-VAL-1011 '{propertyName}' should be equal to '{comparisonValue}' comparisonValue
NotEqual The value differs from the other value. CAT-VAL-1010 '{propertyName}' should not be equal to '{comparisonValue}.' comparisonValue
LessThan The value is less than the other value. CAT-VAL-1012 '{propertyName}' should be less than '{comparisonValue}'. comparisonValue
LessThanOrEqualTo The value is less than or equal to the other value. CAT-VAL-1013 '{propertyName}' should be less than or equal to '{comparisonValue}'. comparisonValue
GreaterThan The value is greater than the other value. CAT-VAL-1014 '{propertyName}' should be greater than '{comparisonValue}'. comparisonValue
GreaterThanOrEqualTo The value is greater than or equal to the other value. CAT-VAL-1015 '{propertyName}' should be greater than or equal to '{comparisonValue}'. comparisonValue

Equal and NotEqual take an optional IEqualityComparer. Strings compare ordinal by default, so case matters.

LessThan, LessThanOrEqualTo, GreaterThan and GreaterThanOrEqualTo work on any type that implements IComparable.

validator.AddPropertyRules(m => m.Quantity, b => b.GreaterThan(0));
validator.AddPropertyRules(m => m.EndDate, b => b.GreaterThan(m => m.StartDate));
validator.AddPropertyRules(m => m.Email, b => b.NotEqual("admin@example.com"));
validator.AddPropertyRules(m => m.ConfirmPassword, b => b.Equal(m => m.Password));

Between rules

Both rules take a lower and an upper bound. The lower bound must be less than the upper bound, or the rule throws when it runs.

Rule Passes when Code Message Extra arguments
InclusiveBetween The value is between the bounds, bounds included. CAT-VAL-1016 '{propertyName}' should be between '{lowerBound}' and '{upperBound}' (inclusive). You entered '{propertyValue}'. lowerBound, upperBound, propertyValue
ExclusiveBetween The value is between the bounds, bounds excluded. CAT-VAL-1017 '{propertyName}' should be between '{lowerBound}' and '{upperBound}' (exclusive). You entered '{propertyValue}'. lowerBound, upperBound, propertyValue

Each rule has four forms:

validator.AddPropertyRules(m => m.Rating, b => b.InclusiveBetween(1, 5));
validator.AddPropertyRules(m => m.Price, b => b.ExclusiveBetween(m => m.MinPrice, m => m.MaxPrice));

Rules for nullable values

The comparison and between rules also work on nullable value types, like decimal?, int? and DateTime?. Equal and NotEqual already accept them.

These rules have the same names, codes, messages and arguments as the rules above:

A null passes, like it does for the string rules. Pair the rule with NotNull when a value is required.

When you compare with another property, or take a bound from the model, a null there passes too. There is nothing to compare with.

validator.AddPropertyRules(m => m.Price, b => b.GreaterThanOrEqualTo(0m));

validator.AddPropertyRules(m => m.Discount, b =>
{
    b.NotNull();
    b.InclusiveBetween(0, 50);
});

String rules

String rules only compile on string properties. They all let a null string pass, so pair them with NotNull or NotEmpty when the text is required.

Rule Passes when Code Message Extra arguments
Length(min, max) The length is between min and max. CAT-VAL-1005 '{propertyName}' should be between '{minLength}' and '{maxLength}' characters. You entered '{length}' characters. minLength, maxLength, length
ExactLength(n) The length is exactly n. CAT-VAL-1006 '{propertyName}' should be '{exactLength}' characters in length. You entered '{length}' characters. exactLength, length
MaximumLength(n) The length is at most n. CAT-VAL-1007 The length of '{propertyName}' should be '{maxLength}' characters or fewer. You entered '{length}' characters. maxLength, length
MinimumLength(n) The length is at least n. CAT-VAL-1008 The length of '{propertyName}' should be at least '{minLength}' characters. You entered '{length}' characters. minLength, length
MatchesRegex The text matches the regular expression. CAT-VAL-1009 '{propertyName}' is not in the correct format. [regex: {regexPattern}] regexPattern

Length needs min to be less than max, or the rule throws when it runs.

Each length rule also takes a lambda instead of a number, so the limit can come from the model: MaximumLength(m => m.MaxTitleLength).

MatchesRegex has four forms:

validator.AddPropertyRules(m => m.Title, b =>
{
    b.NotEmpty();
    b.Length(3, 100);
});

validator.AddPropertyRules(m => m.PostalCode, b =>
{
    b.NotEmpty();
    b.MatchesRegex(@"^\d{4}[A-Z]{2}$");
});

Collection rules

Collection rules compile on any IEnumerable property, like arrays, lists and IEnumerable<T>. They all let a null collection pass, so pair them with NotEmpty when the collection is required. They count the items without loading more than needed. A plain ICollection is not enumerated.

Rule Passes when Code Message Extra arguments
MinimumCount(n) The collection has at least n items. CAT-VAL-1018 '{propertyName}' should have at least '{minCount}' items. It has '{count}' items. minCount, count
MaximumCount(n) The collection has at most n items. CAT-VAL-1019 '{propertyName}' should have '{maxCount}' items or fewer. It has '{count}' items. maxCount, count
CountBetween(min, max) The number of items is between min and max, both included. CAT-VAL-1020 '{propertyName}' should have between '{minCount}' and '{maxCount}' items. It has '{count}' items. minCount, maxCount, count

CountBetween needs min to be at most max, or the rule throws when it runs. Every collection rule also takes lambdas instead of numbers, so the limits can come from the model: MaximumCount(m => m.MaxLines) and CountBetween(m => m.MinLines, m => m.MaxLines).

validator.AddPropertyRules(m => m.BookIds, b =>
{
    b.NotEmpty();
    b.MaximumCount(100);
});

Nested validation rules

These rules run another validator on a property. Register that validator too. The validatorRequired flag of the top level call decides what happens when a type has no validator:

Rule Does
Validate() Runs the validator of the property type. A null property passes.
ValidateEnumerable() Runs the validator of the item type on every item of a collection. A null collection passes. A null item is skipped but still counts for the index.

The errors of the nested validator keep their own code and message. Only the field name changes:

validator.AddPropertyRules(m => m.Todo, b => b.NotNull());
validator.AddPropertyRules(m => m.Todo, b => b.Validate());
validator.AddPropertyRules(m => m.Items, b => b.ValidateEnumerable());

Custom rules with Must

When no built-in rule fits, write the check yourself. Must and MustAsync hand you the value and the field name, and you return the list of errors. Return CatValidatorResult.Valid() when all is well.

Use CatValidatorError.Create(code, fieldName, message, arguments) to build an error.

validator.AddPropertyRules(m => m.Title, b =>
{
    b.Must((title, fieldName) => title.StartsWith("TODO")
        ? CatValidatorResult.Valid()
        : CatValidatorResult.Invalid(
            CatValidatorError.Create("APP-1001", fieldName, "'{propertyName}' should start with TODO.")));
});

There are four forms:

Method Gets
Must The value and the field name.
MustAsync The value, the field name and a cancellation token.
Must The value, the field name and the whole model.
MustAsync The value, the field name, the whole model and a cancellation token.

To check the whole model instead of one property, use AddRules. The lambda gets the model, or the model and a cancellation token for MustAsync.

validator.AddRules(b =>
{
    b.Must(todo => todo.EndDate >= todo.StartDate
        ? CatValidatorResult.Valid()
        : CatValidatorResult.Invalid(
            CatValidatorError.Create("APP-1002", "EndDate", "The end date should not be before the start date.")));
});

Must and MustAsync return nothing, so you can’t chain rule options after them. You build the errors yourself, so set the code, field name and message there.

Rule options

Every built-in rule returns an options builder. Chain options right after the rule. Each option applies only to that one rule.

validator.AddPropertyRules(m => m.Title, b =>
{
    b.NotEmpty()
        .WithFieldName("Name")
        .WithCode("APP-2001")
        .WithMessage("Please enter a name.")
        .When(m => m.IsPublished);
});

When and WhenAsync

When runs the rule only when the condition returns true. When it returns false, the rule is skipped and adds no error. WhenAsync does the same with an async condition.

Add several conditions to one rule and all of them must be true.

b.MaximumLength(50).When(m => m.IsDraft);

b.NotNull().WhenAsync((m, ct) => featureFlags.IsEnabledAsync("todo-title", ct));

The condition gets the whole model, so you can look at other properties. The async condition is an expression, so it can’t be an async lambda. Return the Task<bool> directly.

WithFieldName

WithFieldName replaces the field name in the error. By default the field name is the property name. Use it when your API calls the field something else.

b.NotEmpty().WithFieldName("name");

The new name is also the first argument of the error, so it shows up in the message.

WithMessage

WithMessage replaces the message template. The arguments of the rule stay the same, so you can keep using its placeholders.

b.MaximumLength(100).WithMessage("'{propertyName}' is too long. The limit is '{maxLength}'.");

WithCode

WithCode replaces the error code. Use it to give a rule a code of your own, so clients can tell two uses of the same rule apart.

b.NotEmpty().WithCode("APP-2001");

Write your own rule class

When you reuse a check in many validators, wrap it in a rule class. Derive from one of the base classes and add a small extension method.

Base class Use it for
CatValidatorPropertyRuleBase<TModel, TProperty> A synchronous rule on a property. Implement Validate(value, fieldName, model).
CatValidatorPropertyRuleAsyncBase<TModel, TProperty> An async rule on a property. Implement ValidateAsync(value, fieldName, model, ct).
CatValidatorModelRuleBase<TModel> A synchronous rule on the whole model. Implement Validate(model).
CatValidatorModelRuleAsyncBase<TModel> An async rule on the whole model. Implement ValidateAsync(model, ct).

The property bases give you ErrorCode, FieldName and ErrorMessage, so WithCode, WithFieldName and WithMessage work on your rule too. Fall back to your own defaults like the built-in rules do.

public class PropertyStartsWithTodoRule<TModel> : CatValidatorPropertyRuleBase<TModel, string>
    where TModel : class
{
    protected override List<ICatValidatorError> Validate(string value, string fieldName, TModel model)
    {
        return value.StartsWith("TODO")
            ? CatValidatorResult.Valid()
            : CatValidatorResult.Invalid(
                CatValidatorError.Create(
                    code: ErrorCode ?? "APP-1001",
                    fieldName: FieldName ?? fieldName,
                    message: ErrorMessage ?? "'{propertyName}' should start with TODO."));
    }
}

public static class TodoRuleExtensions
{
    public static ICatValidatorPropertyRuleOptionsBuilder<TModel, string> StartsWithTodo<TModel>(
        this ICatValidatorPropertyRuleBuilder<TModel, string> builder)
        where TModel : class
    {
        return builder.AddPropertyRule(new PropertyStartsWithTodoRule<TModel>());
    }
}

Validate service requests automatically

You rarely call the processor by hand. The Cat.ServiceRequests module has a validation decorator that runs the validator of a request before its handler. An invalid request never reaches the handler. See the Cat.ServiceRequests module.

Errors and exceptions

A broken model never throws. The processor returns a response. Mistakes in how you set up the validators throw exceptions.

Situation What you get
The model passes every rule An Ok response.
One or more rules fail A BadRequest response with one error for each failed rule. See The error you get back.
A condition from When or WhenAsync returns false The rule is skipped and adds no error.
No validator is registered for the model An InvalidOperationException from the service provider. Pass validatorRequired: false to treat the model as valid instead.
A nested type used with Validate() or ValidateEnumerable() has no validator The same exception, or a pass with validatorRequired: false.
ICatCompiledMemberExpressionCache<T> isn’t registered An InvalidOperationException from the service provider when the rules run. Register the Cat.Expressions cache.
Length gets a minimum that isn’t less than the maximum An ArgumentOutOfRangeException with the message <valueName> must be less than <limitName>. when the rule runs.
InclusiveBetween or ExclusiveBetween gets a lower bound that isn’t less than the upper bound The same ArgumentOutOfRangeException. The nullable forms only check the bounds when the property has a value.
MatchesRegex gets an invalid pattern An ArgumentException with the message Invalid regex expression: <pattern>.
A rule class isn’t a property rule or a model rule An ArgumentException with the message Rule <name> is not a valid property rule or Rule <name> is not a valid model rule.

The code, message and arguments of every built-in rule are listed in Rules. CAT-VAL-1000 (Unknown validation error occurred.) is defined as a fallback.

Codes you pass to WithCode or CatValidatorError.Create appear in the response as you wrote them. Give your own codes a prefix of your own, like APP-, so they don’t clash with CAT-VAL- or CAT-GEN-.