Skip to content

Pragmatic.Validation

Zero-allocation, source-generated validation for .NET 10.

Validation in .NET applications scatters across layers and styles. Inline if checks bury validation inside business logic and get duplicated across actions. DataAnnotations uses reflection on every call, cannot do async validation, and handles cross-property rules awkwardly via IValidatableObject. FluentValidation is expressive but runtime-only — rules are evaluated through expression trees, and the validator class lives separately from the model it validates.

In all three approaches, validation rules are either scattered, reflection-based, or disconnected from the model. None integrates natively with a DomainAction pipeline, entity change tracking, or compile-time code generation.

Declare validation rules as attributes on the model itself. The source generator produces the exact validation code at compile time — no reflection, no runtime expression evaluation, no separate validator class to maintain.

public partial class CreateReservationRequest
{
[Required] [NotWhiteSpace]
public string GuestName { get; init; } = "";
[Required] [Email]
public string Email { get; init; } = "";
[FutureDate]
public DateTimeOffset CheckIn { get; init; }
[GreaterThanProperty(nameof(CheckIn))]
public DateTimeOffset CheckOut { get; init; }
[Positive] [Range(1, 20)]
public int NumberOfGuests { get; init; } = 1;
}

The SG generates ISyncValidator.Validate() with inline if statements. For database checks, implement IAsyncValidator<T> with [Validator] and the SG wires up DI and CompositeValidator<T> automatically. Add [Validate] on a DomainAction and the pipeline runs validation before Execute().

Validation runs at two levels in the Pragmatic stack:

  • Level 1 (Input): Validates input DTOs before any database access. Sync attributes ([Required], [Email], [Range]) run first, then async validators (IAsyncValidator<T>) for uniqueness or external checks.
  • Level 2 (Entity): Validates entity invariants after mutation application, ensuring the entity is consistent before persistence. Change-aware validation only re-validates modified properties.

The [Validate] attribute on a DomainAction triggers the full pipeline automatically via ValidationFilter.

Terminal window
dotnet add package Pragmatic.Validation

The Pragmatic.SourceGenerator analyzer must be referenced for code generation:

<ProjectReference Include="..\Pragmatic.SourceGenerator\src\Pragmatic.SourceGenerator\Pragmatic.SourceGenerator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />

1. Define a DTO with validation attributes

Section titled “1. Define a DTO with validation attributes”

Decorate properties on a partial class (or record) with validation attributes. The source generator detects them and generates ISyncValidator.Validate():

using Pragmatic.Validation.Attributes;
public partial class RegisterGuestRequest
{
[Required]
[NotWhiteSpace]
public string FirstName { get; init; } = "";
[Required]
[NotWhiteSpace]
public string LastName { get; init; } = "";
[Required]
[Email]
public string Email { get; init; } = "";
[Phone]
public string? Phone { get; init; }
}

The SG generates a Validate() method on RegisterGuestRequest that implements ISyncValidator. You can call it directly:

var request = new RegisterGuestRequest
{
FirstName = "",
LastName = "Doe",
Email = "not-an-email"
};
var result = request.Validate();
// result.IsFailure == true
// result.Issues contains:
// - FirstName: "validation.required"
// - FirstName: "validation.notwhitespace"
// - Email: "validation.email"

2. Add an async validator for database checks

Section titled “2. Add an async validator for database checks”

For validation that requires I/O (database lookups, external services), implement IAsyncValidator<T> and mark it with [Validator]:

using Pragmatic.Validation;
using Pragmatic.Validation.Attributes;
using Pragmatic.Validation.Types;
[Validator]
public class CreateReservationValidator : IAsyncValidator<CreateReservationRequest>
{
private readonly IReadRepository<RoomType, Guid> _roomTypes;
public CreateReservationValidator(IReadRepository<RoomType, Guid> roomTypes)
=> _roomTypes = roomTypes;
public async Task<ValidationError> ValidateAsync(
CreateReservationRequest request,
CancellationToken ct = default)
{
var roomType = await _roomTypes.GetByIdAsync(request.RoomTypeId, ct)
.ConfigureAwait(false);
if (roomType is null)
return ValidationError.For("RoomTypeId", "validation.room_type.not_found");
if (request.NumberOfGuests > roomType.MaxOccupancy)
return ValidationError.For("NumberOfGuests",
"validation.room_type.max_occupancy");
return ValidationError.Valid;
}
}

