Table of Contents

Query Validation

Query parameters can be validated using the application model's validation infrastructure. The validation happens in the query pipeline through validation filters before query performers are executed.

Validation Filters

The application model provides two validation filters that automatically validate query parameters:

DataAnnotationValidationFilter

Automatically validates query parameters using System.ComponentModel.DataAnnotations attributes:

// Query performer method
[ReadModel]
public class Accounts
{
    public static IEnumerable<DebitAccount> SearchAccounts(
        [Required][StringLength(50)] string name,
        [Range(0, double.MaxValue)] decimal? minBalance,
        [Range(0, double.MaxValue)] decimal? maxBalance,
        IMongoCollection<DebitAccount> collection)
    {
        // Validation happens automatically in the query pipeline
        // This method only executes if validation passes
        
        var filterBuilder = Builders<DebitAccount>.Filter;
        var filters = new List<FilterDefinition<DebitAccount>>();

        filters.Add(filterBuilder.Regex(a => a.Name, new BsonRegularExpression(name, "i")));

        if (minBalance.HasValue)
            filters.Add(filterBuilder.Gte(a => a.Balance, minBalance.Value));

        if (maxBalance.HasValue)
            filters.Add(filterBuilder.Lte(a => a.Balance, maxBalance.Value));

        var combinedFilter = filterBuilder.And(filters);
        return collection.Find(combinedFilter).ToList();
    }
}

FluentValidationFilter

Automatically validates query parameters using FluentValidation validators. Create validators by inheriting from QueryValidator<T>:

// Define a concept for the query parameter type
public record AccountSearchParams(string Name, decimal? MinBalance, decimal? MaxBalance);

// Create a validator for the parameter type
public class AccountSearchParamsValidator : QueryValidator<AccountSearchParams>
{
    public AccountSearchParamsValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty()
            .MaximumLength(50)
            .WithMessage("Account name is required and must be less than 50 characters");

        RuleFor(x => x.MinBalance)
            .GreaterThanOrEqualTo(0)
            .When(x => x.MinBalance.HasValue)
            .WithMessage("Minimum balance must be greater than or equal to 0");

        RuleFor(x => x.MaxBalance)
            .GreaterThanOrEqualTo(0)
            .When(x => x.MaxBalance.HasValue)
            .WithMessage("Maximum balance must be greater than or equal to 0");

        RuleFor(x => x.MinBalance)
            .LessThanOrEqualTo(x => x.MaxBalance)
            .When(x => x.MinBalance.HasValue && x.MaxBalance.HasValue)
            .WithMessage("Minimum balance must be less than or equal to maximum balance");
    }
}

// Use the validated parameter type in query performer
[ReadModel]
public class Accounts
{
    public static IEnumerable<DebitAccount> SearchAccountsWithValidation(
        AccountSearchParams searchParams,
        IMongoCollection<DebitAccount> collection)
    {
        // FluentValidation happens automatically in the query pipeline
        var filterBuilder = Builders<DebitAccount>.Filter;
        var filters = new List<FilterDefinition<DebitAccount>>();

        if (!string.IsNullOrEmpty(searchParams.Name))
            filters.Add(filterBuilder.Regex(a => a.Name, new BsonRegularExpression(searchParams.Name, "i")));

        if (searchParams.MinBalance.HasValue)
            filters.Add(filterBuilder.Gte(a => a.Balance, searchParams.MinBalance.Value));

        if (searchParams.MaxBalance.HasValue)
            filters.Add(filterBuilder.Lte(a => a.Balance, searchParams.MaxBalance.Value));

        var combinedFilter = filters.Any() ? filterBuilder.And(filters) : filterBuilder.Empty;
        return collection.Find(combinedFilter).ToList();
    }
}

How Validation Filters Work

The validation filters operate in the query pipeline:

  1. Parameter Discovery: Filters use IQueryPerformerProviders to discover query parameters and their types
  2. Individual Parameter Validation: Each parameter value from QueryArguments is validated against its type
  3. Validation Results: Failed validations return a QueryResult with validation errors
  4. Pipeline Continuation: Only successful validations allow the query performer to execute

