Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Validation

"Validation is the first line of defense. If your defenses depend on every developer remembering to build them, you don't have defenses — you have hopes."


The Domain That Hurts Most

Validation is where Convention's double cost is most visible, because every command and every query needs it. Not some commands. Not most commands. Every single one.

In a typical CQRS application with 80 commands and 40 queries, that is 120 types that must each have a corresponding validator, registered in the DI container, wired into the MediatR pipeline, and tested for existence. Miss one, and invalid data slides past the front door into your domain layer — where it causes constraint violations, corrupted state, and 2 AM pages.

This is not a theoretical concern. In every codebase that uses FluentValidation + MediatR, there is at least one command that shipped to production without a validator. The convention was documented. The convention was not followed. Nobody noticed until a customer submitted an order with a negative quantity.

Let's trace the evolution.


Era 1: Code — Manual If-Checks

In the beginning, validation was code. Scattered, inconsistent, manually written code.

// Era 1: Code — manual validation in the controller
public class OrderController
{
    private readonly OrderService _orderService;

    public OrderController(OrderService orderService)
    {
        _orderService = orderService;
    }

    public IActionResult CreateOrder(CreateOrderRequest request)
    {
        // Developer A validates like this
        if (request == null)
        {
            return BadRequest("Request cannot be null");
        }

        if (string.IsNullOrWhiteSpace(request.CustomerId))
        {
            return BadRequest("Customer ID is required");
        }

        if (request.CustomerId.Length > 50)
        {
            return BadRequest("Customer ID cannot exceed 50 characters");
        }

        if (request.ShippingAddress == null)
        {
            return BadRequest("Shipping address is required");
        }

        if (string.IsNullOrWhiteSpace(request.ShippingAddress.Street))
        {
            return BadRequest("Street is required");
        }

        if (string.IsNullOrWhiteSpace(request.ShippingAddress.City))
        {
            return BadRequest("City is required");
        }

        if (string.IsNullOrWhiteSpace(request.ShippingAddress.PostalCode))
        {
            return BadRequest("Postal code is required");
        }

        if (request.ShippingAddress.PostalCode.Length > 10)
        {
            return BadRequest("Postal code cannot exceed 10 characters");
        }

        if (request.Items == null || request.Items.Count == 0)
        {
            return BadRequest("Order must have at least one item");
        }

        foreach (var item in request.Items)
        {
            if (string.IsNullOrWhiteSpace(item.ProductId))
            {
                return BadRequest("Product ID is required for all items");
            }

            if (item.Quantity <= 0)
            {
                return BadRequest("Quantity must be greater than zero");
            }

            if (item.Quantity > 10000)
            {
                return BadRequest("Quantity cannot exceed 10,000");
            }

            if (item.UnitPrice <= 0)
            {
                return BadRequest("Unit price must be greater than zero");
            }

            if (item.UnitPrice > 999999.99m)
            {
                return BadRequest("Unit price cannot exceed 999,999.99");
            }
        }

        if (request.Notes != null && request.Notes.Length > 2000)
        {
            return BadRequest("Notes cannot exceed 2,000 characters");
        }

        if (request.RequestedDeliveryDate.HasValue
            && request.RequestedDeliveryDate.Value < DateTime.UtcNow.Date)
        {
            return BadRequest("Delivery date must be in the future");
        }

        if (request.CouponCode != null && request.CouponCode.Length > 20)
        {
            return BadRequest("Coupon code cannot exceed 20 characters");
        }

        // Validation is done. 50 lines later. Now we can actually do the work.
        var result = _orderService.CreateOrder(request);
        return Ok(result);
    }
}
// Meanwhile, Developer B validates the same kind of request differently
public class InvoiceController
{
    public IActionResult CreateInvoice(CreateInvoiceRequest request)
    {
        // Developer B throws exceptions instead of returning BadRequest
        if (request.CustomerId == null)
            throw new ArgumentNullException(nameof(request.CustomerId));

        if (request.Items.Any(i => i.Quantity < 1))
            throw new ArgumentOutOfRangeException("Quantity must be positive");

        // Developer B forgot to validate the shipping address entirely.
        // This will cause a NullReferenceException in the service layer.

        // Developer B validates the email — but Developer A didn't.
        if (!request.ContactEmail.Contains("@"))
            throw new ArgumentException("Invalid email address");

        // Developer B uses a different error handling pattern than Developer A.
        // Neither is wrong. Both are inconsistent.

        _invoiceService.CreateInvoice(request);
        return Ok();
    }
}
// Developer C doesn't validate at all
public class ShipmentController
{
    public IActionResult CreateShipment(CreateShipmentRequest request)
    {
        // "The service layer handles validation"
        // (It doesn't.)
        return Ok(_shipmentService.CreateShipment(request));
    }
}

The Problems

  • Inconsistency: Three developers, three approaches. BadRequest vs exceptions vs nothing.
  • Duplication: The same string.IsNullOrWhiteSpace check written hundreds of times.
  • Incompleteness: Developer C forgot. Developer B forgot the address. Developer A forgot the email.
  • Location: Validation is interleaved with controller logic. The controller is 80 lines long and 60 of them are if-checks.
  • Testing: To test validation, you must test the controller. Validation is not a separable concern.
  • Error format: Each controller returns errors in a different shape. The frontend must handle all of them.

