Architecture and Core Concepts
This guide explains why Pragmatic.Validation exists, how its pieces fit together, and how to choose the right approach for each validation scenario. Read this before diving into the individual feature guides.
The Problem
Section titled “The Problem”Validation in .NET applications tends to scatter across layers, styles, and libraries. Three patterns recur in most codebases, and each has serious downsides.
Inline validation: buried in business logic
Section titled “Inline validation: buried in business logic”public async Task<Result<Reservation, IError>> CreateReservation( CreateReservationRequest request, CancellationToken ct){ // Validation is tangled with business logic if (string.IsNullOrWhiteSpace(request.GuestName)) return new ValidationError("GuestName", "Name is required");
if (request.Email is null || !request.Email.Contains('@')) return new ValidationError("Email", "Invalid email format");
if (request.CheckIn < DateTimeOffset.UtcNow) return new ValidationError("CheckIn", "Must be a future date");
if (request.CheckOut <= request.CheckIn) return new ValidationError("CheckOut", "Must be after check-in");
if (request.NumberOfGuests is < 1 or > 20) return new ValidationError("NumberOfGuests", "Must be between 1 and 20");
// Only now: the actual business logic var roomType = await _roomTypes.GetByIdAsync(request.RoomTypeId, ct); if (roomType is null) return new NotFoundError("RoomType", request.RoomTypeId);
// ... create reservation}Fifteen lines of if checks before a single line of business logic. Every action that accepts input repeats this pattern. When two actions accept the same DTO, the validation is duplicated or forgotten in one of them.
DataAnnotations: reflection-heavy and limited
Section titled “DataAnnotations: reflection-heavy and limited”public class CreateReservationRequest{ [System.ComponentModel.DataAnnotations.Required] public string GuestName { get; init; } = "";
[System.ComponentModel.DataAnnotations.EmailAddress] public string Email { get; init; } = "";
[System.ComponentModel.DataAnnotations.Range(1, 20)] public int NumberOfGuests { get; init; }}
// Validation runs via reflection at runtimevar context = new ValidationContext(request);var results = new List<ValidationResult>();if (!Validator.TryValidateObject(request, context, results, true)){ // Convert results to your error format...}DataAnnotations uses reflection to discover attributes and invoke them on every validation call. Cross-property validation (e.g., “CheckOut must be after CheckIn”) requires implementing IValidatableObject, which mixes validation logic back into the model. Async validation (database uniqueness checks) is not supported at all.
FluentValidation: powerful but runtime-only
Section titled “FluentValidation: powerful but runtime-only”public class CreateReservationValidator : AbstractValidator<CreateReservationRequest>{ public CreateReservationValidator() { RuleFor(x => x.GuestName).NotEmpty(); RuleFor(x => x.Email).NotEmpty().EmailAddress(); RuleFor(x => x.CheckIn).GreaterThan(DateTimeOffset.UtcNow); RuleFor(x => x.CheckOut).GreaterThan(x => x.CheckIn); RuleFor(x => x.NumberOfGuests).InclusiveBetween(1, 20); }}Expressive, but the rules are evaluated via expression trees and reflection at runtime. The validator class is separate from the model, so you must maintain two files in sync. It adds a NuGet dependency, and integrating async validation (database calls) into the rule chain requires careful handling of MustAsync and its performance implications.
The fundamental issue: in all three approaches, validation rules are either scattered, reflection-based, or disconnected from the model they validate. None integrates natively with a DomainAction pipeline, entity change tracking, or compile-time code generation.
The Solution
Section titled “The Solution”Pragmatic.Validation inverts the model. You declare validation rules as attributes on the model itself, and the source generator produces the exact validation code at compile time — no reflection, no runtime expression evaluation, no separate validator class to maintain.
The same reservation request:
using Pragmatic.Validation.Attributes;
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 source generator reads this class at compile time and produces:
- An
ISyncValidator.Validate()method on the class with inlineifchecks — no attribute discovery, no reflection - A change-aware
Validate(IReadOnlySet<string>?)overload for entity types, with cross-property dependency tracking - DI registration for async validators (
[Validator]classes) andCompositeValidator<T>orchestration IAsyncValidatorBindings<T>for change-aware async filtering via[AsyncValidate<T>]- Body DTO validation for endpoints — the Validation SG predicts which DTOs the Endpoints SG will generate and adds
ISyncValidatorto them
The validation is on the model, visible in IntelliSense, verified by the compiler, and executed without overhead.
How It Works: The Validation Pipeline
Section titled “How It Works: The Validation Pipeline”Every validation in Pragmatic flows through a deterministic pipeline. The source generator assembles the pipeline at compile time based on the attributes and interfaces you use.
Instance to validate | v1. Sync Validation ISyncValidator.Validate() | Generated from attributes: [Required], [Email], [Range], etc. | Fast, in-memory, zero I/O | v2. FailFast Check If FailFast=true AND sync failed --> return errors | Skips expensive async validation | v3. Async Validation IAsyncValidator<T>.ValidateAsync() | Database lookups, external API calls | Filtered by IAsyncValidatorBindings<T> if available | v4. Combine Errors Merge sync + async errors --> single ValidationError | vValidationError (IsSuccess or IsFailure with Issues)The pipeline steps that apply depend on the type being validated. A DTO with only attribute validation skips straight to step 1 and returns. An entity with [AsyncValidate<T>] bindings gets the full pipeline with change-aware filtering in step 3. The SG only generates the infrastructure that is declared — there is no overhead for unused features.
Where validation runs in the stack
Section titled “Where validation runs in the stack”Validation integrates at two levels in the Pragmatic architecture:
| Level | What is validated | When | Trigger |
|---|---|---|---|
| Input (Level 1) | Request DTOs, action properties, endpoint body DTOs | Before business logic runs | [Validate] on DomainAction, endpoint pipeline, manual IValidator<T> call |
| Entity (Level 2) | Entity invariants after mutation | After property setters run, before persistence | MutationInvoker calls IValidator<T>.ValidateAsync(entity, modifiedProperties) |
Level 1 validation ensures malformed input never reaches your business logic. Level 2 validation ensures the entity is consistent before it is persisted, even when multiple mutations modify different properties.
Core Types
Section titled “Core Types”ValidationError
Section titled “ValidationError”A readonly struct that implements IError (HTTP 400) and has Result-like semantics. It is both the “error type” for the Result pattern and the container for validation issues.
// Valid (empty) state -- the starting pointValidationError error = ValidationError.Valid;error.IsSuccess // trueerror.IsFailure // falseerror.Count // 0
// Single issue -- property-first syntaxvar error = ValidationError.For("Email", "validation.required");error.IsFailure // trueerror.Count // 1
// Fluent accumulation -- immutable, returns new instance each timevar error = ValidationError.Valid .WithFor("Email", "validation.required") .WithFor("Name", "validation.minlength", ("min", 2));
// Collection expression syntaxValidationError errors = [ new ValidationIssue("validation.required", "Email"), new ValidationIssue("validation.minlength", "Name", ("min", 3))];
// Combine two errorsvar combined = errorA.Combine(errorB);
// Nested paths (for collection element validation)var error = ValidationError.Valid .WithNested("Items", 0, "ProductId", "validation.required");// PropertyPath = "Items[0].ProductId"
// Pattern matchingerror.Match( onValid: () => "All good", onInvalid: issues => $"Failed: {issues.Count} issues");
// Convert to Result for use in service methodsVoidResult<ValidationError> result = error.ToResult();
// Implicit conversion to IError for Result<T, IError>Result<User, IError> result = ValidationError.For("Email", "validation.required");Key properties:
IsSuccess/IsFailure— Result-like boolean accessorsIssues—IReadOnlyList<ValidationIssue>containing all validation problemsCount— Number of issuesStatusCode— Always 400 (Bad Request)Code— Always"VALIDATION_ERROR"
ValidationIssue
Section titled “ValidationIssue”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 methodsvar issue = ValidationIssue.For("Email", "validation.required");var nested = ValidationIssue.ForNested("Items", 0, "ProductId", "validation.required");var objectLevel = ValidationIssue.ForObject("validation.date_range_invalid");Message keys follow the validation.{attribute} convention and are resolved by Pragmatic.Internationalization at the serialization boundary. Parameters enable interpolation: "Name must be at least {min} characters".
Interfaces
Section titled “Interfaces”| Interface | Implemented by | Purpose |
|---|---|---|
ISyncValidator | SG-generated partial class | Has Validate() and Validate(IReadOnlySet<string>?). Fast, in-memory checks from attributes. |
IAsyncValidator<T> | Your [Validator] class | Async validation requiring I/O (database, external services). |
IValidator<T> | CompositeValidator<T> (SG-registered) | Combined sync + async. Inject this in services. |
IAsyncValidatorBindings<T> | SG-generated from [AsyncValidate<T>] | Maps async validators to trigger properties for change-aware filtering. |
CompositeValidator<T>
Section titled “CompositeValidator<T>”The runtime orchestrator that combines sync and async validation:
- Runs
ISyncValidator.Validate()first (if the instance implements it). - If
FailFastis enabled and sync failed, returns immediately. - Runs all
IAsyncValidator<T>instances, filtered byIAsyncValidatorBindings<T>when available. - Combines all errors and returns a single
ValidationError.
CompositeValidator<T> is auto-registered by the SG as IValidator<T> when a [Validator] class exists for the type. It emits structured logging via [LoggerMessage] and OpenTelemetry metrics and traces.
ValidationOptions
Section titled “ValidationOptions”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;});Validation Attributes
Section titled “Validation Attributes”Attributes are the primary way to declare validation rules. They live in Pragmatic.Validation.Attributes and are processed by the SG at compile time.
Design principles
Section titled “Design principles”- Null passes — All attributes except
[Required]return valid for null values. Use[Required]to enforce non-null, then combine with format/range attributes. - Composable — Stack multiple attributes on a property. The SG generates checks in declaration order.
- Localizable — Every attribute has a
DefaultMessageKey(e.g.,validation.required) and an optionalMessageKeyproperty to override it. - Zero-allocation — Generated code uses
ifstatements and direct comparisons, not attribute instances.
Attribute categories
Section titled “Attribute categories”| Category | Attributes | Typical use |
|---|---|---|
| Presence | [Required], [NotEmpty], [NotWhiteSpace] | Non-null, non-empty, non-whitespace |
| String length | [MinLength], [MaxLength], [Length] | Character count bounds |
| Format | [Email], [Phone], [Url], [CreditCard], [Regex], [Guid] | String format validation |
| Numeric | [Range], [Positive], [Negative], [GreaterThan], [LessThan], [GreaterThanOrEqual], [LessThanOrEqual] | Number bounds |
| Collection | [MinCount], [MaxCount], [Count], [ValidateElements] | Collection size and element validation |
| Comparison | [EqualTo], [NotEqualTo], [GreaterThanProperty], [LessThanProperty] | Cross-property comparison |
| Conditional | [RequiredIf], [RequiredIfNot] | Conditional presence based on another property |
| Type-specific | [ValidEnum], [OneOf], [FutureDate], [PastDate] | Enum members, allowed values, date constraints |
For the full attribute reference with parameters, message keys, and examples, see Attributes Reference.
Custom sync attributes
Section titled “Custom sync attributes”Extend ValidationAttribute to create reusable 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 custom attributes, 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; }}For the full custom validator guide, see Custom Validators.
Async Validation
Section titled “Async Validation”Sync attributes cover format and presence checks. For validation that requires I/O — database uniqueness, external API calls, file existence — implement IAsyncValidator<T> and mark the class with [Validator].
Implementing an async validator
Section titled “Implementing an async validator”[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 [Validator] attribute tells the SG to:
- Register
CreateReservationValidatorasIAsyncValidator<CreateReservationRequest>in DI (default lifetime: Scoped). - Register
CompositeValidator<CreateReservationRequest>asIValidator<CreateReservationRequest>, combining sync attributes + async checks.
Multiple validators per type
Section titled “Multiple validators per type”You can have multiple [Validator] classes for the same type. They are resolved as IEnumerable<IAsyncValidator<T>> and all run sequentially:
[Validator]public class EmailUniquenessValidator : IAsyncValidator<CreateUserRequest> { /* ... */ }
[Validator]public class DomainBlocklistValidator : IAsyncValidator<CreateUserRequest> { /* ... */ }// Both run. Errors from both are combined.Change-aware async validation with [AsyncValidate<T>]
Section titled “Change-aware async validation with [AsyncValidate<T>]”For entity validation, bind async validators to specific properties so they only fire when those properties change:
public partial class User{ [AsyncValidate<EmailUniquenessValidator>] public string Email { get; private set; } = "";
[AsyncValidate<UsernameAvailabilityValidator>] public string Username { get; private set; } = "";}- Modifying
EmailtriggersEmailUniquenessValidatorbut notUsernameAvailabilityValidator. - In create mode (
modifiedProperties = null), all bound validators fire. - Entity-level
[AsyncValidate<T>](on the class) fires on any property modification.
The SG generates IAsyncValidatorBindings<User> that CompositeValidator<User> uses to filter which validators to invoke.
Integration with Other Modules
Section titled “Integration with Other Modules”Pragmatic.Actions
Section titled “Pragmatic.Actions”Add [Validate] to a DomainAction to trigger ValidationFilter automatically before Execute():
[DomainAction][Validate]public partial class CreateReservation : DomainAction<Reservation>{ [Required] [Email] public string Email { get; set; } = "";
[Positive] [Range(1, 20)] public int NumberOfGuests { get; set; } = 1;
public override async Task<Result<Reservation, IError>> Execute(CancellationToken ct) { // Validation has already passed by the time we get here // ... }}The ValidationFilter in the action pipeline:
- Calls
ISyncValidator.Validate()on the action instance (generated from attributes). - Calls
IValidator<T>.ValidateAsync()if registered in DI (for async checks). - Returns the
ValidationErroras a 400 response if validation fails, short-circuitingExecute().
Pragmatic.Endpoints
Section titled “Pragmatic.Endpoints”Body DTOs generated by the Endpoints SG automatically get ISyncValidator validation. The Validation SG predicts which body DTOs the Endpoints SG will generate and adds sync validation attributes to them. When validation fails, the endpoint returns HTTP 400 with the ValidationError serialized as ProblemDetails.
Pragmatic.Persistence (Entity Validation)
Section titled “Pragmatic.Persistence (Entity Validation)”For [Entity] types, the SG generates a change-aware Validate(IReadOnlySet<string>? modifiedProperties) overload:
- When
modifiedPropertiesisnull(create mode), all properties are validated. - When
modifiedPropertiesis provided (update mode), only rules for modified properties run. - Cross-property dependencies are tracked: if
CheckOuthas[GreaterThanProperty(nameof(CheckIn))], modifyingCheckInalso triggers re-validation ofCheckOut.
The MutationInvoker calls IValidator<TEntity>.ValidateAsync(entity, modifiedProperties) after applying setters and before calling SaveChangesAsync.
Pragmatic.Result
Section titled “Pragmatic.Result”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");The implicit conversion from ValidationError to IError is built in.
What Gets Generated
Section titled “What Gets Generated”For each class with validation attributes, the source generator produces one or more files. The exact set depends on the type’s characteristics.
| Generated File | Content | Condition |
|---|---|---|
{Type}.Validator.g.cs | ISyncValidator implementation with Validate() method | Type has validation attributes and is partial |
{Type}AsyncValidatorBindings.g.cs | IAsyncValidatorBindings<T> implementation | Type has [AsyncValidate<T>] attributes |
_Infra.Validation.ValidatorRegistration.g.cs | AddGeneratedValidators() extension method with DI registrations | At least one [Validator] class or [AsyncValidate<T>] binding exists |
_Metadata.Validation.g.cs | [assembly: PragmaticMetadata] for cross-assembly discovery | Pragmatic.Composition is referenced |
{BodyDtoType}.Validator.g.cs | ISyncValidator for endpoint body DTOs | Endpoint has validation attributes and Pragmatic.Validation is referenced |
Example: what gets generated for a DTO
Section titled “Example: what gets generated for a DTO”Given this input:
public partial class RegisterGuestRequest{ [Required] [NotWhiteSpace] public string FirstName { get; init; } = "";
[Required] [Email] public string Email { get; init; } = "";}The SG generates RegisterGuestRequest.Validator.g.cs:
public partial class RegisterGuestRequest : ISyncValidator{ public ValidationError Validate() { var error = ValidationError.Valid;
// Validate FirstName if (string.IsNullOrEmpty(FirstName)) error = error.WithFor("FirstName", "validation.required"); else { if (string.IsNullOrWhiteSpace(FirstName)) error = error.WithFor("FirstName", "validation.notwhitespace"); }
// Validate Email if (string.IsNullOrEmpty(Email)) error = error.WithFor("Email", "validation.required"); else { if (!EmailRegex.IsMatch(Email)) error = error.WithFor("Email", "validation.email"); }
return error; }}All generated files live under obj/Debug/net10.0/generated/ and are fully visible in the IDE. You can set breakpoints in generated validation code.
DI registration generation
Section titled “DI registration generation”When a [Validator] class exists, the SG generates a ValidatorRegistrationExtensions class:
public static partial class ValidatorRegistrationExtensions{ public static IServiceCollection AddGeneratedValidators(this IServiceCollection services) { // Register async validator + CompositeValidator services.AddValidatorWithComposite<CreateReservationValidator, CreateReservationRequest>( ServiceLifetime.Scoped);
// Register async validator bindings services.AddAsyncValidatorBindings<UserAsyncValidatorBindings, User>();
return services; }}Call services.AddGeneratedValidators() in your IStartupStep to activate the registrations.
Observability
Section titled “Observability”CompositeValidator<T> emits OpenTelemetry metrics and traces:
| Instrument | Name | Type | Description |
|---|---|---|---|
| Histogram | pragmatic.validation.duration | ms | Duration of validation execution |
| Counter | pragmatic.validation.executions | count | Total validation executions |
| Counter | pragmatic.validation.failures | count | Total validation failures |
Activity source: Pragmatic.Validation with activities named Validate.{TypeName}.
Tags on activities:
pragmatic.validation.type— The type name being validatedpragmatic.validation.issue_count— Number of issues (on failure)
Structured logging via [LoggerMessage] covers: validation start, sync pass/fail, async validator execution/skip/fail, completion.
See Also
Section titled “See Also”- Getting Started — Install and validate your first DTO in 5 minutes
- Attributes Reference — Complete reference for all 30+ validation attributes
- Custom Validators — Custom sync attributes, async validators, async bindings
- Common Mistakes — The 10 most frequent errors and how to fix them
- Troubleshooting — Problem/solution guide with diagnostic reference