Automatic Model Validation

Controller-Based Query Validation

For controller-based queries (using [HttpGet] endpoints), validation works with the standard ASP.NET Core model validation:

public record AccountSearchQuery(
    [Required]
    [StringLength(50)]
    string Name,

    [Range(0, double.MaxValue)]
    decimal? MinBalance,

    [Range(0, double.MaxValue)]
    decimal? MaxBalance);

[HttpGet("search")]
public IEnumerable<DebitAccount> SearchAccounts([FromQuery] AccountSearchQuery query)
{
    // If validation fails, a 400 Bad Request is returned automatically
    // This code only executes if validation passes
    
    var filterBuilder = Builders<DebitAccount>.Filter;
    var filters = new List<FilterDefinition<DebitAccount>>();

    filters.Add(filterBuilder.Regex(a => a.Name, new BsonRegularExpression(query.Name, "i")));

    if (query.MinBalance.HasValue)
        filters.Add(filterBuilder.Gte(a => a.Balance, query.MinBalance.Value));

    if (query.MaxBalance.HasValue)
        filters.Add(filterBuilder.Lte(a => a.Balance, query.MaxBalance.Value));

    var combinedFilter = filterBuilder.And(filters);
    return _collection.Find(combinedFilter).ToList();
}

Standard Data Annotations

The application model supports all standard validation attributes:

public record ProductSearchQuery(
    [Required]
    [StringLength(100, MinimumLength = 3)]
    string Name,

    [Range(0.01, 999999.99)]
    decimal? MinPrice,

    [Range(0.01, 999999.99)]
    decimal? MaxPrice,

    [RegularExpression(@"^[A-Z]{2,4}$")]
    string? Category,

    [EmailAddress]
    string? ContactEmail,

    [Url]
    string? Website);

Custom Validators

For complex validation logic, create custom validators by inheriting from QueryValidator<T>:

public class AccountSearchQueryValidator : QueryValidator<AccountSearchQuery>
{
    public AccountSearchQueryValidator()
    {
        RuleFor(x => x.MinBalance)
            .LessThanOrEqualTo(x => x.MaxBalance)
            .When(x => x.MinBalance.HasValue && x.MaxBalance.HasValue)
            .WithMessage("Minimum balance must be less than or equal to maximum balance");

        RuleFor(x => x.Name)
            .Must(BeValidAccountName)
            .WithMessage("Account name contains invalid characters");

        RuleFor(x => x)
            .Must(HaveAtLeastOneSearchCriteria)
            .WithMessage("At least one search criteria must be provided");
    }

    bool BeValidAccountName(string name)
    {
        // Custom validation logic
        return !string.IsNullOrEmpty(name) && 
               name.All(char.IsLetterOrDigit) || 
               name.All(c => char.IsLetterOrDigit(c) || char.IsWhiteSpace(c));
    }

    bool HaveAtLeastOneSearchCriteria(AccountSearchQuery query)
    {
        return !string.IsNullOrEmpty(query.Name) ||
               query.MinBalance.HasValue ||
               query.MaxBalance.HasValue;
    }
}

FluentValidation Support

The application model uses FluentValidation internally, giving you access to powerful validation rules:

public class CustomerQueryValidator : QueryValidator<CustomerQuery>
{
    public CustomerQueryValidator()
    {
        RuleFor(x => x.Email)
            .EmailAddress()
            .When(x => !string.IsNullOrEmpty(x.Email));

        RuleFor(x => x.PhoneNumber)
            .Matches(@"^\+?[1-9]\d{1,14}$")
            .When(x => !string.IsNullOrEmpty(x.PhoneNumber))
            .WithMessage("Phone number must be in international format");

        RuleFor(x => x.Age)
            .GreaterThanOrEqualTo(0)
            .LessThanOrEqualTo(150)
            .When(x => x.Age.HasValue);

        RuleFor(x => x.Tags)
            .Must(tags => tags.Count <= 10)
            .When(x => x.Tags != null)
            .WithMessage("Maximum 10 tags allowed");
    }
}

Cross-Field Validation