The SG auto-registers this validator in DI and generates a CompositeValidator<T> that runs sync validation first (from attributes), then async validation.

// In your IStartupStep or Program.cs
builder.Services.AddPragmaticValidation();
// With options:
builder.Services.AddPragmaticValidation(options =>
{
options.FailFast = true; // Stop on first error
});
[DomainAction]
[Validate]
public partial class CreateReservation : DomainAction<Reservation>
{
// ... properties with validation attributes
// ValidationFilter runs automatically before Execute()
}

The unified Pragmatic.SourceGenerator detects validation attributes on partial types and generates:

  1. ISyncValidator.Validate() — A method on the type itself that checks all attribute rules. No reflection at runtime; conditions are inlined as if statements.
  2. DI registration[Validator] classes are auto-registered as IAsyncValidator<T>. The SG also registers CompositeValidator<T> as IValidator<T> when both sync and async validation exist.
  3. IAsyncValidatorBindings<T> — For [AsyncValidate<T>] bindings, the SG generates metadata that tells CompositeValidator which async validators to invoke based on which properties changed.
  4. Body DTO validation — For [Endpoint] actions, the SG predicts which body DTOs will be generated and adds ISyncValidator to them.

When IValidator<T>.ValidateAsync() is called (via CompositeValidator<T>):

1. Sync validation (ISyncValidator.Validate)
- Runs all attribute checks (Required, Email, Range, etc.)
- Fast, in-memory, no I/O
|
v
2. If FailFast=true AND sync failed --> return errors immediately
|
v
3. Async validation (IAsyncValidator<T>.ValidateAsync)
- Database lookups, external API calls
- Filtered by IAsyncValidatorBindings<T> if available
|
v
4. Combine errors from both phases --> return ValidationError

When the validated type has [Entity], the SG generates an overload Validate(IReadOnlySet<string>? modifiedProperties):

  • When modifiedProperties is null (create mode), all properties are validated.
  • When modifiedProperties is provided (update mode), only rules for modified properties run.
  • Cross-property dependencies are tracked: if EndDate has [GreaterThanProperty(nameof(StartDate))], modifying StartDate also re-validates EndDate.

A readonly struct that implements IError (400 Bad Request) with Result-like semantics:

// Empty (valid) state
ValidationError error = ValidationError.Valid;
error.IsSuccess // true
error.IsFailure // false
// Single issue (property-first syntax)
var error = ValidationError.For("Email", "validation.required");
// Fluent accumulation
var error = ValidationError.Valid
.WithFor("Email", "validation.required")
.WithFor("Name", "validation.minlength", ("min", 2));
// Collection expression syntax
ValidationError errors = [
new ValidationIssue("validation.required", "Email"),
new ValidationIssue("validation.minlength", "Name", ("min", 3))
];
// Combine two errors
var combined = errorA.Combine(errorB);
// Nested paths (collections)
var error = ValidationError.Valid
.WithNested("Items", 0, "ProductId", "validation.required");
// PropertyPath = "Items[0].ProductId"
// Pattern matching
error.Match(
onValid: () => "All good",
onInvalid: issues => $"Failed: {issues.Count} issues");
// Convert to Result
VoidResult<ValidationError> result = error.ToResult();

A readonly record struct representing a single validation failure:

