Validation Generator
Overview
Section titled “Overview”Applying DataAnnotations like [Required] and [MaxLength] to DTOs and then rewriting the same rules in FluentValidation is costly duplication. When rules change, both places must be updated simultaneously, and missing even one leads to validation behavior inconsistency. This section implements a source generator that analyzes DataAnnotations attributes to automatically generate FluentValidation Validators, establishing a Single Source of Truth for validation logic.
Learning Objectives
Section titled “Learning Objectives”Core Learning Objectives
Section titled “Core Learning Objectives”- Understanding the relationship between DataAnnotations and FluentValidation
- Mapping rules and automatic conversion strategy between the two validation systems
- How to extract attributes from Primary Constructor parameters
- Roslyn symbol analysis techniques for reading attributes from record constructor parameters
- Validation rule mapping and code generation
- Per-attribute FluentValidation method mapping and nullable type handling
Why a Validation Generator?
Section titled “Why a Validation Generator?”Problem: Duplicated Validation Code
Section titled “Problem: Duplicated Validation Code”// DTO definition (DataAnnotations)public record CreateUserRequest( [Required, MaxLength(100)] string Name, [Required, EmailAddress] string Email, [Range(1, 150)] int Age);
// FluentValidation (must be written manually)public class CreateUserRequestValidator : AbstractValidator<CreateUserRequest>{ public CreateUserRequestValidator() { // Must rewrite the same content as DataAnnotations RuleFor(x => x.Name).NotEmpty().MaximumLength(100); RuleFor(x => x.Email).NotEmpty().EmailAddress(); RuleFor(x => x.Age).InclusiveBetween(1, 150); }}Solution: Auto-Generation
Section titled “Solution: Auto-Generation”// Just define the DTO[GenerateValidator]public record CreateUserRequest( [Required, MaxLength(100)] string Name, [Required, EmailAddress] string Email, [Range(1, 150)] int Age);
// Validator is auto-generated!Now that we recognize the duplication problem, let us define the target code for the generator.
Goal: Code to Auto-Generate
Section titled “Goal: Code to Auto-Generate”using MyCompany.SourceGenerator;using System.ComponentModel.DataAnnotations;
namespace MyApp.Application.Commands;
[GenerateValidator]public record CreateUserRequest( [Required, MaxLength(100)] string Name, [Required, EmailAddress] string Email, [Range(1, 150)] int Age, [Phone] string? PhoneNumber);Generated Result
Section titled “Generated Result”// <auto-generated/>#nullable enable
using FluentValidation;
namespace MyApp.Application.Commands;
/// <summary>/// FluentValidation Validator for CreateUserRequest/// </summary>[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage( Justification = "Generated by source generator.")]public sealed class CreateUserRequestValidator : AbstractValidator<CreateUserRequest>{ public CreateUserRequestValidator() { // Name: Required, MaxLength(100) RuleFor(x => x.Name) .NotEmpty() .MaximumLength(100);
// Email: Required, EmailAddress RuleFor(x => x.Email) .NotEmpty() .EmailAddress();
// Age: Range(1, 150) RuleFor(x => x.Age) .InclusiveBetween(1, 150);
// PhoneNumber: Phone (nullable) RuleFor(x => x.PhoneNumber) .Matches(@"^[\d\-\+\(\)\s]+$") .When(x => x.PhoneNumber is not null); }}DataAnnotations to FluentValidation Mapping
Section titled “DataAnnotations to FluentValidation Mapping”Supported Attributes
Section titled “Supported Attributes”| DataAnnotations | FluentValidation | Description |
|---|---|---|
[Required] | .NotEmpty() | Required value |
[MaxLength(n)] | .MaximumLength(n) | Maximum length |
[MinLength(n)] | .MinimumLength(n) | Minimum length |
[StringLength(max, Min=min)] | .Length(min, max) | Length range |
[Range(min, max)] | .InclusiveBetween(min, max) | Numeric range |
[EmailAddress] | .EmailAddress() | Email format |
[Phone] | .Matches(phoneRegex) | Phone format |
[Url] | .Must(Uri.IsWellFormedUriString) | URL format |
[RegularExpression(pattern)] | .Matches(pattern) | Regular expression |
[Compare(otherProperty)] | .Equal(x => x.OtherProperty) | Value comparison |
With the mapping rules defined, let us implement the generator. The core difficulty of this generator is accurately extracting attributes from Primary Constructor parameters.
Implementation
Section titled “Implementation”1. Metadata Classes
Section titled “1. Metadata Classes”namespace MyCompany.SourceGenerator;
/// <summary>/// Metadata needed for Validator generation/// </summary>public sealed record ValidationInfo( string TypeName, string Namespace, IReadOnlyList<PropertyValidation> Properties);
public sealed record PropertyValidation( string PropertyName, string PropertyType, bool IsNullable, IReadOnlyList<ValidationRule> Rules);
public sealed record ValidationRule( ValidationRuleType Type, IReadOnlyDictionary<string, object?> Arguments);
public enum ValidationRuleType{ Required, MaxLength, MinLength, StringLength, Range, EmailAddress, Phone, Url, RegularExpression, Compare}2. Marker Attribute Definition
Section titled “2. Marker Attribute Definition”namespace MyCompany.SourceGenerator;
internal static class GenerateValidatorAttribute{ public const string Source = """ // <auto-generated/> #nullable enable
namespace MyCompany.SourceGenerator;
/// <summary> /// Automatically generates a FluentValidation Validator. /// Converts DataAnnotations attributes to FluentValidation rules. /// </summary> [global::System.AttributeUsage( global::System.AttributeTargets.Class | global::System.AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage( Justification = "Generated by source generator.")] public sealed class GenerateValidatorAttribute : global::System.Attribute; """;
public const string FullyQualifiedName = "MyCompany.SourceGenerator.GenerateValidatorAttribute";}3. Symbol Analysis: Extracting Attributes from Primary Constructor Parameters
Section titled “3. Symbol Analysis: Extracting Attributes from Primary Constructor Parameters”private static IReadOnlyList<PropertyValidation> ExtractPropertyValidations( INamedTypeSymbol typeSymbol){ var properties = new List<PropertyValidation>();
// Analyze Primary Constructor parameters var primaryConstructor = typeSymbol.Constructors .FirstOrDefault(c => c.Parameters.Length > 0 && c.DeclaringSyntaxReferences.Any(r => r.GetSyntax() is RecordDeclarationSyntax or ClassDeclarationSyntax { ParameterList: not null }));
if (primaryConstructor is not null) { foreach (var param in primaryConstructor.Parameters) { var rules = ExtractValidationRules(param); if (rules.Count > 0) { properties.Add(new PropertyValidation( PropertyName: ToPascalCase(param.Name), PropertyType: param.Type.ToDisplayString(), IsNullable: param.Type.NullableAnnotation == NullableAnnotation.Annotated, Rules: rules)); } } }
// Analyze regular properties foreach (var property in typeSymbol.GetMembers().OfType<IPropertySymbol>()) { // Skip if already processed via Primary Constructor if (properties.Any(p => p.PropertyName == property.Name)) continue;
var rules = ExtractValidationRules(property); if (rules.Count > 0) { properties.Add(new PropertyValidation( PropertyName: property.Name, PropertyType: property.Type.ToDisplayString(), IsNullable: property.Type.NullableAnnotation == NullableAnnotation.Annotated, Rules: rules)); } }
return properties;}
private static IReadOnlyList<ValidationRule> ExtractValidationRules(ISymbol symbol){ var rules = new List<ValidationRule>();
foreach (var attribute in symbol.GetAttributes()) { var attrName = attribute.AttributeClass?.Name;
var rule = attrName switch { "RequiredAttribute" => new ValidationRule( ValidationRuleType.Required, new Dictionary<string, object?>()),
"MaxLengthAttribute" => new ValidationRule( ValidationRuleType.MaxLength, new Dictionary<string, object?> { ["Length"] = attribute.ConstructorArguments[0].Value }),
"MinLengthAttribute" => new ValidationRule( ValidationRuleType.MinLength, new Dictionary<string, object?> { ["Length"] = attribute.ConstructorArguments[0].Value }),
"StringLengthAttribute" => new ValidationRule( ValidationRuleType.StringLength, new Dictionary<string, object?> { ["MaximumLength"] = attribute.ConstructorArguments[0].Value, ["MinimumLength"] = attribute.NamedArguments .FirstOrDefault(a => a.Key == "MinimumLength").Value.Value ?? 0 }),
"RangeAttribute" => new ValidationRule( ValidationRuleType.Range, new Dictionary<string, object?> { ["Minimum"] = attribute.ConstructorArguments[0].Value, ["Maximum"] = attribute.ConstructorArguments[1].Value }),
"EmailAddressAttribute" => new ValidationRule( ValidationRuleType.EmailAddress, new Dictionary<string, object?>()),
"PhoneAttribute" => new ValidationRule( ValidationRuleType.Phone, new Dictionary<string, object?>()),
"UrlAttribute" => new ValidationRule( ValidationRuleType.Url, new Dictionary<string, object?>()),
"RegularExpressionAttribute" => new ValidationRule( ValidationRuleType.RegularExpression, new Dictionary<string, object?> { ["Pattern"] = attribute.ConstructorArguments[0].Value }),
"CompareAttribute" => new ValidationRule( ValidationRuleType.Compare, new Dictionary<string, object?> { ["OtherProperty"] = attribute.ConstructorArguments[0].Value }),
_ => null };
if (rule is not null) rules.Add(rule); }
return rules;}4. Code Generation
Section titled “4. Code Generation”private static string GenerateValidatorSource(ValidationInfo info){ var sb = new StringBuilder();
// Header sb.AppendLine("// <auto-generated/>"); sb.AppendLine("#nullable enable"); sb.AppendLine();
// Using statements sb.AppendLine("using FluentValidation;"); sb.AppendLine();
// Namespace sb.AppendLine($"namespace {info.Namespace};"); sb.AppendLine();
// Class description sb.AppendLine($"/// <summary>"); sb.AppendLine($"/// FluentValidation Validator for {info.TypeName}"); sb.AppendLine($"/// </summary>"); sb.AppendLine("[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage("); sb.AppendLine(" Justification = \"Generated by source generator.\")]");
// Class declaration sb.AppendLine($"public sealed class {info.TypeName}Validator : AbstractValidator<{info.TypeName}>"); sb.AppendLine("{");
// Constructor sb.AppendLine($" public {info.TypeName}Validator()"); sb.AppendLine(" {");
foreach (var property in info.Properties) { GeneratePropertyRules(sb, property); }
sb.AppendLine(" }"); sb.AppendLine("}");
return sb.ToString();}
private static void GeneratePropertyRules(StringBuilder sb, PropertyValidation property){ // Comment var ruleNames = string.Join(", ", property.Rules.Select(r => r.Type.ToString())); sb.AppendLine($" // {property.PropertyName}: {ruleNames}");
// Start RuleFor sb.Append($" RuleFor(x => x.{property.PropertyName})");
foreach (var rule in property.Rules) { sb.AppendLine(); sb.Append(" "); sb.Append(GenerateRule(rule, property)); }
// Nullable handling if (property.IsNullable && !property.Rules.Any(r => r.Type == ValidationRuleType.Required)) { sb.AppendLine(); sb.Append($" .When(x => x.{property.PropertyName} is not null)"); }
sb.AppendLine(";"); sb.AppendLine();}
private static string GenerateRule(ValidationRule rule, PropertyValidation property){ return rule.Type switch { ValidationRuleType.Required => ".NotEmpty()",
ValidationRuleType.MaxLength => $".MaximumLength({rule.Arguments["Length"]})",
ValidationRuleType.MinLength => $".MinimumLength({rule.Arguments["Length"]})",
ValidationRuleType.StringLength => $".Length({rule.Arguments["MinimumLength"]}, {rule.Arguments["MaximumLength"]})",
ValidationRuleType.Range => $".InclusiveBetween({rule.Arguments["Minimum"]}, {rule.Arguments["Maximum"]})",
ValidationRuleType.EmailAddress => ".EmailAddress()",
ValidationRuleType.Phone => ".Matches(@\"^[\\d\\-\\+\\(\\)\\s]+$\").WithMessage(\"Invalid phone number format\")",
ValidationRuleType.Url => ".Must(uri => global::System.Uri.IsWellFormedUriString(uri, global::System.UriKind.Absolute)).WithMessage(\"Invalid URL format\")",
ValidationRuleType.RegularExpression => $".Matches(@\"{rule.Arguments["Pattern"]}\")",
ValidationRuleType.Compare => $".Equal(x => x.{rule.Arguments["OtherProperty"]})",
_ => "" };}Once basic validation rule generation is complete, it can be extended to advanced scenarios like nested objects and custom rules.
Advanced Features
Section titled “Advanced Features”Nested Object Validation
Section titled “Nested Object Validation”// Input[GenerateValidator]public record CreateOrderRequest( [Required] OrderId OrderId, [Required] Address ShippingAddress, // Nested object [Required] List<OrderItem> Items); // Collection
[GenerateValidator]public record Address( [Required, MaxLength(200)] string Street, [Required, MaxLength(100)] string City);
// Generated resultpublic sealed class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>{ public CreateOrderRequestValidator() { RuleFor(x => x.OrderId).NotEmpty();
// Nested object uses its own Validator RuleFor(x => x.ShippingAddress) .NotEmpty() .SetValidator(new AddressValidator());
// Collection validates each item RuleFor(x => x.Items) .NotEmpty() .ForEach(item => item.SetValidator(new OrderItemValidator())); }}Custom Validation Rules
Section titled “Custom Validation Rules”// Custom attribute definition[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)]public class FutureDateAttribute : ValidationAttribute{ public override bool IsValid(object? value) { if (value is DateTime date) return date > DateTime.Now; return false; }}
// Usage[GenerateValidator]public record CreateEventRequest( [Required] string Title, [FutureDate] DateTime EventDate);
// Generated result (custom rule converted to Must)public sealed class CreateEventRequestValidator : AbstractValidator<CreateEventRequest>{ public CreateEventRequestValidator() { RuleFor(x => x.Title).NotEmpty();
RuleFor(x => x.EventDate) .Must(date => date > global::System.DateTime.Now) .WithMessage("Event date must be in the future"); }}Snapshot tests verify the generation results for three scenarios: basic attributes, Range attributes, and nullable handling.
Basic Validation Test
Section titled “Basic Validation Test”[Fact]public Task ValidationGenerator_ShouldGenerate_BasicValidator(){ // Arrange string input = """ using MyCompany.SourceGenerator; using System.ComponentModel.DataAnnotations;
namespace TestNamespace;
[GenerateValidator] public record CreateUserRequest( [Required, MaxLength(100)] string Name, [Required, EmailAddress] string Email); """;
// Act string? actual = _sut.Generate(input);
// Assert return Verify(actual);}Range Attribute Test
Section titled “Range Attribute Test”[Fact]public Task ValidationGenerator_ShouldGenerate_RangeValidation(){ // Arrange string input = """ using MyCompany.SourceGenerator; using System.ComponentModel.DataAnnotations;
namespace TestNamespace;
[GenerateValidator] public record ProductRequest( [Required] string Name, [Range(0.01, 999999.99)] decimal Price, [Range(0, 10000)] int Stock); """;
// Act string? actual = _sut.Generate(input);
// Assert return Verify(actual);}Nullable Attribute Test
Section titled “Nullable Attribute Test”[Fact]public Task ValidationGenerator_ShouldGenerate_NullableValidation(){ // Arrange string input = """ using MyCompany.SourceGenerator; using System.ComponentModel.DataAnnotations;
namespace TestNamespace;
[GenerateValidator] public record UpdateUserRequest( [MaxLength(100)] string? Name, [EmailAddress] string? Email, [Phone] string? PhoneNumber); """;
// Act string? actual = _sut.Generate(input);
// Assert return Verify(actual);}Usage Examples
Section titled “Usage Examples”ASP.NET Core Integration
Section titled “ASP.NET Core Integration”builder.Services.AddValidatorsFromAssemblyContaining<CreateUserRequestValidator>();
// Or use auto-registration extension methodbuilder.Services.AddGeneratedValidators();Using with MediatR Pipeline
Section titled “Using with MediatR Pipeline”public sealed class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>{ private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators) { _validators = validators; }
public async Task<TResponse> Handle( TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken) { if (_validators.Any()) { var context = new ValidationContext<TRequest>(request);
var validationResults = await Task.WhenAll( _validators.Select(v => v.ValidateAsync(context, cancellationToken)));
var failures = validationResults .SelectMany(r => r.Errors) .Where(f => f != null) .ToList();
if (failures.Count != 0) throw new ValidationException(failures); }
return await next(); }}Summary at a Glance
Section titled “Summary at a Glance”Here is a summary of the key design of the Validation generator.
| Item | Description |
|---|---|
| Purpose | Automatic conversion from DataAnnotations to FluentValidation |
| Target | record/class with [GenerateValidator] attribute |
| Analysis | Primary Constructor parameters + property attributes |
| Supported attributes | Required, MaxLength, MinLength, Range, EmailAddress, Phone, Url, RegularExpression, Compare |
| Advanced features | Nested objects, collections, custom rules |
The Validation generator performs the most complex symbol analysis among the three generators covered in this chapter. The pattern of extracting both constructor arguments and named arguments from attributes and mapping them to different FluentValidation methods per type can be applied to various metaprogramming scenarios.
Q1: How are attributes on Primary Constructor parameters distinguished from attributes on properties?
Section titled “Q1: How are attributes on Primary Constructor parameters distinguished from attributes on properties?”A: C# record Primary Constructor parameters automatically generate properties, so attributes like [Required] can appear on both the parameter and property sides. The generator first extracts rules from IParameterSymbol.GetAttributes() of the Primary Constructor, then skips already-processed properties to prevent duplication.
Q2: What happens with unsupported DataAnnotations attributes (e.g., [CreditCard])?
Section titled “Q2: What happens with unsupported DataAnnotations attributes (e.g., [CreditCard])?”A: Attributes not matched in the switch expression of ExtractValidationRules() return null and are ignored. If the property has no other supported attributes, the RuleFor itself is not generated. To support a new attribute, add a case to the switch and map the FluentValidation method.
Q3: What is the criterion for automatically adding the .When(x => x.Property is not null) condition on nullable properties?
Section titled “Q3: What is the criterion for automatically adding the .When(x => x.Property is not null) condition on nullable properties?”A: The .When() condition is added when the property’s NullableAnnotation is Annotated (i.e., the ? suffix is present, like string?) and the [Required] attribute is not present. When [Required] is present, null is not allowed, so .NotEmpty() takes the place of the null check.
We have implemented three practical generators: Entity Id, ValueConverter, and Validation. The next section provides a project template for quickly starting new source generators based on this experience.