Skip to content

Registration and DI

The generator emits registration alongside the validators, and which shape it emits depends on whether your project references DependencyModules. You do not have to choose; it probes and decides.

Both branches share one emitter for the body and differ only in the wrapper.

Without DependencyModules

An IServiceCollection extension named after your assembly:

csharp
var services = new ServiceCollection();

services.AddSampleValidators();
csharp
// <auto-generated/>
namespace Microsoft.Extensions.DependencyInjection {
    public static class MyAppValidationExtensions {
        public static IServiceCollection AddMyAppValidators(this IServiceCollection services) {
            services.AddSingleton<IValidatorFor<global::MyApp.Address>, global::MyApp.AddressValidator>();
            services.AddSingleton<IValidatorFor<global::MyApp.Pet>, global::MyApp.PetValidator>();

            services.AddValidationRunner<global::MyApp.Address>();
            services.AddValidationRunner<global::MyApp.Pet>();

            services.TryAddSingleton<IValidationFieldNamer>(CamelCaseFieldNamer.Instance);

            return services;
        }
    }
}

The method name carries the assembly, and has to. Each assembly registers its own validators — there is deliberately no cross-assembly scanning — so two of them emitting AddValidationModules() on IServiceCollection would be CS0121 at the composition root. AddMyAppValidators() and AddMyLibValidators() compose without ceremony.

Not idempotent. Calling it twice registers every validator twice, and a runner merges every registered validator for a type, so each error would be reported twice. Add rather than TryAdd is deliberate: registering a second validator for one type is how a hand-written rule composes with the generated one, so this cannot dedupe without breaking that.

The registrations are ordered by namespace then validator name, so they do not reshuffle between builds and an incremental compile does not produce a spurious diff.

With DependencyModules

A complete module, which you load the usual way:

csharp
services.AddModule<ValidationModule>();
csharp
// <auto-generated/>
public sealed class ValidationModule : IDependencyModule {
    public void PopulateServiceCollection(IServiceCollection services) {
        services.AddMyAppValidators();
    }
}

One body, two wrappers. The module is a one-line call to the same extension rather than a second copy of the registrations, so the two branches cannot drift.

IDependencyModule has exactly one member without a default implementation, so this is a complete class rather than a partial waiting for DM's own generator to finish it. That matters: source generators cannot see each other's output, so putting [SingletonService] on a generated validator and hoping DM picks it up would do nothing at all. Registration is emitted by the same generator that emits the validators, for that reason.

Do not host DependencyModules' stages

If you are writing your own generator on top of ValidationModules.SourceGenerator.Impl, do not derive from DM's BaseSourceGenerator and yield its ServiceSourceGenerator. A project referencing both would then have two generators processing [DependencyModule] and emit the module twice. Use DM's writers and models as a library; do not use its host.

Forcing the choice

xml
<PropertyGroup>
    <ValidationModules_Registration>ServiceCollection</ValidationModules_Registration>
</PropertyGroup>
ValueEffect
(unset)auto — DependencyModules if IDependencyModule resolves, otherwise the extension
DependencyModulesalways emit the module
ServiceCollectionalways emit the extension alone
Noneemit no registration at all

None is the escape hatch for DM arriving transitively into a project that does not want its validators in a module. It emits the validators and nothing else; wire them up yourself.

Lifetimes

Validators are singletons, always.

Generated validators are stateless, and building a rule graph once rather than per call is a hard requirement rather than a preference. This is the single largest difference from FluentValidation in practice: AddValidatorsFromAssemblyContaining registers validators scoped, so by default it rebuilds its rule graph on every request — about 11 KB of allocation per resolve, against 0 B and roughly 4 ns to reach a generated singleton. See benchmarks/RESULTS.md for the measurement, including why the construction timing is quoted as an order of magnitude rather than a figure.

ValidationRunner<T> is scoped, because the async validators it composes may take scoped dependencies.

ValidationRunner<T>

Once a type has more than one validator — a generated structural one plus a hand-written business rule — you want them run together and their results merged:

csharp
public class PetService {
    private readonly ValidationRunner<Pet> _validation;

    public PetService(ValidationRunner<Pet> validation) => _validation = validation;

    public async Task<ValidationResult> CheckAsync(Pet pet, CancellationToken cancellationToken) =>
        await _validation.ValidateAsync(pet, cancellationToken);
}

