Getting Started with Pragmatic.Validation
This guide walks you through adding validation to a Pragmatic application, from a simple DTO to a full DomainAction with async database checks.
Prerequisites
Section titled “Prerequisites”- .NET 10.0+
Pragmatic.ValidationNuGet packagePragmatic.SourceGeneratorreferenced as an analyzer
<!-- In your .csproj --><PackageReference Include="Pragmatic.Validation" /><ProjectReference Include="..\Pragmatic.SourceGenerator\...\Pragmatic.SourceGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />Step 1: Validate a Request DTO
Section titled “Step 1: Validate a Request DTO”Create a partial class (or record) and decorate properties with validation attributes:
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 source generator detects the validation attributes and generates an ISyncValidator.Validate() method on RegisterGuestRequest. The type must be partial — if it is not, you will get diagnostic PRAG0200.
Call Validate() directly
Section titled “Call Validate() directly”var request = new RegisterGuestRequest{ FirstName = "", LastName = "Doe", Email = "not-an-email"};
var result = request.Validate();
if (result.IsFailure){ foreach (var issue in result.Issues) Console.WriteLine($"{issue.PropertyPath}: {issue.MessageKey}"); // Output: // FirstName: validation.required // FirstName: validation.notwhitespace // Email: validation.email}Key points:
result.IsSuccessistruewhen there are no issues.result.IsFailureistruewhen there is at least one issue.result.Issuesis anIReadOnlyList<ValidationIssue>withMessageKey,PropertyPath, and optionalParameters.- Null values pass for all attributes except
[Required]. Combine[Required]with other attributes to check both presence and format.
Step 2: Add Cross-Property Validation
Section titled “Step 2: Add Cross-Property Validation”Use comparison attributes to validate relationships between properties:
using Pragmatic.Validation.Attributes;
public partial class CreateReservationRequest{ public Guid GuestId { get; init; } public Guid PropertyId { get; init; } public Guid RoomTypeId { 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;}This validates:
CheckInmust be in the future.CheckOutmust be afterCheckIn.NumberOfGuestsmust be between 1 and 20 (inclusive) and positive.
The SG tracks cross-property dependencies. On entities, modifying CheckIn also triggers re-validation of CheckOut.
Step 3: Add an Async Validator
Section titled “Step 3: Add an Async Validator”For checks that require database lookups or external services, implement IAsyncValidator<T>:
using Pragmatic.Validation;using Pragmatic.Validation.Attributes;using Pragmatic.Validation.Types;
[Validator]public class CreateReservationValidator : IAsyncValidator<CreateReservationRequest>{ private readonly IReadRepository<RoomType, Guid> _roomTypes; private readonly IReadRepository<Reservation, Guid> _reservations;
public CreateReservationValidator( IReadRepository<RoomType, Guid> roomTypes, IReadRepository<Reservation, Guid> reservations) { _roomTypes = roomTypes; _reservations = reservations; }
public async Task<ValidationError> ValidateAsync( CreateReservationRequest request, CancellationToken ct = default) { // Check room type exists var roomType = await _roomTypes.GetByIdAsync(request.RoomTypeId, ct) .ConfigureAwait(false);
if (roomType is null) return ValidationError.For("RoomTypeId", "validation.room_type.not_found");
// Check occupancy 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. - Register
CompositeValidator<CreateReservationRequest>asIValidator<CreateReservationRequest>.
The CompositeValidator runs sync validation first (attribute checks), then async validation (database checks).
Step 4: Register Validation Services
Section titled “Step 4: Register Validation Services”In your IStartupStep or Program.cs:
builder.Services.AddPragmaticValidation();With options:
builder.Services.AddPragmaticValidation(options =>{ options.FailFast = true; // Stop at first error (default: false)});The SG generates additional registration extension methods for discovered validators. Call them in your startup:
// Generated by SG:services.AddGeneratedValidators();Step 5: Use in a DomainAction
Section titled “Step 5: Use in a DomainAction”Add [Validate] to your DomainAction to enable automatic validation via ValidationFilter:
[DomainAction][Validate]public partial class CreateReservation : DomainAction<Reservation>{ [FutureDate] public DateTimeOffset CheckIn { get; set; }
[GreaterThanProperty(nameof(CheckIn))] public DateTimeOffset CheckOut { 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 (if it has validation attributes). - Calls
IValidator<T>.ValidateAsync()(if registered in DI). - Returns the
ValidationErroras a 400 response if validation fails.
Step 6: Inject IValidator<T> in Services
Section titled “Step 6: Inject IValidator<T> in Services”For manual validation in services (outside the action pipeline):
public class ReservationService(IValidator<CreateReservationRequest> validator){ public async Task<Result<Reservation, IError>> CreateAsync( CreateReservationRequest request, CancellationToken ct) { var validation = await validator.ValidateAsync(request, ct); if (validation.IsFailure) return validation; // ValidationError implements IError
// ... create reservation }}IValidator<T> runs both sync (attributes) and async (database) validation in the correct order.
Step 7: Write Tests
Section titled “Step 7: Write Tests”Unit Tests (Sync Validation)
Section titled “Unit Tests (Sync Validation)”using FluentAssertions;
public class RegisterGuestValidationTests{ [Fact] public void Validate_WithValidRequest_ReturnsSuccess() { var request = new RegisterGuestRequest { FirstName = "John", LastName = "Doe", Email = "john@example.com" };
var result = request.Validate();
result.IsSuccess.Should().BeTrue(); }
[Fact] public void Validate_WithEmptyEmail_ReturnsFailure() { var request = new RegisterGuestRequest { FirstName = "John", LastName = "Doe", Email = "" };
var result = request.Validate();
result.IsFailure.Should().BeTrue(); }
[Fact] public void Validate_WithMultipleErrors_ReportsAll() { var request = new RegisterGuestRequest { FirstName = "", LastName = "", Email = "" };
var result = request.Validate();
result.IsFailure.Should().BeTrue(); result.Count.Should().BeGreaterThan(1); }}Integration Tests (HTTP Endpoints)
Section titled “Integration Tests (HTTP Endpoints)”[Fact]public async Task CreateGuest_InvalidEmail_Returns400(){ var body = new { firstName = "Val", lastName = "Test", email = "not-an-email" };
var response = await PostAsync("/api/v1/guests", body);
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);}Common Patterns
Section titled “Common Patterns”Accumulating Multiple Errors
Section titled “Accumulating Multiple Errors”ValidationError is immutable. Use WithFor() to accumulate errors:
var error = ValidationError.Valid .WithFor("Email", "validation.required") .WithFor("Name", "validation.minlength", ("min", 2));
// Or combine two ValidationError instancesvar combined = errorA.Combine(errorB);Collection Validation
Section titled “Collection Validation”Validate each element in a collection by marking the element type as partial with attributes, then using [ValidateElements]:
public partial class OrderItemRequest{ [Required] public Guid ProductId { get; init; }
[Range(1, 100)] public int Quantity { get; init; }}
public partial class PlaceOrderRequest{ [Required] [NotEmpty] [ValidateElements] public List<OrderItemRequest> Items { get; init; } = [];}Errors include the index: Items[0].ProductId, Items[1].Quantity.
Conditional Validation
Section titled “Conditional Validation”Use [RequiredIf] to make fields conditionally required:
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; }}Next Steps
Section titled “Next Steps”- Attributes Reference — Complete list of all 30+ validation attributes.
- Custom Validators — Build custom sync and async validators.