ValidationModulesValidation, decided at compile time
Declare constraints on the model they belong to, and a source generator writes the checks during the build. Nothing reflects, nothing compiles an expression tree, and no regex is built at startup — so a Native AOT publish keeps every rule you declared.
Validation is the layer most likely to be quietly reflective. FluentValidation compiles expression trees; System.ComponentModel.DataAnnotations walks attributes with Validator.TryValidateObject. Both work — and both put reflection on the request path:
csharp
RuleFor(x => x.Name).NotNull().Length(1, 100);
Under Native AOT Expression.Compile() does not throw. It falls back to the LINQ interpreter, so property access is interpreted rather than compiled, and you carry IL2026/IL3050 trim warnings into a published build. The rules still run; they just cost more than they look like they cost, in the configuration where you can least afford it.
Declare the constraint on the property. The check is written for you during the build.
csharp
using ValidationModules.Constraints;public record Pet { [Required] [StringLength(min: 1, max: 100)] public string? Name { get; init; } [Range(0, 30)] public int Age { get; init; } [ValidateNested] public Address? Home { get; init; }}
What comes out the other side is the code you would have written by hand, in your own assembly:
csharp
public sealed partial class PetValidator : IValidatorFor<Pet> { public static readonly PetValidator Instance = new(); public void Validate(ref ValidationContext ctx, Pet value) { if (string.IsNullOrWhiteSpace(value.Name)) ctx.AddRequired("name"); else if (value.Name.Length > 100) ctx.AddStringLength("name", 1, 100); if (value.Age < 0 || value.Age > 30) ctx.AddRange("age", 0, 30); if (value.Home is { } nestedHome) { var ctxHome = ctx.Push("home"); HomeValidators[0].Validate(ref ctxHome, nestedHome); } }}
Then run it:
csharp
var result = new PetValidator().Validate(pet);foreach (var error in result.Errors) { Console.WriteLine($"{error.Field}: {error.Code}");}// name required// home.postalCode required// toys[3].name required
Measured against the same rules expressed in FluentValidation and in DataAnnotations, on .NET 10, Apple M3 Pro. The full method and the four choices made in FluentValidation's favour are in benchmarks/README.md; the run these came from, and what is and is not stable in it, is in benchmarks/RESULTS.md.
ValidationModules
FluentValidation
DataAnnotations
Flat model, valid
26.4 ns / 40 B
196 ns / 664 B
1,031 ns / 2,696 B
Nested model, valid
104 ns / 40 B
1,804 ns / 5,224 B
588 ns (top level only)
1,000 elements
12.0 µs / 40 B
250 µs / 846 KB
does not descend
Resolve from DI
4 ns / 0 B
~4–6 µs / 11 KB
—
The allocation column is the one worth reading twice, because it is counted rather than timed and so does not move between runs. 40 bytes is the whole cost of a passing validation — one result object, the same for a flat model, a nested one, and a thousand elements, because the walk itself allocates nothing.
The last row is a range rather than a figure deliberately. AddValidatorsFromAssemblyContaining registers validators scoped, so by default FluentValidation rebuilds its rule graph on every request. That costs about 11 KB per resolve, which is exact, and a few microseconds, which is dominated by garbage collection and moves too much between runs on our hardware to quote more precisely than that.