public readonly record struct ValidationIssue
{
public string MessageKey { get; } // "validation.required"
public string? PropertyPath { get; } // "Email", "Items[0].Name", null for object-level
public IReadOnlyDictionary<string, object>? Parameters { get; } // {"min": 3}
}
// Factory methods
var issue = ValidationIssue.For("Email", "validation.required");
var nested = ValidationIssue.ForNested("Items", 0, "ProductId", "validation.required");
var objectLevel = ValidationIssue.ForObject("validation.date_range_invalid");
InterfacePurpose
ISyncValidatorGenerated by SG. Has Validate() and Validate(IReadOnlySet<string>?) for change-aware validation.
IAsyncValidator<T>Manual implementation for I/O-bound validation (DB, external services).
IValidator<T>Combined sync + async. Implemented by CompositeValidator<T>. Inject this in services.
IAsyncValidatorBindings<T>Generated by SG from [AsyncValidate<T>]. Maps validators to trigger properties.

The runtime orchestrator that combines sync and async validation:

  • Runs ISyncValidator.Validate() first (if the instance implements it).
  • If FailFast is enabled and sync failed, returns immediately.
  • Runs all IAsyncValidator<T> instances (filtered by IAsyncValidatorBindings<T> when available).
  • Combines all errors and returns a single ValidationError.
  • Emits structured logging via [LoggerMessage] and OpenTelemetry metrics/traces.
builder.Services.AddPragmaticValidation(options =>
{
// Stop at the first error (default: false -- accumulate all errors)
options.FailFast = true;
// Include full property path in nested errors (default: true)
// "Items[0].ProductId" vs just "ProductId"
options.IncludePropertyPath = true;
});

All attributes are in the Pragmatic.Validation.Attributes namespace. Null values pass validation for all attributes except [Required] — use [Required] to enforce non-null.

AttributeDescriptionMessage Key
[Required]Value must not be null (or empty string by default). AllowEmptyStrings property controls empty string behavior.validation.required
[NotEmpty]String must have length > 0, collection must have count > 0.validation.notempty
[NotWhiteSpace]String must not be null, empty, or whitespace only. Stricter than [Required].validation.notwhitespace
public partial class CreateUserRequest
{
[Required]
public string Email { get; init; } = "";
[Required(AllowEmptyStrings = true)] // Allows ""
public string Name { get; init; } = "";
[Required]
[NotEmpty]
public List<string> Tags { get; init; } = [];
[NotWhiteSpace] // Rejects " "
public string? Bio { get; init; }
}
AttributeDescriptionConstructorMessage Key
[MinLength(n)]Minimum character count (or collection count).MinLength(int length)validation.minlength
[MaxLength(n)]Maximum character count (or collection count).MaxLength(int length)validation.maxlength
[Length(min, max)]Character count must be in range. Combines MinLength + MaxLength.Length(int min, int max)validation.length
public partial class CreateProductRequest
{
[MinLength(2)]
[MaxLength(100)]
public string Name { get; init; } = "";
[Length(2, 100)] // Equivalent to above
public string Description { get; init; } = "";
}
AttributeDescriptionMessage Key
[Email]Valid email format (practical regex, not RFC 5322).validation.email
[Phone]Valid phone number (digits, spaces, hyphens, optional +). 7-15 digits.validation.phone
[Url]Valid URL. AllowedSchemes defaults to ["http", "https"]. RequireAbsolute defaults to true.validation.url
[CreditCard]Valid credit card number (Luhn algorithm). 13-19 digits.validation.creditcard
[Regex(pattern)]Matches a regular expression. Compiled with 250ms timeout (ReDoS protection).validation.regex
[Guid]Valid GUID format string.validation.guid
public partial class PaymentRequest
{
[Required] [Email]
public string Email { get; init; } = "";
[Phone]
public string? Phone { get; init; }
[Required] [CreditCard]
public string CardNumber { get; init; } = "";
[Url]
public string? Website { get; init; }
[Url(AllowedSchemes = new[] { "http", "https", "ftp" })]
public string? FileUrl { get; init; }
[Regex(@"^[A-Z]{2}-\d{4}$")]
public string? ProductCode { get; init; }
[Guid]
public string? ExternalId { get; init; }
}
AttributeDescriptionConstructorMessage Key
[Range(min, max)]Value must be in range (inclusive). Supports int and double constructors.Range(int min, int max) or Range(double min, double max)validation.range
[Positive]Value must be > 0. Zero is not positive.Nonevalidation.positive
[Negative]Value must be < 0. Zero is not negative.Nonevalidation.negative
[GreaterThan(n)]Value must be > n (exclusive).GreaterThan(int value) or GreaterThan(double value)validation.greaterthan
[GreaterThanOrEqual(n)]Value must be >= n (inclusive).GreaterThanOrEqual(int value) or GreaterThanOrEqual(double value)validation.greaterthanorequal
[LessThan(n)]Value must be < n (exclusive).LessThan(int value) or LessThan(double value)validation.lessthan
[LessThanOrEqual(n)]Value must be <= n (inclusive).LessThanOrEqual(int value) or LessThanOrEqual(double value)validation.lessthanorequal