Line count: ~55 lines of validation per command. 80 commands = 4,400 lines of hand-written if-checks.


Era 2: Configuration — DataAnnotations

DataAnnotations moved validation rules from code to metadata. The model carries its own constraints as attributes. The framework reads them at runtime.

// Era 2: Configuration — DataAnnotations
public class CreateOrderRequest
{
    [Required(ErrorMessage = "Customer ID is required")]
    [StringLength(50, ErrorMessage = "Customer ID cannot exceed 50 characters")]
    public string CustomerId { get; set; } = null!;

    [Required(ErrorMessage = "Shipping address is required")]
    public AddressDto ShippingAddress { get; set; } = null!;

    [Required(ErrorMessage = "Order must have at least one item")]
    [MinLength(1, ErrorMessage = "Order must have at least one item")]
    public List<OrderItemDto> Items { get; set; } = new();

    [StringLength(2000, ErrorMessage = "Notes cannot exceed 2,000 characters")]
    public string? Notes { get; set; }

    public DateTime? RequestedDeliveryDate { get; set; }

    [StringLength(20, ErrorMessage = "Coupon code cannot exceed 20 characters")]
    public string? CouponCode { get; set; }
}

public class AddressDto
{
    [Required(ErrorMessage = "Street is required")]
    [StringLength(200)]
    public string Street { get; set; } = null!;

    [Required(ErrorMessage = "City is required")]
    [StringLength(100)]
    public string City { get; set; } = null!;

    [Required(ErrorMessage = "Postal code is required")]
    [StringLength(10, ErrorMessage = "Postal code cannot exceed 10 characters")]
    public string PostalCode { get; set; } = null!;
}

public class OrderItemDto
{
    [Required(ErrorMessage = "Product ID is required")]
    public string ProductId { get; set; } = null!;

    [Range(1, 10000, ErrorMessage = "Quantity must be between 1 and 10,000")]
    public int Quantity { get; set; }

    [Range(0.01, 999999.99, ErrorMessage = "Unit price must be between 0.01 and 999,999.99")]
    public decimal UnitPrice { get; set; }
}
// The controller is clean — but validation is runtime-only
public class OrderController : ControllerBase
{
    [HttpPost]
    public IActionResult CreateOrder([FromBody] CreateOrderRequest request)
    {
        if (!ModelState.IsValid)
        {
            return BadRequest(ModelState);
        }

        // But what about "delivery date must be in the future"?
        // DataAnnotations can't express that with built-in attributes.
        // So we're back to manual code for anything beyond simple constraints.

        if (request.RequestedDeliveryDate.HasValue
            && request.RequestedDeliveryDate.Value < DateTime.UtcNow.Date)
        {
            ModelState.AddModelError(
                nameof(request.RequestedDeliveryDate),
                "Delivery date must be in the future");
            return BadRequest(ModelState);
        }

        var result = _orderService.CreateOrder(request);
        return Ok(result);
    }
}
// Custom validation attribute — for rules that don't fit the built-in set
public class FutureDateAttribute : ValidationAttribute
{
    protected override ValidationResult? IsValid(
        object? value, ValidationContext validationContext)
    {
        if (value is DateTime date && date < DateTime.UtcNow.Date)
        {
            return new ValidationResult(
                ErrorMessage ?? "Date must be in the future",
                new[] { validationContext.MemberName! });
        }

        return ValidationResult.Success;
    }
}

// Now you can use [FutureDate] on the property — but this is code, not configuration.
// You are writing validation logic inside an attribute class.
// Custom validation attributes proliferate: [FutureDate], [NotEqual], [RequiredIf],
// [MustBeTrue], [ValidJson], [StrongerPassword]...
// Each is a class. Each must be tested. Each is invisible to anyone who doesn't know it exists.

The Problems

  • Runtime-only: DataAnnotations are metadata. They do nothing at compile time. A model with [Required] on every property compiles fine even if nothing ever calls ModelState.IsValid.
  • Limited expressiveness: Simple constraints work. Business rules don't. "Delivery date must be in the future" requires a custom attribute or manual code. Cross-property validation (StartDate < EndDate) is painful.
  • No guarantee of execution: There is nothing in the type system that says "this controller action validates its input." A developer can forget if (!ModelState.IsValid) and the annotations become decoration.
  • Custom attribute sprawl: Every non-trivial rule becomes its own ValidationAttribute subclass, each with IsValid override logic. The "configuration" era quietly becomes a "code" era with extra steps.
  • No composition: You cannot express "validate this object, then validate business rules that depend on the validated state." Annotations are flat.

Progress: Error format is consistent (ModelState). Rules live on the model. Duplication reduced. Still missing: Compile-time checking. Complex rules. Guarantee of execution.


Era 3: Convention — FluentValidation + MediatR

This is where most modern .NET applications live today. FluentValidation provides an expressive, composable API for validation rules. MediatR provides a pipeline where validation runs before the handler. Assembly scanning discovers validators automatically.

