dotnet-cqrs/docs/core-features/queries
2025-12-11 01:18:24 -05:00
..
basic-queries.md this is a mess 2025-12-11 01:18:24 -05:00
query-attributes.md this is a mess 2025-12-11 01:18:24 -05:00
query-authorization.md this is a mess 2025-12-11 01:18:24 -05:00
query-registration.md this is a mess 2025-12-11 01:18:24 -05:00
README.md this is a mess 2025-12-11 01:18:24 -05:00

Queries Overview

Queries represent read operations that retrieve data without modifying state.

What are Queries?

Queries are interrogative requests to fetch data from your system. They never change state and always return results.

Characteristics:

  • Question-based names - GetUser, SearchProducts, ListOrders
  • Read-only - Never modify state
  • Always return data - Must return a result
  • Idempotent - Can call multiple times safely
  • Cacheable - Results can be cached
  • Fast - Should be optimized for performance

Basic Query Example

// Query
public record GetUserQuery
{
    public int UserId { get; init; }
}

// DTO (result)
public record UserDto
{
    public int Id { get; init; }
    public string Name { get; init; } = string.Empty;
    public string Email { get; init; } = string.Empty;
}

// Handler
public class GetUserQueryHandler : IQueryHandler<GetUserQuery, UserDto>
{
    private readonly IUserRepository _userRepository;

    public async Task<UserDto> HandleAsync(GetUserQuery query, CancellationToken cancellationToken)
    {
        var user = await _userRepository.GetByIdAsync(query.UserId, cancellationToken);

        if (user == null)
            throw new KeyNotFoundException($"User {query.UserId} not found");

        return new UserDto
        {
            Id = user.Id,
            Name = user.Name,
            Email = user.Email
        };
    }
}

// Registration
builder.Services.AddQuery<GetUserQuery, UserDto, GetUserQueryHandler>();

// Endpoints:
//   GET /api/query/getUser?userId=1
//   POST /api/query/getUser (with JSON body)

HTTP Support

Queries automatically get both GET and POST endpoints:

GET with Query String

curl "http://localhost:5000/api/query/getUser?userId=123"

POST with JSON Body

curl -X POST http://localhost:5000/api/query/getUser \
  -H "Content-Type: application/json" \
  -d '{"userId": 123}'

Query Documentation

Basic Queries

Simple query patterns:

  • Single entity queries
  • List queries
  • Search queries
  • Projection queries

Query Registration

How to register queries:

  • Basic registration
  • Registration with validators
  • Bulk registration
  • Organizing registrations

Query Authorization

Securing queries:

  • IQueryAuthorizationService
  • Row-level security
  • Tenant isolation
  • Resource ownership

Query Attributes

Controlling query behavior:

  • [QueryName] - Custom endpoint names
  • [IgnoreQuery] - Internal queries
  • [GrpcIgnore] - HTTP-only queries

Common Query Patterns

Pattern 1: Get by ID

public record GetOrderQuery
{
    public int OrderId { get; init; }
}

public class GetOrderQueryHandler : IQueryHandler<GetOrderQuery, OrderDto>
{
    public async Task<OrderDto> HandleAsync(GetOrderQuery query, CancellationToken cancellationToken)
    {
        var order = await _orders.GetByIdAsync(query.OrderId, cancellationToken);

        if (order == null)
            throw new KeyNotFoundException($"Order {query.OrderId} not found");

        return MapToDto(order);
    }
}

Pattern 2: List with Pagination

public record ListUsersQuery
{
    public int Page { get; init; } = 1;
    public int PageSize { get; init; } = 10;
}

public class ListUsersQueryHandler : IQueryHandler<ListUsersQuery, PagedResult<UserDto>>
{
    public async Task<PagedResult<UserDto>> HandleAsync(ListUsersQuery query, CancellationToken cancellationToken)
    {
        var totalCount = await _users.CountAsync(cancellationToken);

        var users = await _users.GetAllAsync()
            .Skip((query.Page - 1) * query.PageSize)
            .Take(query.PageSize)
            .ToListAsync(cancellationToken);

        return new PagedResult<UserDto>
        {
            Items = users.Select(MapToDto).ToList(),
            TotalCount = totalCount,
            Page = query.Page,
            PageSize = query.PageSize
        };
    }
}
public record SearchProductsQuery
{
    public string Keyword { get; init; } = string.Empty;
    public decimal? MinPrice { get; init; }
    public decimal? MaxPrice { get; init; }
}

public class SearchProductsQueryHandler : IQueryHandler<SearchProductsQuery, List<ProductDto>>
{
    public async Task<List<ProductDto>> HandleAsync(SearchProductsQuery query, CancellationToken cancellationToken)
    {
        var products = _products.GetAllAsync();

        if (!string.IsNullOrWhiteSpace(query.Keyword))
        {
            products = products.Where(p =>
                p.Name.Contains(query.Keyword) ||
                p.Description.Contains(query.Keyword));
        }

        if (query.MinPrice.HasValue)
            products = products.Where(p => p.Price >= query.MinPrice.Value);

        if (query.MaxPrice.HasValue)
            products = products.Where(p => p.Price <= query.MaxPrice.Value);

        var result = await products.ToListAsync(cancellationToken);
        return result.Select(MapToDto).ToList();
    }
}

Pattern 4: Aggregation

public record GetOrderStatisticsQuery
{
    public int CustomerId { get; init; }
}

public record OrderStatistics
{
    public int TotalOrders { get; init; }
    public decimal TotalSpent { get; init; }
    public decimal AverageOrderValue { get; init; }
    public DateTime? LastOrderDate { get; init; }
}

public class GetOrderStatisticsQueryHandler : IQueryHandler<GetOrderStatisticsQuery, OrderStatistics>
{
    public async Task<OrderStatistics> HandleAsync(GetOrderStatisticsQuery query, CancellationToken cancellationToken)
    {
        var orders = await _orders
            .Where(o => o.CustomerId == query.CustomerId)
            .ToListAsync(cancellationToken);

        return new OrderStatistics
        {
            TotalOrders = orders.Count,
            TotalSpent = orders.Sum(o => o.TotalAmount),
            AverageOrderValue = orders.Any() ? orders.Average(o => o.TotalAmount) : 0,
            LastOrderDate = orders.Max(o => (DateTime?)o.CreatedAt)
        };
    }
}

Best Practices

DO

  • Always return DTOs, never domain entities
  • Keep queries simple and focused
  • Use pagination for large result sets
  • Optimize database queries (projections, indexes)
  • Handle "not found" cases
  • Use async/await consistently
  • Accept CancellationToken

DON'T

  • Don't modify state in queries
  • Don't return IQueryable (always materialize)
  • Don't include sensitive data in DTOs
  • Don't fetch unnecessary data
  • Don't skip pagination for large datasets
  • Don't return null (throw KeyNotFoundException instead)

GET vs POST

Use GET When:

  • Simple parameters (IDs, strings)
  • No sensitive data
  • Results can be cached
  • Idempotent

Use POST When:

  • Complex parameters (objects, arrays)
  • Sensitive data
  • Long query strings
  • Need request body

Good news: Both are generated automatically!

What's Next?

See Also