All numeric attributes support: int, long, short, byte, sbyte, uint, ulong, ushort, float, double, decimal.

public partial class CreateReservationRequest
{
[Positive]
[Range(1, 20)]
public int NumberOfGuests { get; init; } = 1;
[GreaterThan(0)]
public decimal Price { get; init; }
[LessThanOrEqual(100)]
public decimal DiscountPercent { get; init; }
}
AttributeDescriptionConstructorMessage Key
[MinCount(n)]Collection must have >= n elements.MinCount(int count)validation.mincount
[MaxCount(n)]Collection must have <= n elements.MaxCount(int count)validation.maxcount
[Count(min, max)]Element count must be in range. Combines MinCount + MaxCount.Count(int min, int max)validation.count
[ValidateElements]Validate each element in the collection. Element type must implement ISyncValidator.NoneN/A (delegates to element validation)

[ValidateElements] has a StopOnFirstError property (default false) that controls whether to stop at the first invalid element or validate all.

Error paths include the index: Items[0].ProductId, Items[1].Quantity.

public partial class PlaceOrderRequest
{
[Required]
[NotEmpty]
[MinCount(1)]
[MaxCount(50)]
[ValidateElements]
public List<OrderItemRequest> Items { get; init; } = [];
}
public partial class OrderItemRequest
{
[Required]
public Guid ProductId { get; init; }
[Range(1, 100)]
public int Quantity { get; init; }
}

These attributes compare the decorated property against another property on the same type. They require RequiresInstance = true and use PropertyAccessorCache at runtime (compiled expression delegates, no per-call reflection).

AttributeDescriptionMessage Key
[EqualTo(nameof(Other))]Value must equal the other property’s value.validation.equalto
[NotEqualTo(nameof(Other))]Value must differ from the other property’s value.validation.notequalto
[GreaterThanProperty(nameof(Other))]Value must be > the other property (uses IComparable).validation.greaterthanproperty
[LessThanProperty(nameof(Other))]Value must be < the other property (uses IComparable).validation.lessthanproperty
public partial class ChangePasswordRequest
{
[Required]
public string CurrentPassword { get; init; } = "";
[Required]
[NotEqualTo(nameof(CurrentPassword))]
public string NewPassword { get; init; } = "";
[Required]
[EqualTo(nameof(NewPassword))]
public string ConfirmPassword { get; init; } = "";
}
public partial class CreateReservationRequest
{
[FutureDate]
public DateTimeOffset CheckIn { get; init; }
[GreaterThanProperty(nameof(CheckIn))]
public DateTimeOffset CheckOut { get; init; }
}
AttributeDescriptionMessage Key
[RequiredIf(nameof(Other), value)]Required when the other property equals the expected value. AllowEmptyStrings controls empty string behavior.validation.requiredif
[RequiredIfNot(nameof(Other), value)]Required when the other property does NOT equal the specified value.validation.requiredifnot

Both support AllowMultiple = true — you can have multiple conditions on the same property.

