Getting started
The problem
Every .NET application validates its inputs, and the layer that does it is the one most likely to be quietly reflective.
// FluentValidation
RuleFor(x => x.Name).NotNull().Length(1, 100);// DataAnnotations
Validator.TryValidateObject(model, context, results, validateAllProperties: true);Both work. Both put reflection on the path a request takes. FluentValidation compiles an expression tree per property access; TryValidateObject walks attributes and invokes each one.
Under Native AOT this does not fail loudly, which is the awkward part. Expression.Compile() falls back to the LINQ interpreter rather than throwing, so the rules still run — just interpreted, and with IL2026/IL3050 trim warnings carried into the published build. You find out from a benchmark, or from a warning you learned to ignore, rather than from a crash.
The second problem is quieter still. A constraint that never fires looks exactly like a constraint that passes:
public record Pet([Required] string Name); // the attribute lands on the parameter, not the propertyNothing tells you. The model reads as validated and validates nothing.
How ValidationModules helps
You declare the constraint on the property, and a source generator writes the check into your assembly while the project builds.
Because the result is ordinary C# in your own assembly, there is nothing to reflect over at startup, nothing for the trimmer to lose, and nothing between the attribute you wrote and the branch that runs. And because the generator has the full compilation in front of it, the mistakes above become build errors instead of silence.
Install
dotnet add package ValidationModules.Runtime
dotnet add package ValidationModules.SourceGeneratorRequires .NET 8.0 or later, and ships both net8.0 and net10.0 assemblies so a project on either LTS release gets one built against its own framework.
ValidationModules.Runtime depends only on Microsoft.Extensions.DependencyInjection.Abstractions. In particular it does not reference DependencyModules.Runtime — the library is DependencyModules-shaped in its ergonomics, but only the generated module needs DM types, and that lands in your assembly, which already references DM if you use it.
The generator should be referenced with PrivateAssets="all" so it does not flow to your package's consumers:
<PackageReference Include="ValidationModules.SourceGenerator" Version="…" PrivateAssets="all" />Your first validator
Put constraints on the model:
using ValidationModules.Constraints;
namespace MyApp;
public record Pet {
[Required]
[StringLength(min: 1, max: 100)]
public string? Name { get; init; }
[Range(0, 30)]
public int Age { get; init; }
}That is the whole declaration. There is no PetValidator to write and no registration call to remember — the generator finds any type carrying a constraint and emits a validator for it.
Import ValidationModules.Constraints, not ValidationModules
The constraint attributes live in their own namespace on purpose. Five of the names — Required, StringLength, Range, AllowedValues and the length family — collide with System.ComponentModel.DataAnnotations. Keeping them separate means the ambiguity is only reachable from a file that explicitly asks for both, and your service code never trips it.
What was generated
Set EmitCompilerGeneratedFiles and look:
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup>obj/Debug/net8.0/generated/…/MyApp.PetValidator.g.cs holds:
// <auto-generated/>
#nullable enable
public sealed partial class PetValidator : IValidatorFor<global::MyApp.Pet> {
public PetValidator() { }
public void Validate(ref ValidationContext ctx, global::MyApp.Pet value) {
if (string.IsNullOrWhiteSpace(value.Name)) ctx.AddRequired("name");
else if (value.Name is not null && (value.Name.Length < 1 || value.Name.Length > 100))
ctx.AddStringLength("name", 1, 100);
if ((value.Age < 0 || value.Age > 30)) ctx.AddRange("age", 0, 30);
}
}Three things in that file are worth noticing, because each is a deliberate constraint rather than an implementation detail:
- A public parameterless constructor, and no state. The validator is registered as a singleton and holds nothing, so constructing one costs an allocation and no work. A type with nested properties gets a second constructor taking the nested types' validators, which is how a hand-written validator for a nested type composes — see nesting.
- The
else ifafter arequiredcheck is an optimization, not the mechanism. Suppressing the rest of a field after[Required]fails is enforced by the collector, so every engine gets it — see the error model. - The class is
internalif your model is. A public validator taking a less accessible parameter is CS0051, so the emitter matches the model's accessibility. - No attributes on the generated type. Source generators cannot see each other's output, so an attribute here would be read by nothing. Registration is emitted by the same generator instead.
Running it
The simplest call takes the value and hands back an immutable result:
var pet = new Pet();
var result = new PetValidator().Validate(pet);
if (!result.IsValid) {
foreach (var error in result.Errors) {
Console.WriteLine($"{error.Field}: {error.Code} — {error.Message}");
}
}name: required — name is required.
age: range — age must be between 0 and 30.There are three other entry points, and which you want depends on what you are doing with the answer:
| Call | Use when |
|---|---|
validator.Validate(value) | you want the errors. Allocates a collector and a result. |
validator.IsValid(value) | you only want the verdict. Stops as soon as it knows. |
validator.ValidateAndThrow(value) | a failure should unwind. Throws ValidationException. |
validator.ValidateInto(collector, value) | a hot path where you own and reuse the collector. |
Wiring it into DI
The generator emits an IServiceCollection extension named after your assembly:
var services = new ServiceCollection();
services.AddSampleValidators();The name carries the assembly because each one registers its own validators — MyApp gets AddMyAppValidators(), MyApp.Contracts gets AddMyAppContractsValidators(). Two assemblies both emitting AddValidationModules() would be ambiguous at the composition root; this composes without ceremony.
Finding the name
It is your assembly name with the dots removed, wrapped in Add…Validators. If you would rather read it than derive it, it is in obj/Debug/<tfm>/generated/…/GeneratedValidatorRegistration.g.cs.
If your project references DependencyModules, the generator emits a module wrapping the same call instead, and you load it the usual way:
services.AddModule<ValidationModule>();You do not choose between these — the generator probes for IDependencyModule and emits whichever fits. Registration and DI covers forcing the choice, and what ValidationRunner<T> adds once more than one validator exists for a type.
Where to go next
- Constraints — the nine attributes and what each emits.
- Nesting and collections —
[ValidateNested], element paths, dictionaries. - The error model — ordering, codes, field paths, severity.
- Rule classes — declaring rules for a type you do not own, and cross-field rules that no attribute can express.
- ASP.NET Core — validating a request before the handler runs.
- Trimming and AOT — what is enforced, and the one thing that needs your attention.