Rulegeneral
Queries Rule
Rules for CQRS queries, including structure, validation, response types, and mapping
CQRS Queries
Guidelines for implementing CQRS queries, including structure, validation, response types, and MediatR pipeline behaviors.
Implementation
- Create queries in
/[scs-name]/Core/Features/[Feature]/Queries - Create one file per query containing Query, Response, Validator (optional), and Handler:
- Name the file after the query without suffix (e.g.,
GetUsers.cs)
- Name the file after the query without suffix (e.g.,
- Query Record:
- Create a public sealed record marked with
[PublicAPI]that implementsIRequest<Result<TResponse>> - Name with
Querysuffix (e.g.,GetUsersQuery) - Define properties in the primary constructor
- Use property initializers for input normalization:
public string Email { get; } = Email?.Trim().ToLower(); - For route parameters, use
[JsonIgnore] // Removes from API contracton properties - Use default values for optional parameters (e.g.,
int PageSize = 25) - Use nullable reference types for optional parameters (e.g.,
UserRole? UserRole = null)
- Create a public sealed record marked with
- Response Record:
- Create a public sealed record marked with
[PublicAPI] - Name with
Responsesuffix (e.g.,UserResponse) - Include all necessary data for the client
- Use Strongly Typed IDs and enums
- Take special care not to include sensitive data
- Create a public sealed record marked with
- Validator (optional):
- Focus on preventing malicious input like
PageSize=1_000_000_000—the WebApp ensures meaningful input - Create a public sealed class with
Validatorsuffix (e.g.,GetUsersQueryValidator) - Each property should have one shared error message
- Only validate query properties (format, length)—use guards in the handler for complex checks
- Focus on preventing malicious input like
- Handler:
- Create a public sealed class with
Handlersuffix (e.g.,GetUsersHandler) - Implement
IRequestHandler<QueryType, Result<ResponseType>> - Use guard statements with early returns instead of throwing exceptions
- Enclose dynamic values in single quotes:
$"User with ID '{userId}' not found." - Use repositories to retrieve data—never use Entity Framework directly
- Prefer Mapster for mapping; use manual mapping for complex cases
- Never do N+1 operations—load all entities and process in memory
- Queries should rarely track TelemetryEvents
- Create a public sealed class with
- After changing the API, run
build --backendto generate the OpenAPI JSON contract, thenbuild --frontendto triggeropenapi-typescript
Note: Queries run through MediatR pipeline behaviors in this order: Validation → Query → PublishTelemetryEvents.
Examples
[PublicAPI] // ✅ DO: Mark public API with [PublicAPI] and suffix with Query
public sealed record GetUsersQuery(string? Search = null, UserRole? UserRole = null, int PageOffset = 0, int PageSize = 25)
: IRequest<Result<UsersResponse>>
{
public string? Search { get; } = Search?.Trim().ToLower(); // ✅ DO: Sanitize input
}
[PublicAPI] // ✅ DO: Mark public API with [PublicAPI] and suffix with Response
public sealed record UsersResponse(int TotalCount, int PageSize, UserDetails[] Users);
[PublicAPI]
public sealed record UserDetails(UserId Id, string Email, UserRole Role);
public sealed class GetUsersQueryValidator : AbstractValidator<GetUsersQuery>
{
public GetUsersQueryValidator()
{
// ✅ DO: Validate input
RuleFor(x => x.Search).MaximumLength(100).WithMessage("The search term must be at most 100 characters.");
}
}
public sealed class GetUsersHandler(IUserRepository userRepository)
: IRequestHandler<GetUsersQuery, Result<UsersResponse>>
{
public async Task<Result<UsersResponse>> Handle(GetUsersQuery query, CancellationToken cancellationToken)
{
if (query.PageOffset >= totalPages)
{
// ✅ DO: Return Result<T> instead of throwing exceptions and enclose values in single quotes
return Result<UsersResponse>.BadRequest($"The page offset '{query.PageOffset}' is greater than the total number of pages.");
}
var (users, count) = await userRepository.Search(query.Search, query.UserRole, query.PageOffset, query.PageSize, cancellationToken);
var userResponses = users.Adapt<UserDetails[]>(); // ✅ DO: Use Mapster for simple cases
return new UsersResponse(count, query.PageSize, userResponses);
}
}
[PublicAPI] // ❌ No Query suffix, using class instead of record
public sealed class BadUsers : IRequest<Result<BadUsersDto>>
{
public bool UpdateLastAccessed { get; init; } = true; // ❌ Queries must not mutate state
}
// ❌ Using DTO suffix, missing [PublicAPI] attribute
public sealed record BadUsersDto(UserId Id, string Email, UserRole Role);
public sealed class BadUsersHandler(IUserRepository userRepository)
: IRequestHandler<BadUsers, Result<BadUsersDto>>
{
public async Task<Result<BadUsersDto>> Handle(BadUsers query, CancellationToken cancellationToken)
{
var user = await userRepository.GetByIdAsync(query.UserId, cancellationToken);
if (user == null)
{
throw new NotFoundException($"User with ID {query.UserId} not found"); // ❌ Throws exception, wrong message format
}
if (someCondition)
{
return Result<BadUsersDto>.NotFound( // ❌ DON'T: Split Result returns across multiple lines if it fits on one line
$"User with ID {query.UserId} not found" // ❌ Missing single quotes around dynamic value and trailing period
);
}
return new BadUsersDto(user.Id, user.Email, user.Role); // ❌ Manual mapping when Mapster can be used
}
}