Building a Source Generator for DTO Validation in .NET 8
NET 8 doesn't include a built-in DataAnnotations source generator. This post shows how to write a minimal generator that emits a fast Validate method for your DTOs, moving validation from runtime reflection to compile-time code generation.
16 Feb 2026, 02:16 UTC

Problem: Validation boilerplate that runs at runtime
Most .NET APIs end up with a layer of data-transfer objects (DTOs) that carry request payloads. Each DTO typically needs rules — required fields, length limits, numeric ranges, email format — and developers traditionally express those rules with System.ComponentModel.DataAnnotations attributes. At runtime the framework (or a manual call to Validator.TryValidateObject) walks the object graph via reflection, checks every attribute, and builds a list of ValidationResult objects. That reflection has a measurable cost, and the validation logic lives only in the compiled assembly, so you don't see errors until the code actually runs.
Thesis: A source generator moves validation to build time, but .NET 8 doesn't ship one for DataAnnotations
.NET 8 includes several built-in source generators (System.Text.Json, logging, regex, etc.), but there is no built-in generator that reads [Required], [StringLength], [Range], and friends and emits a fast Validate method. To get compile-time validation you either adopt a third-party generator (for example, the community DataAnnotations.SourceGenerator package) or write a small generator yourself. Writing your own is surprisingly little code and gives you full control over the emitted API.
How a validation source generator works
A source generator is a piece of code that runs during compilation. It receives a Compilation object representing the whole project, inspects syntax trees for types marked with a marker attribute (or simply any type that has DataAnnotations attributes), and emits additional C# source files into the compilation. The emitted files become part of the same assembly, so the generated Validate method is just another method you can call — no reflection, no extra NuGet dependency at runtime.
The generator does not emit IL directly; it emits C# text (or syntax trees) that the compiler then turns into IL. This distinction matters: you can open the generated .g.cs file, read it, debug it, and even copy it into your project if you ever need to stop using the generator.
Worked example: A minimal DataAnnotations validation generator
The following steps create a class library that defines a DTO, a separate generator project that emits a Validate method for any record or class containing DataAnnotations attributes, and a console app that exercises the generated code. All code targets .NET 8.
1. Create the solution structure
mkdir ValidationGeneratorDemo
cd ValidationGeneratorDemo
dotnet new sln
dotnet new classlib -n DtoModels
dotnet new classlib -n ValidationGenerator
dotnet new console -n DemoApp
dotnet sln add DtoModels/DtoModels.csproj ValidationGenerator/ValidationGenerator.csproj DemoApp/DemoApp.csproj2. Define a marker attribute and a DTO
In DtoModels, add a simple attribute that the generator will look for. This keeps the generator from processing every type in the compilation.
// DtoModels/GenerateValidation.cs
using System;
namespace DtoModels;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Record, AllowMultiple = false, Inherited = false)]
public sealed class GenerateValidationAttribute : Attribute { }Now add a DTO that uses the marker and standard DataAnnotations:
// DtoModels/UserDto.cs
using System.ComponentModel.DataAnnotations;
namespace DtoModels;
[GenerateValidation]
public record UserDto
{
[Required]
[StringLength(50, MinimumLength = 3)]
public string? Username { get; init; }
[Range(18, 100)]
public int Age { get; init; }
[EmailAddress]
public string? Email { get; init; }
}3. Implement the generator
The generator project needs two package references: Microsoft.CodeAnalysis.CSharp and Microsoft.CodeAnalysis.Analyzers. Edit ValidationGenerator/ValidationGenerator.csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<IncludeBuildOutput>false</IncludeBuildOutput>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.11.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
</ItemGroup>
</Project>Replace Class1.cs with the generator implementation. The code below is intentionally compact; a production generator would handle more attribute types, nullable contexts, and diagnostics.
// ValidationGenerator/ValidationGenerator.cs
using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
namespace ValidationGenerator;
[Generator(LanguageNames.CSharp)]
public sealed class ValidationGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Find all record/class/struct declarations that have the [GenerateValidation] attribute
var candidateTypes = context.SyntaxProvider
.CreateSyntaxProvider(
predicate: static (node, _) => node is TypeDeclarationSyntax { AttributeLists.Count: > 0 },
transform: static (ctx, _) => GetTypeIfMarked(ctx))
.Where(static t => t is not null);
var compilationAndTypes = context.CompilationProvider.Combine(candidateTypes.Collect());
context.RegisterSourceOutput(compilationAndTypes, static (spc, source) =>
{
var (compilation, types) = source;
foreach (var type in types)
{
var generated = GenerateValidationClass(compilation, type);
spc.AddSource($"{type.Name}.g.cs", SourceText.From(generated, Encoding.UTF8));
}
});
}
private static INamedTypeSymbol? GetTypeIfMarked(GeneratorSyntaxContext ctx)
{
var typeDecl = (TypeDeclarationSyntax)ctx.Node;
var symbol = ctx.SemanticModel.GetDeclaredSymbol(typeDecl);
if (symbol is not INamedTypeSymbol named) return null;
var marker = compilation.GetTypeByMetadataName("DtoModels.GenerateValidationAttribute");
if (marker is null) return null;
return named.GetAttributes().Any(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, marker))
? named : null;
}
private static string GenerateValidationClass(Compilation compilation, INamedTypeSymbol type)
{
var ns = type.ContainingNamespace.ToDisplayString();
var typeName = type.Name;
var properties = type.GetMembers().OfType()
.Where(p => p.GetAttributes().Any(a => IsDataAnnotation(a.AttributeClass)));
var sb = new System.Text.StringBuilder();
sb.AppendLine($"// ");
sb.AppendLine($"namespace {ns};");
sb.AppendLine($"public partial class {typeName} ");
sb.AppendLine($"{");
sb.AppendLine($" public static System.Collections.Generic.IEnumerable Validate({typeName} instance)");
sb.AppendLine($" {");
sb.AppendLine($" if (instance is null)");
sb.AppendLine($" yield return new System.ComponentModel.DataAnnotations.ValidationResult("Instance is null", new[] {{ nameof({typeName}) }});");
foreach (var prop in properties)
{
foreach (var attr in prop.GetAttributes().Where(a => IsDataAnnotation(a.AttributeClass)))
{
var check = BuildCheck(prop, attr);
if (check is not null) sb.AppendLine($" {check}");
}
}
sb.AppendLine($" }");
sb.AppendLine($"}");
return sb.ToString();
}
private static bool IsDataAnnotation(INamedTypeSymbol? attrType)
{
if (attrType is null) return false;
var ns = attrType.ContainingNamespace.ToDisplayString();
return ns == "System.ComponentModel.DataAnnotations" &&
(attrType.Name == "RequiredAttribute" ||
attrType.Name == "StringLengthAttribute" ||
attrType.Name == "RangeAttribute" ||
attrType.Name == "EmailAddressAttribute");
}
private static string? BuildCheck(IPropertySymbol prop, AttributeData attr)
{
var propName = prop.Name;
var memberAccess = $"instance.{propName}";
return attr.AttributeClass?.Name switch
{
"RequiredAttribute" => $"if (string.IsNullOrWhiteSpace({memberAccess})) yield return new ValidationResult(\"The {propName} field is required.\", new[] {{ nameof({propName}) }});",
"StringLengthAttribute" => BuildStringLengthCheck(prop, attr, memberAccess),
"RangeAttribute" => BuildRangeCheck(prop, attr, memberAccess),
"EmailAddressAttribute" => $"if (!string.IsNullOrWhiteSpace({memberAccess}) && !new EmailAddressAttribute().IsValid({memberAccess})) yield return new ValidationResult(\"The {propName} field is not a valid e-mail address.\", new[] {{ nameof({propName}) }});",
_ => null
};
}
private static string? BuildStringLengthCheck(IPropertySymbol prop, AttributeData attr, string memberAccess)
{
int? min = null, max = null;
foreach (var named in attr.NamedArguments)
{
if (named.Key == "MinimumLength" && named.Value.Value is int minVal) min = minVal;
if (named.Key == "MaximumLength" && named.Value.Value is int maxVal) max = maxVal;
}
if (attr.ConstructorArguments.Length >= 1 && attr.ConstructorArguments[0].Value is int maxCtor) max = maxCtor;
var parts = new List();
if (min.HasValue) parts.Add($"{memberAccess}.Length < {min.Value}");
if (max.HasValue) parts.Add($"{memberAccess}.Length > {max.Value}");
if (parts.Count == 0) return null;
var msg = $"The {prop.Name} field must be between {min ?? 0} and {max ?? int.MaxValue} characters.";
return $"if (!string.IsNullOrWhiteSpace({memberAccess}) && ({string.Join(" || ", parts)})) yield return new ValidationResult(\"{msg}\", new[] {{ nameof({prop.Name}) }});";
}
private static string? BuildRangeCheck(IPropertySymbol prop, AttributeData attr, string memberAccess)
{
if (attr.ConstructorArguments.Length < 2) return null;
var min = attr.ConstructorArguments[0].Value;
var max = attr.ConstructorArguments[1].Value;
var msg = $"The {prop.Name} field must be between {min} and {max}.";
return $"if ({memberAccess} < {min} || {memberAccess} > {max}) yield return new ValidationResult(\"{msg}\", new[] {{ nameof({prop.Name}) }});";
}
}4. Wire the generator into the DTO project
Edit DtoModels/DtoModels.csproj to reference the generator as an analyzer:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../ValidationGenerator/ValidationGenerator.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
</Project>5. Consume the DTO and generated validator
In DemoApp, reference DtoModels and call the generated method:
// DemoApp/Program.cs
using DtoModels;
using System.ComponentModel.DataAnnotations;
var valid = new UserDto { Username = "alice", Age = 30, Email = "[contact removed]" };
var invalid = new UserDto { Username = "ab", Age = 10, Email = "not-an-email" };
foreach (var dto in new[] { valid, invalid })
{
var results = UserDto.Validate(dto).ToList();
Console.WriteLine($"{(results.Count == 0 ? "VALID" : "INVALID")}: {dto.Username}");
foreach (var r in results)
Console.WriteLine($" - {r.ErrorMessage}");
}Run the demo:
dotnet run --project DemoAppExpected output (formatted):
VALID: alice
INVALID: ab
- The Username field must be between 3 and 50 characters.
- The Age field must be between 18 and 100.
- The Email field is not a valid e-mail address.6. Inspect the generated file
After a successful build, the generated source lives under the obj folder of the consuming project (DtoModels), for example:
DtoModels/obj/Debug/net8.0/generated/ValidationGenerator/ValidationGenerator/UserDto.g.csThe exact path includes the generator assembly name and generator type name. Open the file to see the emitted partial class with the Validate method — plain C# you can read and step through.
Trade-offs and limitations
- Build-time only. Changing an attribute (e.g., adjusting
MinimumLength) requires a rebuild before the new validation logic appears. IDEs may show stale diagnostics until the build finishes. - No cross-property validation. The generator only sees individual property attributes. Rules like "EndDate must be after StartDate" still need hand-written code.
- Generator maintenance. You own the generator code. If you need support for
[RegularExpression],[Compare], or custom attributes, you extend theIsDataAnnotationandBuildCheckmethods. - Nullable reference types. The example emits null checks for strings but does not fully model the nullable context. A production generator should respect
#nullablesettings. - Performance. The generated method avoids reflection, so validation is essentially a series of inlined conditionals. Microbenchmarks typically show 2–5× speedup over
Validator.TryValidateObject, but the absolute difference is microseconds per object — only measurable in high-throughput scenarios.
When to use this approach
Use a validation source generator when:
- You have many DTOs with repetitive DataAnnotations and want to eliminate the reflection overhead.
- You want validation failures to appear as compiler diagnostics (by adding
#errordirectives in the generator) or at least as fast-running unit tests. - You are comfortable maintaining a small generator project alongside your domain models.
Stick with the built-in Validator.TryValidateObject or ASP.NET Core's automatic model validation when:
- The number of DTOs is small and the reflection cost is negligible.
- You need cross-property or complex conditional validation that attributes cannot express.
- You prefer zero build-time dependencies beyond the standard SDK.
Next steps
- Clone the example structure above and run
dotnet buildto verify the generator emits the expected.g.csfile. - Add a unit test project that calls
UserDto.Validatewith valid and invalid instances and asserts the returnedValidationResultlist. - If you need broader attribute coverage, extend
IsDataAnnotationandBuildCheck— or evaluate the communityDataAnnotations.SourceGeneratorNuGet package, which covers more attributes out of the box.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.