Skip to content

How it works ​

ValidationModules does its work in two places. The source generator runs inside the compiler and writes a validator class for each validated type. Your application calls those classes when it validates.

At build time ​

The generator writes a validator for every class, record, struct or interface that has at least one of these:

  • a constraint attribute on an instance property, including a property it inherits. The attribute can come from ValidationModules.Constraints, from System.ComponentModel.DataAnnotations, or from your own custom constraints.
  • [GenerateValidator] on the type
  • a rules class that implements IValidationRulesFor<T> for the type

The validator for SignUp is named SignUpValidator. It is declared in the namespace of SignUp, with the same accessibility, and it implements IValidatorFor<SignUp>. The generator also writes one registration method for the whole assembly, described in Registration.

The validator for a type declared inside another type carries the containing types' names, joined with underscores. The validator for Order.Item is named Order_ItemValidator, and the validator for an Item declared inside Invoice is named Invoice_ItemValidator.

Here is a model:

csharp
using ValidationModules.Constraints;

public sealed class SignUp
{
    [Required, EmailAddress]
    public string? Email { get; init; }

    [Range(18, 120)]
    public int Age { get; init; }
}

This is the validator the generator writes for it. It is shortened, the global:: qualifiers are removed, and the helper methods are called in extension form:

csharp
// <auto-generated/>
public sealed partial class SignUpValidator : IValidatorFor<SignUp>
{
    private static readonly ValidationMessageInfo _message0 = new ValidationMessageInfo(
        ValidationMessageTemplates.RangeBetween,
        18,
        120
    );

    public ValidationFlow Validate(ref ValidationContext ctx, SignUp value)
    {
        var missingEmail = string.IsNullOrWhiteSpace(value.Email);
        if (missingEmail && ctx.ReportRequired("email", value: value.Email).ShouldStop)
        {
            return ValidationFlow.Stop;
        }
        if (
            !missingEmail
            && value.Email is not null
            && !ConstraintChecks.IsEmail(value.Email)
            && ctx.ReportEmail("email", value: value.Email).ShouldStop
        )
        {
            return ValidationFlow.Stop;
        }
        if (
            (value.Age < 18 || value.Age > 120)
            && ctx.Report("age", ValidationCodes.Range, value.Age, _message0).ShouldStop
        )
        {
            return ValidationFlow.Stop;
        }

        return ValidationFlow.Continue;
    }

    public bool IsValid(SignUp value)
    {
        if (string.IsNullOrWhiteSpace(value.Email))
        {
            return false;
        }
        // ...
        return true;
    }
}

Each constraint is an if statement that reads the member directly. The attributes are not read at run time. Message templates and their arguments are built once, in static fields. The validator uses no reflection, which is why it works under trimming and Native AOT.

The generator also checks what it reads. A declaration that cannot work is reported as a diagnostic with an id of the form VM####, in the IDE and in the build:

DeclarationDiagnostic
[StringLength] on an int propertyVM1001, error
[StringLength(1, Min = 10)]VM1101, error
[Pattern("(")]VM1106, error
[Required] on an int propertyVM1201, warning

The diagnostics reference lists every id and what to do about it.

At run time ​

validator.Validate(value) runs every check and returns a ValidationResult that holds the failures. The attribute checks run first, property by property, then the rules classes. Each failure is a ValidationError with a Field, a Code, a Message and a Severity. Results and errors covers these types.

validator.IsValid(value) answers only whether the value is valid. For most types declared with attributes, the generated IsValid repeats the checks and returns false at the first failure, without building paths, messages or error records. Some types run a full Validate pass instead and discard the errors: a type with a rules class, a type that implements IValidatableObject, a type with a ValidationAttribute on the class, a type with a Polymorphism.Runtime property, and a type that contains itself directly or through other types.

Performance ​

The generated code is built to do little work on each call:

  • A pass that finds no errors allocates only its error collector, and returns the shared ValidationResult.Valid.
  • ValidateInto lets the caller supply the collector, so one collector can serve many passes.
  • Messages are not built when an error is recorded. ValidationError.Message renders the text when it is read.
  • Arrays and lists that can be indexed, such as IReadOnlyList<T>, are walked by index, without an enumerator.
  • IsValid avoids the cost of recording errors, as described above.

The repository has two benchmark suites. The default suite measures this library on its own. The comparative suite measures it against FluentValidation and DataAnnotations, with the same rules in each. Run them with scripts/benchmark.sh, or with scripts/benchmark.sh --comparative for the comparison.

Rules classes are read, not run ​

A rules class declares rules in a static Describe method. The generator reads the body of Describe and writes an equivalent method into a generated class. Each rule call, such as rules.Range(x.Guests, 1, 8), becomes a check. Every other statement is copied as written and runs each time the validator runs.

Nothing calls Describe. A breakpoint in it never hits. Set the breakpoint in the generated code instead. Rules classes describes what the body can contain.

Viewing the generated code ​

Visual Studio and Rider list the generated files under the project's analyzers, below ValidationModules.SourceGenerator. To also write them to disk, set EmitCompilerGeneratedFiles in the project file:

xml
<PropertyGroup>
  <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup>

The files are then written under obj/<configuration>/<target framework>/generated/, or under the folder named by CompilerGeneratedFilesOutputPath. The validator for Shop.SignUp is in Shop.SignUpValidator.g.cs, the body of a rules class is in <rules class>_Rules.g.cs, and the registration method is in GeneratedValidatorRegistration.g.cs.

The compiler compares these file names without regard to case. When two of them differ only in case, the one that is later in ordinal order gets a number before .g.cs. The validators for Shop.Batch and Shop.batch are in Shop.BatchValidator.g.cs and Shop.batchValidator.2.g.cs.

Released under the MIT License.