It is genuinely good engineering. And it demonstrates Convention's double cost with perfect clarity.

The Validator

// Era 3: Convention — FluentValidation
public class CreateOrderCommand : IRequest<OrderResult>
{
    public required string CustomerId { get; init; }
    public required AddressDto ShippingAddress { get; init; }
    public required List<OrderItemDto> Items { get; init; }
    public string? Notes { get; init; }
    public DateTime? RequestedDeliveryDate { get; init; }
    public string? CouponCode { get; init; }
}

public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderCommandValidator()
    {
        RuleFor(x => x.CustomerId)
            .NotEmpty()
            .WithMessage("Customer ID is required")
            .MaximumLength(50)
            .WithMessage("Customer ID cannot exceed 50 characters");

        RuleFor(x => x.ShippingAddress)
            .NotNull()
            .WithMessage("Shipping address is required")
            .SetValidator(new AddressDtoValidator());

        RuleFor(x => x.Items)
            .NotEmpty()
            .WithMessage("Order must have at least one item");

        RuleForEach(x => x.Items)
            .SetValidator(new OrderItemDtoValidator());

        RuleFor(x => x.Notes)
            .MaximumLength(2000)
            .WithMessage("Notes cannot exceed 2,000 characters")
            .When(x => x.Notes is not null);

        RuleFor(x => x.RequestedDeliveryDate)
            .GreaterThanOrEqualTo(DateTime.UtcNow.Date)
            .WithMessage("Delivery date must be in the future")
            .When(x => x.RequestedDeliveryDate.HasValue);

        RuleFor(x => x.CouponCode)
            .MaximumLength(20)
            .WithMessage("Coupon code cannot exceed 20 characters")
            .When(x => x.CouponCode is not null);
    }
}

public class AddressDtoValidator : AbstractValidator<AddressDto>
{
    public AddressDtoValidator()
    {
        RuleFor(x => x.Street).NotEmpty().MaximumLength(200);
        RuleFor(x => x.City).NotEmpty().MaximumLength(100);
        RuleFor(x => x.PostalCode).NotEmpty().MaximumLength(10);
    }
}

public class OrderItemDtoValidator : AbstractValidator<OrderItemDto>
{
    public OrderItemDtoValidator()
    {
        RuleFor(x => x.ProductId).NotEmpty();
        RuleFor(x => x.Quantity).InclusiveBetween(1, 10000);
        RuleFor(x => x.UnitPrice).InclusiveBetween(0.01m, 999999.99m);
    }
}

The Pipeline

// MediatR validation behavior — runs validators before the handler
public 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())
        {
            return await next();
        }

        var context = new ValidationContext<TRequest>(request);

        var failures = (await Task.WhenAll(
                _validators.Select(v => v.ValidateAsync(context, cancellationToken))))
            .SelectMany(result => result.Errors)
            .Where(failure => failure is not null)
            .ToList();

        if (failures.Count != 0)
        {
            throw new ValidationException(failures);
        }

        return await next();
    }
}

The Registration

// DI registration — assembly scanning
builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssemblyContaining<CreateOrderCommand>());

builder.Services.AddValidatorsFromAssemblyContaining<CreateOrderCommandValidator>();

builder.Services.AddTransient(
    typeof(IPipelineBehavior<,>),
    typeof(ValidationBehavior<,>));

This is clean. This is expressive. This is the state of the art in Convention-era .NET.

And here is what it costs.

The Convention

The convention is simple to state: "Every command must have a matching validator class."

But simple to state is not simple to maintain. This convention exists in exactly one place — the wiki. And the wiki is where conventions go to die.

THE DOCUMENTATION

Here is the actual wiki page that a team using this pattern must write and maintain:

# Validation Standards

## Overview
All commands and queries are validated using FluentValidation. Validators run
automatically via the MediatR pipeline. Validation errors are returned as
structured error responses.

## Rules

### 1. Every Command Must Have a Validator
- Every class that implements `IRequest<T>` must have a corresponding
  validator class.
- Naming convention: `{CommandName}Validator`
  (e.g., `CreateOrderCommand``CreateOrderCommandValidator`).
- Validators must be in the same assembly as the command.

### 2. Validator Implementation
- Validators inherit `AbstractValidator<T>` from FluentValidation.
- Use `RuleFor()` for single-property rules.
- Use `RuleForEach()` for collection item rules.
- Use `.SetValidator()` for nested object validation.
- Use `.When()` for conditional rules (not if-statements in the constructor).

### 3. Error Messages
- Use `.WithMessage()` for user-facing error messages.
- Use `.WithErrorCode()` for machine-readable codes (prefix: `VAL-`).
- Messages must be complete sentences starting with a capital letter.
- Do not include property names in messages — the framework adds them.

### 4. Pipeline Registration
- Validators are auto-discovered by `AddValidatorsFromAssembly()`.
- The `ValidationBehavior<TRequest, TResponse>` is registered as a
  pipeline behavior. It runs validators before the handler.
- **Never validate inside the handler.** Validation belongs in the validator.