Validate relationships between multiple fields:

public class DateRangeQueryValidator : QueryValidator<DateRangeQuery>
{
    public DateRangeQueryValidator()
    {
        RuleFor(x => x.StartDate)
            .LessThanOrEqualTo(x => x.EndDate)
            .When(x => x.StartDate.HasValue && x.EndDate.HasValue)
            .WithMessage("Start date must be before or equal to end date");

        RuleFor(x => x.EndDate)
            .GreaterThanOrEqualTo(DateTime.Today.AddDays(-365))
            .When(x => x.EndDate.HasValue)
            .WithMessage("End date cannot be more than one year in the past");

        RuleFor(x => x)
            .Must(x => !x.StartDate.HasValue || !x.EndDate.HasValue || 
                      (x.EndDate.Value - x.StartDate.Value).Days <= 90)
            .WithMessage("Date range cannot exceed 90 days");
    }
}

Async Validation

For validation that requires database lookups or external services:

public class AccountExistsQueryValidator : QueryValidator<AccountExistsQuery>
{
    readonly IMongoCollection<DebitAccount> _collection;

    public AccountExistsQueryValidator(IMongoCollection<DebitAccount> collection)
    {
        _collection = collection;

        RuleFor(x => x.AccountId)
            .MustAsync(AccountExists)
            .WithMessage("Account does not exist");

        RuleFor(x => x.OwnerEmail)
            .MustAsync(OwnerEmailIsValid)
            .When(x => !string.IsNullOrEmpty(x.OwnerEmail))
            .WithMessage("Owner email is not registered");
    }

    async Task<bool> AccountExists(AccountId accountId, CancellationToken cancellationToken)
    {
        var count = await _collection.CountDocumentsAsync(
            a => a.Id == accountId, 
            cancellationToken: cancellationToken);
        return count > 0;
    }

    async Task<bool> OwnerEmailIsValid(string email, CancellationToken cancellationToken)
    {
        // Call external service or database to validate email
        // This is just an example
        await Task.Delay(100, cancellationToken);
        return email.Contains("@") && email.Contains(".");
    }
}

Model-Bound Query Validation

For model-bound queries with [ReadModel], validation can be applied to the method parameters:

public record GetAccountsByOwnerQuery(
    [Required]
    CustomerId OwnerId,
    
    [Range(1, 1000)]
    int MaxResults = 100);

[ReadModel]
public class Accounts
{
    public static IEnumerable<DebitAccount> GetAccountsByOwner(
        GetAccountsByOwnerQuery query,
        IMongoCollection<DebitAccount> collection)
    {
        return collection
            .Find(a => a.Owner == query.OwnerId)
            .Limit(query.MaxResults)
            .ToList();
    }
}

// Custom validator for the query
public class GetAccountsByOwnerQueryValidator : QueryValidator<GetAccountsByOwnerQuery>
{
    public GetAccountsByOwnerQueryValidator()
    {
        RuleFor(x => x.OwnerId)
            .NotNull()
            .NotEmpty()
            .WithMessage("Owner ID is required");

        RuleFor(x => x.MaxResults)
            .GreaterThan(0)
            .LessThanOrEqualTo(1000)
            .WithMessage("Max results must be between 1 and 1000");
    }
}

Validation Error Responses

Filter-Based Validation Errors

When validation fails in the query pipeline (using validation filters), the query returns a QueryResult with detailed error information:

{
  "data": null,
  "paging": {
    "page": 0,
    "pageSize": 0,
    "totalItems": 0
  },
  "correlationId": "12345678-1234-1234-1234-123456789012",
  "isSuccess": false,
  "isAuthorized": true,
  "isValid": false,
  "hasExceptions": false,
  "validationResults": [
    {
      "severity": "Error",
      "message": "Account name is required and must be less than 50 characters",
      "members": ["name"]
    },
    {
      "severity": "Error", 
      "message": "Minimum balance must be greater than or equal to 0",
      "members": ["minBalance"]
    }
  ],
  "exceptionMessages": [],
  "exceptionStackTrace": ""
}

Controller-Based Validation Errors

