Skip to content

Validation Generator

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.

  1. Understanding the relationship between DataAnnotations and FluentValidation
    • Mapping rules and automatic conversion strategy between the two validation systems
  2. How to extract attributes from Primary Constructor parameters
    • Roslyn symbol analysis techniques for reading attributes from record constructor parameters
  3. Validation rule mapping and code generation
    • Per-attribute FluentValidation method mapping and nullable type handling

// 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);
}
}
// 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.


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);
// <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”
DataAnnotationsFluentValidationDescription
[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.


ValidationInfo.cs
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
}
GenerateValidatorAttribute.cs
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”
ValidationAnalyzer.cs
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;
}
ValidationCodeGenerator.cs
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.


// 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 result
public 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 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.


[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);
}
[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);
}
[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);
}

Program.cs
builder.Services.AddValidatorsFromAssemblyContaining<CreateUserRequestValidator>();
// Or use auto-registration extension method
builder.Services.AddGeneratedValidators();
ValidationBehavior.cs
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();
}
}

Here is a summary of the key design of the Validation generator.

ItemDescription
PurposeAutomatic conversion from DataAnnotations to FluentValidation
Targetrecord/class with [GenerateValidator] attribute
AnalysisPrimary Constructor parameters + property attributes
Supported attributesRequired, MaxLength, MinLength, Range, EmailAddress, Phone, Url, RegularExpression, Compare
Advanced featuresNested 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.

-> 05. Custom Generator Template