### 5. Complex Validation
- Cross-property validation: use `RuleFor(x => x).Must(...)`.
- Async validation (e.g., database uniqueness): use `MustAsync()`.
- Conditional validation: use `.When()` / `.Unless()`.
- Severity: use `.WithSeverity(Severity.Warning)` for non-blocking rules.

### 6. Testing
- Every validator must have unit tests.
- Test both valid and invalid cases.
- Test edge cases: null, empty, boundary values.
- Use `TestValidateAsync()` for async validators.

### 7. Exceptions
- Query validators are optional but recommended.
- Health check endpoints do not require validation.
- Internal commands (no user input) may skip validation with
  `[SkipValidation]` attribute and a code comment explaining why.

That is 40 lines of documentation. It covers naming conventions, implementation patterns, error message formatting, registration mechanics, testing requirements, and exceptions to the rule.

It was accurate when it was written. It is out of date within 6 months, because:

  • Someone adds a new validation pattern (conditional validators) that the wiki doesn't mention
  • The error code prefix changes from VAL- to VALID- in code but not in the wiki
  • A tech lead adds [SkipValidation] support but doesn't update the wiki's exceptions section
  • The onboarding guide links to this page, but new hires skim it because it is 40 lines of prose about validation

THE ENFORCEMENT CODE

Documentation alone does not prevent violations. So the team writes tests to enforce the convention:

// tests/ArchitectureTests/ValidationConventionTests.cs

/// <summary>
/// Convention enforcement: every Command type must have a corresponding Validator.
/// This test exists because the convention is invisible to the compiler.
/// If the compiler knew about it, this test would not need to exist.
/// </summary>
public class ValidationConventionTests
{
    private readonly Assembly _commandAssembly = typeof(CreateOrderCommand).Assembly;
    private readonly Assembly _validatorAssembly = typeof(CreateOrderCommandValidator).Assembly;

    [Fact]
    public void Every_Command_Must_Have_A_Validator()
    {
        var commandTypes = _commandAssembly
            .GetTypes()
            .Where(t => !t.IsAbstract)
            .Where(t => t.GetInterfaces().Any(i =>
                i.IsGenericType &&
                i.GetGenericTypeDefinition() == typeof(IRequest<>)))
            .Where(t => !t.GetCustomAttributes()
                .Any(a => a.GetType().Name == "SkipValidationAttribute"))
            .ToList();

        var validatorTypes = _validatorAssembly
            .GetTypes()
            .Where(t => !t.IsAbstract)
            .Where(t => t.BaseType is { IsGenericType: true } baseType &&
                baseType.GetGenericTypeDefinition() == typeof(AbstractValidator<>))
            .Select(t => t.BaseType!.GetGenericArguments()[0])
            .ToHashSet();

        var commandsWithoutValidators = commandTypes
            .Where(c => !validatorTypes.Contains(c))
            .Select(c => c.Name)
            .ToList();

        commandsWithoutValidators.Should().BeEmpty(
            "every command must have a corresponding AbstractValidator<T>. " +
            "Missing validators for: {0}",
            string.Join(", ", commandsWithoutValidators));
    }

    [Fact]
    public void All_Validators_Are_Registered_In_DI_Container()
    {
        var services = new ServiceCollection();
        services.AddValidatorsFromAssemblyContaining<CreateOrderCommandValidator>();

        var provider = services.BuildServiceProvider();

        var validatorTypes = _validatorAssembly
            .GetTypes()
            .Where(t => !t.IsAbstract)
            .Where(t => t.BaseType is { IsGenericType: true } baseType &&
                baseType.GetGenericTypeDefinition() == typeof(AbstractValidator<>))
            .ToList();

        foreach (var validatorType in validatorTypes)
        {
            var commandType = validatorType.BaseType!.GetGenericArguments()[0];
            var validatorInterfaceType = typeof(IValidator<>).MakeGenericType(commandType);

            var resolved = provider.GetService(validatorInterfaceType);

            resolved.Should().NotBeNull(
                $"Validator {validatorType.Name} must be resolvable from the DI container. " +
                "Ensure AddValidatorsFromAssembly() is called in Startup.");
        }
    }

    [Fact]
    public void MediatR_Pipeline_Includes_ValidationBehavior()
    {
        var services = new ServiceCollection();

        // Reproduce the actual DI registration
        services.AddMediatR(cfg =>
            cfg.RegisterServicesFromAssemblyContaining<CreateOrderCommand>());
        services.AddTransient(
            typeof(IPipelineBehavior<,>),
            typeof(ValidationBehavior<,>));

        var provider = services.BuildServiceProvider();

        var behaviors = provider
            .GetServices<IPipelineBehavior<CreateOrderCommand, OrderResult>>()
            .ToList();

        behaviors.Should().ContainSingle(b => b is ValidationBehavior<CreateOrderCommand, OrderResult>,
            "The MediatR pipeline must include ValidationBehavior. " +
            "Without it, validators are registered but never executed.");
    }

