Skip to content

Query Validation

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

💡 Client-Side Validation: When using FluentValidation, validation rules are automatically extracted by the ProxyGenerator and run on the client before server calls. This provides immediate feedback to users and reduces unnecessary server requests.

Arc provides two validation filters that automatically validate query parameters:

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();
}
}

A ConceptValidator<T> describes whether a value of that concept is well formed. Because that is a property of the type rather than of the operation carrying it, the validator applies wherever the concept appears — on a command property, on a query argument, and at any depth inside either. Declare it once:

public record EmailAddress(string Value) : ConceptAs<string>(Value)
{
public static implicit operator string(EmailAddress address) => address.Value;
public static implicit operator EmailAddress(string value) => new(value);
}
public class EmailAddressValidator : ConceptValidator<EmailAddress>
{
public EmailAddressValidator() =>
RuleFor(x => x.Value).EmailAddress().WithMessage("Must be a valid email address");
}

Every query taking an EmailAddress now rejects a malformed one, with no per-query rule to write or keep in sync. The rule is also carried into the generated client, so the browser rejects it too — see Proxy Generation Validation.

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();
}
}

The validation filters operate in the query pipeline:

  1. Parameter Discovery: Filters use IQueryPerformerProviders to discover query parameters and their types
  2. Argument Coercion: Raw arguments are converted to their declared parameter types, so a validator always sees a real AccountId rather than the string it arrived as
  3. Validation: The arguments are validated — either as a whole, or one at a time (see below)
  4. Validation Results: Failed validations return a QueryResult with validation errors
  5. Pipeline Continuation: Only successful validations allow the query performer to execute

Validation walks the whole object graph of each argument, exactly as command validation walks a command’s properties. A validator is found and run for the argument itself, for anything nested inside it, and for every element of a collection — so a rule never silently fails to apply just because the value it guards sits a level down.

Whole Argument Set vs. Individual Arguments

Section titled “Whole Argument Set vs. Individual Arguments”

A query’s arguments are flat, which means there are two ways to describe rules over them.

Individual arguments is the default. Each argument is validated on its own, and a failing rule reports the argument it belongs to. A rule reported by a validator for a nested value is prefixed with the path to the argument that carried it, so it is attributable to the field the caller supplied.

Members are reported the way the client names them: camelCased, and without a concept’s inner member. A ConceptValidator<T> declares its rules against the concept’s Value, but a concept is a single value — a failure on an email argument is reported as email, not Email or email.Value. This is what lets a form match a server rejection to the field that caused it.

The whole argument set is used when you want rules that span several arguments. Declare a type whose properties mirror the query’s parameters, named {QueryName}Parameters or {ReadModelName}{QueryName}Parameters, and a QueryValidator<T> for it:

[ReadModel]
public class Accounts
{
public static IEnumerable<DebitAccount> GetByEmailAndAge(string email, int minAge, IMongoCollection<DebitAccount> collection) =>
collection.Find(a => a.Owner.Email == email && a.Owner.Age >= minAge).ToList();
}
public class GetByEmailAndAgeParameters
{
public string Email { get; set; } = string.Empty;
public int MinAge { get; set; }
}
public class GetByEmailAndAgeValidator : QueryValidator<GetByEmailAndAgeParameters>
{
public GetByEmailAndAgeValidator()
{
RuleFor(x => x.Email).NotEmpty().EmailAddress();
RuleFor(x => x.MinAge).GreaterThanOrEqualTo(0);
}
}

The parameters type is the server-side twin of the arguments object the generated proxy validates, so the same rules run in the browser and at the endpoint, reporting the same member names. Rules declared this way are enforced server-side whether or not the caller went through the proxy — a validator that only ran in the browser would be no validator at all, since the endpoint can be called directly.

A type is only accepted as the argument set when it has a property of matching name and type for every one of the query’s parameters, so an unrelated type that happens to carry the name is never picked up. Injected dependencies — the IMongoCollection<T> above, and any other service the method takes — are ignored: only the parameters the caller actually supplies count. A query that takes no arguments never resolves an argument set.

When a query has an argument set, it is validated through that alone, so a failure is never reported twice. If the type does not match, validation falls back to each argument on its own rather than failing.

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();
}

Arc 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);

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;
}
}

Arc 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");
}
}

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");
}
}

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(".");
}
}

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");
}
}

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": ""
}

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": ""
}

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();
}

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");
}
}

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);
}
}
  • 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
  • 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
  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