When validation fails on controller-based queries, the response returns a 400 Bad Request with detailed error information:

{
  "data": null,
  "paging": {
    "page": 0,
    "pageSize": 0,
    "totalItems": 0
  },
  "correlationId": "12345678-1234-1234-1234-123456789012",
  "isSuccess": false,
  "isAuthorized": true,
  "isValid": false,
  "hasExceptions": false,
  "validationResults": [
    {
      "severity": "Error",
      "message": "Account name is required",
      "members": ["name"]
    },
    {
      "severity": "Error", 
      "message": "Minimum balance must be greater than or equal to 0",
      "members": ["minBalance"]
    }
  ],
  "exceptionMessages": [],
  "exceptionStackTrace": ""
}

Ignoring Validation

In some cases, you may want to bypass validation (useful for administrative queries):

[HttpGet("admin/all-data")]
[IgnoreValidation] // Skip validation for this endpoint
public IEnumerable<DebitAccount> GetAllDataForAdmin([FromQuery] AdminQuery query)
{
    // This will execute without validation
    return _collection.Find(_ => true).ToList();
}

Conditional Validation

Apply validation rules conditionally:

public class ConditionalQueryValidator : QueryValidator<ConditionalQuery>
{
    public ConditionalQueryValidator()
    {
        // Only validate email if contact method is email
        RuleFor(x => x.Email)
            .EmailAddress()
            .When(x => x.ContactMethod == ContactMethod.Email);

        // Only validate phone when contact method is phone
        RuleFor(x => x.PhoneNumber)
            .Matches(@"^\+?[1-9]\d{1,14}$")
            .When(x => x.ContactMethod == ContactMethod.Phone);

        // Require at least one contact method
        RuleFor(x => x)
            .Must(x => x.ContactMethod != ContactMethod.None)
            .WithMessage("A contact method must be specified");
    }
}

Complex Object Validation

Validate nested objects and collections:

public record OrderSearchQuery(
    string? CustomerName,
    DateRangeQuery? DateRange,
    List<string>? ProductCategories,
    AddressQuery? ShippingAddress);

public class OrderSearchQueryValidator : QueryValidator<OrderSearchQuery>
{
    public OrderSearchQueryValidator()
    {
        RuleFor(x => x.DateRange)
            .SetValidator(new DateRangeQueryValidator())
            .When(x => x.DateRange != null);

        RuleFor(x => x.ShippingAddress)
            .SetValidator(new AddressQueryValidator())
            .When(x => x.ShippingAddress != null);

        RuleForEach(x => x.ProductCategories)
            .NotEmpty()
            .Length(2, 50)
            .When(x => x.ProductCategories != null);
    }
}

When to Use Each Validation Approach

  • Model-bound queries: Using [ReadModel] classes with static methods
  • Query pipeline: Working with the query pipeline infrastructure
  • Parameter-level validation: Need to validate individual query parameters
  • Consistent validation: Want validation behavior consistent with commands
  • Complex parameter types: Using concepts or complex objects as parameters

Use Controller-Based Validation

  • HTTP endpoints: Creating traditional REST API endpoints with [HttpGet]
  • ASP.NET Core integration: Leveraging existing ASP.NET Core validation infrastructure
  • Simple query objects: Working with simple DTOs as query parameters
  • Web API consistency: Maintaining consistency with other ASP.NET Core controllers

Best Practices

  1. Prefer filter-based validation for model-bound queries using the query pipeline
  2. Use data annotations for simple validation rules on individual parameters
  3. Create custom validators for complex business logic and cross-parameter validation
  4. Validate early to prevent unnecessary database queries and improve performance
  5. Provide clear error messages that help users understand how to fix their input
  6. Use async validation sparingly as it can impact query performance significantly
  7. Test validation rules thoroughly with edge cases and boundary conditions
  8. Consider performance impact of validation, especially for high-frequency queries
  9. Document validation requirements clearly for API consumers
  10. Use conditional validation to avoid unnecessary validation overhead
  11. Group related validation rules logically for better maintainability
  12. Validate parameter types that are concepts or complex objects rather than individual parameters when possible