    [Fact]
    public void Validator_Names_Follow_Convention()
    {
        var validatorTypes = _validatorAssembly
            .GetTypes()
            .Where(t => !t.IsAbstract)
            .Where(t => t.BaseType is { IsGenericType: true } baseType &&
                baseType.GetGenericTypeDefinition() == typeof(AbstractValidator<>))
            .ToList();

        foreach (var validator in validatorTypes)
        {
            var commandType = validator.BaseType!.GetGenericArguments()[0];
            var expectedName = commandType.Name + "Validator";

            validator.Name.Should().Be(expectedName,
                $"Validator for {commandType.Name} must be named {expectedName} " +
                "(not {validator.Name}). See wiki/validation-standards.md.");
        }
    }
}

The Cost Accounting

📄 wiki/validation-standards.md                            40 lines — documentation
📄 tests/ValidationConventionTests.cs                      90 lines — enforcement code
📄 ValidationBehavior.cs                                   30 lines — pipeline infrastructure
📄 DI registration (AddValidators + AddBehavior)            5 lines — wiring
────────────────────────────────────────
Convention overhead:                                      165 lines

Plus the actual validators — roughly 30-50 lines each, 80 commands = 2,400 to 4,000 lines of validator code. The convention overhead (165 lines) applies once. The actual validator writing applies 80 times. And the convention ensures nothing about the quality of those 80 validators — only that they exist.

A validator that passes every input is still a valid validator as far as the enforcement tests are concerned.


Era 4: Contention — [Validated] Source-Generated Validators

What if the command itself declared its validation rules — and the compiler generated the validator, registered it, and refused to compile handlers that accept unvalidated input?

The Developer Writes This

// Era 4: Contention — attribute-driven validation
[Validated]
public record CreateOrderCommand : IRequest<OrderResult>
{
    public required string CustomerId { get; init; }

    [StringLength(50)]
    public required string CustomerName { get; init; }

    public required AddressDto ShippingAddress { get; init; }

    [MinCount(1)]
    public required List<OrderItemDto> Items { get; init; }

    [StringLength(2000)]
    public string? Notes { get; init; }

    [Rule("Must be a future date", nameof(IsFutureDate))]
    public DateTime? RequestedDeliveryDate { get; init; }

    [StringLength(20)]
    public string? CouponCode { get; init; }

    private static bool IsFutureDate(DateTime? date)
        => date is null || date.Value >= DateTime.UtcNow.Date;
}

[Validated]
public record AddressDto
{
    [StringLength(200)]
    public required string Street { get; init; }

    [StringLength(100)]
    public required string City { get; init; }

    [StringLength(10)]
    public required string PostalCode { get; init; }
}

[Validated]
public record OrderItemDto
{
    public required string ProductId { get; init; }

    [Range(1, 10000)]
    public required int Quantity { get; init; }

    [Range(0.01, 999999.99)]
    public required decimal UnitPrice { get; init; }
}

That is the entire validation specification. The required keyword tells the SG "this property must not be empty." The [StringLength], [Range], and [MinCount] attributes express constraints. The [Rule] attribute links to a static method for business logic.

No separate validator class. No registration code. No pipeline wiring.

The Source Generator Produces This