public partial class PaymentRequest
{
[Required]
public PaymentMethod Method { get; init; }
[RequiredIf(nameof(Method), PaymentMethod.CreditCard)]
public string? CardNumber { get; init; }
[RequiredIf(nameof(Method), PaymentMethod.BankTransfer)]
public string? BankAccount { get; init; }
}
AttributeDescriptionMessage Key
[ValidEnum]Value must be a defined enum member. Prevents (Status)999.validation.enum
[OneOf("a", "b", "c")]Value must be one of the specified allowed values.validation.oneof
[FutureDate]DateTime/DateTimeOffset must be in the future (compared to UtcNow).validation.future_date
[PastDate]DateTime/DateTimeOffset must be in the past (compared to UtcNow).validation.past_date
public partial class ScheduleRequest
{
[Required]
[ValidEnum]
public Priority Priority { get; init; }
[Required]
[OneOf("draft", "published", "archived")]
public string Status { get; init; } = "";
[FutureDate]
public DateTimeOffset StartDate { get; init; }
[PastDate]
public DateTime? BirthDate { get; init; }
}

Every attribute supports a MessageKey property to override the default localization key:

[Required(MessageKey = "custom.user.name_required")]
public string Name { get; init; } = "";
[Email(MessageKey = "custom.invalid_email_format")]
public string Email { get; init; } = "";

Extend ValidationAttribute to create custom sync validation:

public sealed class FiscalCodeAttribute : ValidationAttribute
{
public override string DefaultMessageKey => "validation.fiscalcode";
public override bool IsValid(object? value)
{
if (value is not string s) return true; // null handled by [Required]
return FiscalCodeValidator.IsValid(s);
}
}

For cross-property validation, override RequiresInstance and IsValid(object?, object):

public sealed class DateRangeAttribute : ValidationAttribute
{
public string EndProperty { get; }
public DateRangeAttribute(string endProperty) => EndProperty = endProperty;
public override string DefaultMessageKey => "validation.daterange";
public override bool RequiresInstance => true;
public override bool IsValid(object? value, object instance)
{
if (value is not DateTime start) return true;
var end = PropertyAccessorCache.GetValue(instance, EndProperty);
return end is DateTime endDate && start < endDate;
}
}

Implement IAsyncValidator<T> for validation requiring I/O:

[Validator]
public class CreateUserValidator : IAsyncValidator<CreateUserRequest>
{
private readonly IReadRepository<User, Guid> _users;
public CreateUserValidator(IReadRepository<User, Guid> users)
=> _users = users;
public async Task<ValidationError> ValidateAsync(
CreateUserRequest request,
CancellationToken ct = default)
{
if (await _users.ExistsByEmailAsync(request.Email, ct))
return ValidationError.For("Email", "validation.email.exists");
return ValidationError.Valid;
}
}

The [Validator] attribute:

  • Auto-registers the class in DI as IAsyncValidator<T>.
  • Configurable Lifetime (default: ServiceLifetime.Scoped).
  • Automatically generates CompositeValidator<T> registration as IValidator<T>.

Change-Aware Async Validation with [AsyncValidate<T>]

Section titled “Change-Aware Async Validation with [AsyncValidate<T>]”

Bind async validators to specific properties so they only fire when those properties change:

// Property-level: fires only when Email changes
public partial class Reservation
{
[AsyncValidate<EmailUniquenessValidator>]
public string Email { get; private set; }
[AsyncValidate<AvailabilityValidator>]
public DateTimeOffset CheckIn { get; private set; }
}
// Entity-level: fires on any modification
[AsyncValidate<ReservationConsistencyValidator>]
public partial class Reservation { ... }

The SG generates IAsyncValidatorBindings<Reservation> that CompositeValidator<T> uses to filter which validators to invoke. In create mode (modifiedProperties = null), all bound validators run.


// Register async validator + CompositeValidator as IValidator<T>
services.AddValidatorWithComposite<CreateUserValidator, CreateUserRequest>();
// Register async validator only (without CompositeValidator)
services.AddAsyncValidator<CreateUserValidator, CreateUserRequest>();
// Register a custom IValidator<T> implementation
services.AddValidator<MyCustomValidator, MyType>();
// Register CompositeValidator for sync-only validation (no async validator)
services.AddSyncOnlyValidator<CreateUserRequest>();
// Register async validator bindings
services.AddAsyncValidatorBindings<ReservationAsyncValidatorBindings, Reservation>();
public class UserService(IValidator<CreateUserRequest> validator)
{
public async Task<Result<User, IError>> CreateAsync(
CreateUserRequest request, CancellationToken ct)
{
var validation = await validator.ValidateAsync(request, ct);
if (validation.IsFailure)
return validation; // Implicit conversion: ValidationError -> IError
// ... create user
}
}