The signature is ValidateAsync(T value, CancellationToken cancellationToken = default), so the token passes positionally.

Validate(value) is the synchronous half, and runs the structural validators only.

The runner resolves every registered IValidatorFor<T> and IAsyncValidatorFor<T>, runs the structural ones first, and only runs the async ones if structural validation passed — so nothing hits the database to check uniqueness on a field that is null.

Results merge rather than replacing by precedence. Structural constraints must not silently disappear because someone added a business rule, and merging removes the precedence question entirely. Async and business rules covers writing the async side.

It is registered closed, per type, by the generator:

csharp
services.AddValidationRunner<Pet>();

Closed rather than open generic, deliberately — AddScoped(typeof(ValidationRunner<>)) would have MS.DI construct it reflectively, which is what a Native AOT publish cannot do.

Nested types compose too

A validator registered for a nested type runs when that type is reached through its parent, not just when it is validated directly:

csharp
var services = new ServiceCollection();

services.AddSampleValidators();
services.AddSingleton<IValidatorFor<Address>, AddressBlocklistValidator>();

using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();

var runner = scope.ServiceProvider.GetRequiredService<ValidationRunner<Pet>>();

// Reports home.postalCode:blocked from the hand-written Address validator.
var result = runner.Validate(new Pet { Home = new Address { PostalCode = "SW1" } });

public sealed class AddressBlocklistValidator : IValidatorFor<Address> {
    public void Validate(ref ValidationContext context, Address value) {
        if (value.PostalCode == "SW1") context.Add("postalCode", "blocked", "postal code is blocked.");
    }
}

This works by constructor injection, not by a lookup at descent time. A generated validator for a type with nested properties takes one IEnumerable<IValidatorFor<Nested>> per nested type and materialises each into an array in its constructor:

csharp
public PetValidator(
    IEnumerable<IValidatorFor<global::MyApp.Address>> home,
    IEnumerable<IValidatorFor<global::MyApp.Toy>> toys) {
    _homeValidators = System.Linq.Enumerable.ToArray(home);
    _toysValidators = System.Linq.Enumerable.ToArray(toys);
}

Two consequences worth knowing. The set is resolved once, when the singleton is built, not per descent — so composition costs nothing on the hot path. And the closed generic is written by the generator, which knows the nested type at build time; the reflective spelling would be MakeGenericType, so this stays AOT-safe.

What it costs when no container took part

A validator you construct yourself — new PetValidator() — uses the parameterless constructor and falls back to each nested type's own generated validator, built lazily on first descent. That is the pre-composition behaviour, and it is what a unit test gets.

Match the field name

The generated AddressValidator derives its field name from [JsonPropertyName] and the naming policy. A hand-written validator has to say the same thing — if the model carried [JsonPropertyName("postal_code")], write:

csharp
context.Add("postal_code", "blocked", "postal code is blocked.");   // not "postalCode"

Otherwise one field arrives under two names depending on which rule failed. The runtime cannot check this for you — reading the attribute at run time is reflection.

Registering a validator by hand

Nothing stops you adding your own alongside the generated one:

csharp
services.AddSingleton<IValidatorFor<Pet>, MyExtraPetValidator>();

Both run, and both sets of errors appear. Registration is Add, not TryAdd, precisely so this works.

Rule classes without the generator

If a rule class has not been compiled by this generator — because it came from a referenced assembly, or another generator emitted it — register it to be run instead:

csharp
services.AddDescribedValidator<Pet, PetRules>();

That constructs a DescribedValidator<Pet>, which calls Describe once in its constructor and walks the rules it recorded. Singleton, so Describe runs once per process.

Do not do both for one type

If this generator compiled the rules class it also registered the validator it emitted. Calling AddDescribedValidator as well registers a second, slower validator for the same type — and since ValidationRunner<T> merges every registered validator, every error appears twice. Within one compilation that is VM0074.

The field namer

The generated registration and AddDescribedValidator both register IValidationFieldNamer with TryAdd, so a namer you registered first survives:

csharp
services.AddSingleton<IValidationFieldNamer>(SnakeCaseFieldNamer.Instance);
services.AddSampleValidators();   // keeps yours

This only affects engines that compute names at run time — the FluentValidation adapter and DescribedValidator<T>. Generated validators have their field names baked in as literals at build time, so changing the registered namer does not rename their errors. Use ValidationModules_FieldNaming for those, and set both to the same policy if you use both engines.

Released under the MIT License.