// ── Generated: CreateOrderCommandValidator.g.cs ──
[GeneratedCode("Cmf.Validation.Generators", "1.0.0")]
internal sealed class CreateOrderCommandValidator
    : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderCommandValidator()
    {
        // From 'required string CustomerId'
        RuleFor(x => x.CustomerId)
            .NotEmpty()
            .WithMessage("Customer ID is required")
            .WithErrorCode("VAL-CreateOrderCommand-CustomerId-Required");

        // From 'required string CustomerName' + [StringLength(50)]
        RuleFor(x => x.CustomerName)
            .NotEmpty()
            .WithMessage("Customer name is required")
            .WithErrorCode("VAL-CreateOrderCommand-CustomerName-Required")
            .MaximumLength(50)
            .WithMessage("Customer name cannot exceed 50 characters")
            .WithErrorCode("VAL-CreateOrderCommand-CustomerName-MaxLength");

        // From 'required AddressDto ShippingAddress' — recursive [Validated]
        RuleFor(x => x.ShippingAddress)
            .NotNull()
            .WithMessage("Shipping address is required")
            .WithErrorCode("VAL-CreateOrderCommand-ShippingAddress-Required")
            .SetValidator(new AddressDtoValidator());

        // From 'required List<OrderItemDto> Items' + [MinCount(1)]
        RuleFor(x => x.Items)
            .NotEmpty()
            .WithMessage("Items is required")
            .WithErrorCode("VAL-CreateOrderCommand-Items-Required");

        RuleFor(x => x.Items)
            .Must(items => items is not null && items.Count >= 1)
            .WithMessage("Items must contain at least 1 element")
            .WithErrorCode("VAL-CreateOrderCommand-Items-MinCount")
            .When(x => x.Items is not null);

        RuleForEach(x => x.Items)
            .SetValidator(new OrderItemDtoValidator());

        // From 'string? Notes' + [StringLength(2000)]
        RuleFor(x => x.Notes)
            .MaximumLength(2000)
            .WithMessage("Notes cannot exceed 2,000 characters")
            .WithErrorCode("VAL-CreateOrderCommand-Notes-MaxLength")
            .When(x => x.Notes is not null);

        // From [Rule("Must be a future date", nameof(IsFutureDate))]
        RuleFor(x => x.RequestedDeliveryDate)
            .Must(value => CreateOrderCommand.IsFutureDate(value))
            .WithMessage("Must be a future date")
            .WithErrorCode("VAL-CreateOrderCommand-RequestedDeliveryDate-Rule")
            .When(x => x.RequestedDeliveryDate is not null);

        // From 'string? CouponCode' + [StringLength(20)]
        RuleFor(x => x.CouponCode)
            .MaximumLength(20)
            .WithMessage("Coupon code cannot exceed 20 characters")
            .WithErrorCode("VAL-CreateOrderCommand-CouponCode-MaxLength")
            .When(x => x.CouponCode is not null);
    }
}
// ── Generated: AddressDtoValidator.g.cs ──
[GeneratedCode("Cmf.Validation.Generators", "1.0.0")]
internal sealed class AddressDtoValidator : AbstractValidator<AddressDto>
{
    public AddressDtoValidator()
    {
        RuleFor(x => x.Street)
            .NotEmpty()
            .WithMessage("Street is required")
            .WithErrorCode("VAL-AddressDto-Street-Required")
            .MaximumLength(200)
            .WithMessage("Street cannot exceed 200 characters")
            .WithErrorCode("VAL-AddressDto-Street-MaxLength");

        RuleFor(x => x.City)
            .NotEmpty()
            .WithMessage("City is required")
            .WithErrorCode("VAL-AddressDto-City-Required")
            .MaximumLength(100)
            .WithMessage("City cannot exceed 100 characters")
            .WithErrorCode("VAL-AddressDto-City-MaxLength");

        RuleFor(x => x.PostalCode)
            .NotEmpty()
            .WithMessage("Postal code is required")
            .WithErrorCode("VAL-AddressDto-PostalCode-Required")
            .MaximumLength(10)
            .WithMessage("Postal code cannot exceed 10 characters")
            .WithErrorCode("VAL-AddressDto-PostalCode-MaxLength");
    }
}
// ── Generated: OrderItemDtoValidator.g.cs ──
[GeneratedCode("Cmf.Validation.Generators", "1.0.0")]
internal sealed class OrderItemDtoValidator : AbstractValidator<OrderItemDto>
{
    public OrderItemDtoValidator()
    {
        RuleFor(x => x.ProductId)
            .NotEmpty()
            .WithMessage("Product ID is required")
            .WithErrorCode("VAL-OrderItemDto-ProductId-Required");

        RuleFor(x => x.Quantity)
            .InclusiveBetween(1, 10000)
            .WithMessage("Quantity must be between 1 and 10,000")
            .WithErrorCode("VAL-OrderItemDto-Quantity-Range");

        RuleFor(x => x.UnitPrice)
            .InclusiveBetween(0.01m, 999999.99m)
            .WithMessage("Unit price must be between 0.01 and 999,999.99")
            .WithErrorCode("VAL-OrderItemDto-UnitPrice-Range");
    }
}
// ── Generated: ValidationServiceRegistration.g.cs ──
[GeneratedCode("Cmf.Validation.Generators", "1.0.0")]
public static class ValidationServiceRegistration
{
    public static IServiceCollection AddGeneratedValidators(
        this IServiceCollection services)
    {
        // All [Validated] types get their generated validator registered
        services.AddScoped<IValidator<CreateOrderCommand>,
            CreateOrderCommandValidator>();
        services.AddScoped<IValidator<AddressDto>,
            AddressDtoValidator>();
        services.AddScoped<IValidator<OrderItemDto>,
            OrderItemDtoValidator>();

        // Pipeline behavior — automatic
        services.AddTransient(
            typeof(IPipelineBehavior<,>),
            typeof(ValidationBehavior<,>));

        return services;
    }
}

The Analyzer Enforces This

The SG generates the validators. The Roslyn Analyzer ensures the system is structurally sound — at compile time, in the IDE, with red squiggles.

// VAL001: Command/Query lacks [Validated]
// Severity: Error
// Trigger: Class implements IRequest<T> but has no [Validated] attribute
//          and no [SkipValidation] attribute.

error VAL001: Type 'UpdateInventoryCommand' implements IRequest<InventoryResult>
              but is not marked [Validated] or [SkipValidation].
              All request types must declare their validation strategy.
              Add [Validated] to generate a validator, or [SkipValidation]
              with a justification comment.
              (UpdateInventoryCommand.cs, line 5)
// VAL002: [Validated] class has unconstrained property
// Severity: Warning
// Trigger: A [Validated] class has a required property with no validation
//          attribute beyond the implicit NotEmpty from 'required'.

warning VAL002: Property 'CreateOrderCommand.CustomerId' is 'required string'
                with no [StringLength], [Range], or [Rule] constraint.
                The generated validator will enforce NotEmpty only.
                Consider adding a [StringLength] constraint.
                (CreateOrderCommand.cs, line 8)
// VAL003: Handler accepts unvalidated request
// Severity: Error
// Trigger: IRequestHandler<T, R> where T lacks [Validated] or [SkipValidation].