Add [Validate] to a DomainAction to trigger ValidationFilter automatically:

[DomainAction]
[Validate]
public partial class CreateUser : DomainAction<User>
{
[Required] [Email]
public string Email { get; set; } = "";
[Required] [MinLength(2)]
public string Name { get; set; } = "";
public override async Task<Result<User, IError>> Execute(CancellationToken ct)
{
// If we get here, validation has already passed
// ...
}
}

Body DTOs generated by the Endpoints SG automatically get ISyncValidator validation. The endpoint returns HTTP 400 with the ValidationError when validation fails.

ValidationError implements IError with StatusCode = 400. You can return it directly from a Result<T, IError>:

Result<User, IError> result = ValidationError.For("Email", "validation.required");

CompositeValidator<T> emits OpenTelemetry metrics and traces:

InstrumentNameTypeDescription
Histogrampragmatic.validation.durationmsDuration of validation execution
Counterpragmatic.validation.executionscountTotal validation executions
Counterpragmatic.validation.failurescountTotal validation failures

Activity source: Pragmatic.Validation with activities named Validate.{TypeName}.

Structured logging via [LoggerMessage] covers: validation start, sync pass/fail, async validator execution/skip/fail, completion.


IDSeverityDescription
PRAG0200ErrorType with validation attributes must be partial.
PRAG0201Error[Validator] class must implement IAsyncValidator<T>.
PRAG0202WarningProperty has validation attributes but type is not partial.
PRAG0203ErrorReferenced property in comparison attribute not found on type.
PRAG0204Warning[ValidateElements] on a non-collection type.
PRAG0205ErrorCollection element type does not implement ISyncValidator.
PRAG0206Warning[Validator] class does not validate a type with ISyncValidator.
PRAG0209WarningIncompatible types in comparison attributes (e.g., int vs string).

See samples/Pragmatic.Validation.Samples/ for 8 runnable scenarios: basic validation, cross-property ([EqualTo], [RequiredIf], [GreaterThanProperty]), format/date ([Regex], [Guid], [FutureDate], [PastDate], [ValidEnum], [OneOf]), nested collection ([ValidateElements]), ValidationError API (fluent building, combining, collection expressions), async validator with DI, change-aware validation, and deep nested with custom MessageKey.

| Concepts | Architecture, validation pipeline, core types, integration with other modules. | | Getting Started | Add validation to your first action, step by step. | | Attributes Reference | Complete reference for all 30+ validation attributes. | | Custom Validators | ISyncValidator, IAsyncValidator, async bindings patterns. | | Common Mistakes | The 10 most frequent errors and how to fix them. | | Troubleshooting | Problem/solution guide with diagnostics reference and FAQ. |


ProblemSolution
Validation requires reflection at runtimeSource-generated Validate() method — zero reflection
Input validation is forgotten or inconsistent30+ declarative attributes
Database uniqueness checks need special handlingIAsyncValidator<T> for DB/API checks
Validation not integrated into action pipeline[Validate] on DomainAction triggers ValidationFilter
Error messages need localizationMessage keys follow validation.{attribute} pattern
Cross-property validation (password confirmation, date ranges)[EqualTo], [GreaterThanProperty], [RequiredIf]
Collection elements need individual validation[ValidateElements] on collection properties
Custom validation rules beyond built-in attributesExtend ValidationAttribute with IsValid()
Expensive async validators run unnecessarily on updates[AsyncValidate<T>] binds validators to trigger properties
Entity validation re-validates unchanged propertiesChange-aware Validate(IReadOnlySet<string>?) for entities
  • .NET 10.0+
  • Pragmatic.SourceGenerator analyzer

Part of the Pragmatic.Design ecosystem.