error VAL003: Handler 'UpdateInventoryCommandHandler' accepts
              'UpdateInventoryCommand' which is not validated.
              The MediatR pipeline will execute this handler
              without validation. Mark the request type [Validated]
              or [SkipValidation].
              (UpdateInventoryCommandHandler.cs, line 3)
// VAL004: Manually written validator for [Validated] type
// Severity: Warning
// Trigger: A class inherits AbstractValidator<T> where T is marked [Validated].

warning VAL004: Type 'CreateOrderCommandValidator' manually validates
                'CreateOrderCommand', which is marked [Validated].
                A validator is already source-generated.
                The manual validator will be registered alongside the
                generated one, causing duplicate validation.
                Remove the manual validator or remove [Validated].
                (CreateOrderCommandValidator.cs, line 1)

What Disappears

📄 No wiki/validation-standards.md       — [Validated] IS the standard
📄 No ValidationConventionTests.cs       — VAL001/VAL003 enforce at compile time
📄 No manual validator classes            — SG generates them from constraints
📄 No manual DI registration             — SG generates AddGeneratedValidators()
📄 No manual pipeline wiring             — SG includes it in registration
────────────────────────────────────────
Convention overhead: 0 lines
Wiki pages: 0
Enforcement tests: 0

The Convention Tax — Measured

Artifact Convention (Era 3) Contention (Era 4)
Validation rules defined on Separate *Validator class The command itself (required, [StringLength], [Rule])
Wiki / documentation 40 lines 0 — attribute IS the documentation
Enforcement tests 90 lines across 4 tests 0 — analyzer VAL001-VAL004 at compile time
Pipeline behavior class 30 lines (manual) 0 — SG generates registration
DI registration 5 lines (manual scanning) 0 — SG generates AddGeneratedValidators()
Per-command validator class 30-50 lines each, 80 commands 0 — SG generates from constraints
Error codes Manual, inconsistent Auto-generated, consistent pattern
Error messages Manual, inconsistent Auto-generated from property name + constraint
Total overhead 165 lines + 2,400-4,000 validator lines 0 lines
Catches missing validators At test time At compile time (VAL001)
Catches missing pipeline At test time At compile time (VAL003)
New command workflow Create command, create validator, run tests Create command with [Validated], add constraints. Done.

The developer's cognitive load drops from "create the command, then create the validator in a separate file following the naming convention, then make sure it's in the right assembly so scanning finds it, then check that the pipeline is wired" to "add [Validated] and put constraints on properties."


Where Validation Rules Live — and When They Execute

Diagram

Each era moves the rules closer to the thing being validated and moves enforcement earlier in the development cycle. But only Contention eliminates the gap between "rules exist" and "rules are guaranteed to execute."


The Validation Gap Analysis

What gets missed in each era — and when you find out.

Diagram

The critical insight: in the Convention era, the enforcement test checks that a validator exists but not that it validates correctly. An empty validator — public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand> { } — passes every convention test. It also passes every input, including malicious ones.

Contention inverts this. The SG generates validation rules from the constraints on the properties. If a required property exists, a NotEmpty rule is generated. If [StringLength(50)] exists, a MaximumLength(50) rule is generated. The validator cannot be empty unless the command has no constraints — and VAL002 warns about that.


The Generated Validator Pipeline

From attribute to running validation — the full path through the Contention era.

Diagram

Everything above the "Runtime" boundary happens at compile time. The developer writes the command with constraints. The SG generates the validator, the registration, and the pipeline wiring. The analyzer ensures nothing is missing. By the time the application runs, validation is structurally guaranteed.


FluentValidation Is Not the Enemy

To be clear: FluentValidation is excellent software. Its API is expressive, composable, and well-tested. The AbstractValidator<T> pattern is one of the best validation abstractions in any language.

The problem is not FluentValidation. The problem is the convention layer on top of it: the wiki that says "every command needs one," the enforcement test that checks existence, the onboarding guide that explains the naming pattern, the code review checklist item that says "did you add a validator?"

Contention does not replace FluentValidation. It generates FluentValidation validators. The generated code uses AbstractValidator<T>, RuleFor, NotEmpty, MaximumLength — all the same FluentValidation APIs. The difference is who writes the validator: the developer (Convention) or the compiler (Contention).

The SG is a FluentValidation code generator, not a FluentValidation replacement.


Escape Hatches

Not every validation rule fits into attributes. Some rules require database lookups, external service calls, or complex cross-property logic that no attribute can express.

Contention handles this with partial validators:

[Validated]
public record TransferFundsCommand : IRequest<TransferResult>
{
    public required string FromAccountId { get; init; }
    public required string ToAccountId { get; init; }

    [Range(0.01, 1_000_000)]
    public required decimal Amount { get; init; }

    [StringLength(500)]
    public string? Memo { get; init; }
}
// ── Generated: TransferFundsCommandValidator.g.cs ──
[GeneratedCode("Cmf.Validation.Generators", "1.0.0")]
internal sealed partial class TransferFundsCommandValidator
    : AbstractValidator<TransferFundsCommand>
{
    public TransferFundsCommandValidator()
    {
        RuleFor(x => x.FromAccountId)
            .NotEmpty()
            .WithErrorCode("VAL-TransferFundsCommand-FromAccountId-Required");

        RuleFor(x => x.ToAccountId)
            .NotEmpty()
            .WithErrorCode("VAL-TransferFundsCommand-ToAccountId-Required");

        RuleFor(x => x.Amount)
            .InclusiveBetween(0.01m, 1_000_000m)
            .WithErrorCode("VAL-TransferFundsCommand-Amount-Range");

        RuleFor(x => x.Memo)
            .MaximumLength(500)
            .When(x => x.Memo is not null);

        // Hook for developer-provided rules
        ConfigureCustomRules();
    }

    partial void ConfigureCustomRules();
}
// Developer writes ONLY the custom part:
internal sealed partial class TransferFundsCommandValidator
{
    partial void ConfigureCustomRules()
    {
        RuleFor(x => x)
            .Must(x => x.FromAccountId != x.ToAccountId)
            .WithMessage("Cannot transfer to the same account")
            .WithErrorCode("VAL-TransferFundsCommand-SameAccount");

        RuleFor(x => x.FromAccountId)
            .MustAsync(async (accountId, ct) =>
            {
                var account = await _accountRepository.GetByIdAsync(accountId, ct);
                return account is { IsActive: true };
            })
            .WithMessage("Source account does not exist or is inactive")
            .WithErrorCode("VAL-TransferFundsCommand-FromAccount-Active");

        RuleFor(x => x)
            .MustAsync(async (cmd, ct) =>
            {
                var balance = await _accountRepository
                    .GetBalanceAsync(cmd.FromAccountId, ct);
                return balance >= cmd.Amount;
            })
            .WithMessage("Insufficient funds")
            .WithErrorCode("VAL-TransferFundsCommand-InsufficientFunds");
    }
}

The SG generates the partial class with a partial void ConfigureCustomRules() method. If the developer doesn't implement it, the C# compiler elides the call entirely — zero overhead. If the developer implements it, their custom rules are appended to the generated rules.

This gives you the best of both worlds:

  • 80% of rules (required, string length, range, min count) are generated from attributes — zero manual code
  • 20% of rules (database lookups, cross-property, business logic) are written in the partial method — full FluentValidation power
  • The convention ("every command has a validator") is still enforced by the analyzer — even when custom rules exist

The Numbers in Practice

A real application with 80 commands, 40 queries, and an average of 6 properties per type:

Metric Convention (Era 3) Contention (Era 4)
Validator classes written 80-120 0 (generated) + ~15 partial (custom rules)
Lines of validator code 3,200-6,000 ~150 (partial methods only)
Wiki pages 1 (40 lines) 0
Enforcement tests 4 (90 lines) 0
DI registration code 5 lines 0 (generated)
Pipeline wiring 3 lines 0 (generated)
Error code consistency Manual — varies Automatic — pattern-based
Time to add validation to new command 5-15 minutes 30 seconds
Time to onboard new developer to validation patterns 1-2 hours (read wiki, study examples) 0 — [Validated] is self-explanatory
Risk of missing validator Medium — caught at test time Zero — caught at compile time

The 30-second workflow for a new command in the Contention era:

  1. Create the record with [Validated]
  2. Add required to mandatory properties
  3. Add [StringLength], [Range], [MinCount] as needed
  4. If custom rules needed, implement partial void ConfigureCustomRules()
  5. Build. The SG generates the validator. The analyzer confirms coverage.

No separate file to create. No naming convention to remember. No wiki to consult. No enforcement test to satisfy.


Cross-References

This pattern connects directly to two other posts:

  • Requirements as Code — The feature compliance system uses the same SG + Analyzer pattern to verify that requirements are implemented and tested. [FeatureImplements] is to requirements what [Validated] is to validation: one attribute that drives generation and enforcement.

  • Don't Put the Burden on Developers — The core argument: if a rule matters, encode it structurally. If "every command must have a validator" matters (and it does), don't document it in a wiki and enforce it with a test. Make the compiler generate the validator and refuse to compile without one. That is a structural fix. Everything else is discipline — and discipline does not scale.


Closing

Validation is the clearest case for Contention because the convention it replaces is both universal and fragile. Every command needs a validator. Every team documents this convention. Every team writes tests to enforce it. And every team eventually ships a command without one.

The [Validated] attribute eliminates the documentation (the attribute IS the documentation), eliminates the enforcement tests (the analyzer IS the enforcement), eliminates the boilerplate validators (the SG generates them), and eliminates the registration wiring (the SG generates that too). What remains is the validation rules themselves — expressed as constraints on the properties they protect, not as separate classes in separate files following a naming convention that someone wrote down in a wiki six months ago.

The convention "every command must have a validator" costs 165 lines of overhead plus 3,000-6,000 lines of validators. The attribute [Validated] costs one word — and the compiler does the rest.

Next: Part IV: API Contracts and Serialization — where the convention "all endpoints return Result<T> with consistent error shapes" becomes another wiki page, another enforcement test, and another source of drift. And where [TypedEndpoint] generates the endpoint, the OpenAPI spec, and the typed client in one pass.