Queries - PogovorovDaniil/Requestum GitHub Wiki

Queries

Queries are the foundation of data retrieval operations in Requestum. This guide covers everything you need to know about working with queries.

📋 What is a Query?

A query is a request that retrieves data from the system. Queries:

  • Represent questions or data requests
  • Do not modify system state (read operations)
  • Return typed responses
  • Have exactly one handler
  • Use descriptive naming (questions)

🎯 Creating Queries

Basic Query

// Query
public record GetUserByIdQuery(int UserId) : IQuery<UserDto>;

// Response
public record UserDto
{
    public int Id { get; init; }
    public required string Name { get; init; }
    public required string Email { get; init; }
}

Query with Multiple Parameters

public record SearchUsersQuery : IQuery<List<UserDto>>
{
    public required string SearchTerm { get; init; }
    public int PageNumber { get; init; } = 1;
    public int PageSize { get; init; } = 10;
    public bool IncludeInactive { get; init; } = false;
}

Query without Parameters

public record GetAllActiveUsersQuery : IQuery<List<UserDto>>;

Complex Query with Filtering

public record GetOrdersQuery : IQuery<PagedResult<OrderDto>>
{
    public int? CustomerId { get; init; }
    public DateTime? FromDate { get; init; }
    public DateTime? ToDate { get; init; }
    public OrderStatus? Status { get; init; }
    public int PageNumber { get; init; } = 1;
    public int PageSize { get; init; } = 20;
    public string? SortBy { get; init; }
    public bool SortDescending { get; init; } = false;
}

public record PagedResult<T>
{
    public List<T> Items { get; init; } = new();
    public int TotalCount { get; init; }
    public int PageNumber { get; init; }
    public int PageSize { get; init; }
    public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
    public bool HasPreviousPage => PageNumber > 1;
    public bool HasNextPage => PageNumber < TotalPages;
}

🔧 Creating Query Handlers

Synchronous Handler

Use when the operation is purely synchronous (in-memory, cache):

public class CalculateTotalQueryHandler : IQueryHandler<CalculateTotalQuery, decimal>
{
    public decimal Handle(CalculateTotalQuery query)
    {
        // Pure calculation, no I/O
        return query.Items.Sum(item => item.Price * item.Quantity);
    }
}

Asynchronous Handler

Use when the operation involves I/O (database, network, files):

public class GetUserByIdQueryHandler : IAsyncQueryHandler<GetUserByIdQuery, UserDto>
{
    private readonly IUserRepository _repository;
    private readonly ILogger<GetUserByIdQueryHandler> _logger;
    
    public GetUserByIdQueryHandler(
        IUserRepository repository,
        ILogger<GetUserByIdQueryHandler> logger)
    {
        _repository = repository;
        _logger = logger;
    }
    
    public async Task<UserDto> HandleAsync(
        GetUserByIdQuery query, 
        CancellationToken ct = default)
    {
        _logger.LogInformation("Fetching user with ID: {UserId}", query.UserId);
        
        var user = await _repository.GetByIdAsync(query.UserId, ct);
 
        if (user == null)
        {
            throw new NotFoundException($"User with ID {query.UserId} not found");
        }
        
        return new UserDto
        {
            Id = user.Id,
            Name = user.Name,
            Email = user.Email
        };
    }
}

🚀 Executing Queries

Execute with Instance

var query = new GetUserByIdQuery(UserId: 123);

// Synchronous execution
var user = requestum.Handle<GetUserByIdQuery, UserDto>(query);

// Asynchronous execution
var userAsync = await requestum.HandleAsync<GetUserByIdQuery, UserDto>(query);

// With cancellation token
var userWithToken = await requestum.HandleAsync<GetUserByIdQuery, UserDto>(query, cancellationToken);

Execute without Instance

For queries with parameterless constructors:

// Synchronous
var users = requestum.Handle<GetAllUsersQuery, List<UserDto>>();

// Asynchronous
var usersAsync = await requestum.HandleAsync<GetAllUsersQuery, List<UserDto>>();

Execute in ASP.NET Core

[ApiController]
[Route("api/users")]
public class UsersController : ControllerBase
{
    private readonly IRequestum _requestum;
    
    public UsersController(IRequestum requestum) => _requestum = requestum;
  
    [HttpGet("{id}")]
    public async Task<ActionResult<UserDto>> GetUser(int id, CancellationToken ct)
    {
        var query = new GetUserByIdQuery(id);
        var user = await _requestum.HandleAsync<GetUserByIdQuery, UserDto>(query, ct);

        return Ok(user);
    }
    
    [HttpGet]
    public async Task<ActionResult<PagedResult<UserDto>>> SearchUsers(
        [FromQuery] string? searchTerm,
        [FromQuery] int pageNumber = 1,
        [FromQuery] int pageSize = 10,
        CancellationToken ct = default)
    {
        var query = new SearchUsersQuery
        {
            SearchTerm = searchTerm ?? "",
            PageNumber = pageNumber,
            PageSize = pageSize
        };
        
        var result = await _requestum.HandleAsync<SearchUsersQuery, PagedResult<UserDto>>(query, ct);
        
        return Ok(result);
    }
}

🎨 Query Patterns

Simple Data Retrieval

public record GetUserByEmailQuery(string Email) : IQuery<UserDto?>;

public class GetUserByEmailQueryHandler : IAsyncQueryHandler<GetUserByEmailQuery, UserDto?>
{
    private readonly IUserRepository _repository;
    
    public async Task<UserDto?> HandleAsync(GetUserByEmailQuery query, CancellationToken ct = default)
    {
        var user = await _repository.FindByEmailAsync(query.Email, ct);
        
        if (user == null)
            return null;
   
        return new UserDto
        {
            Id = user.Id,
            Name = user.Name,
            Email = user.Email
        };
    }
}

Pagination

public record GetUsersPagedQuery : IQuery<PagedResult<UserDto>>
{
    public int PageNumber { get; init; } = 1;
    public int PageSize { get; init; } = 10;
}

public class GetUsersPagedQueryHandler : IAsyncQueryHandler<GetUsersPagedQuery, PagedResult<UserDto>>
{
    private readonly IUserRepository _repository;
    
    public async Task<PagedResult<UserDto>> HandleAsync(
        GetUsersPagedQuery query, 
        CancellationToken ct = default)
    {
        var totalCount = await _repository.CountAsync(ct);
        var users = await _repository.GetPagedAsync(
            query.PageNumber, 
            query.PageSize, 
            ct);
      
        var userDtos = users.Select(u => new UserDto
        {
            Id = u.Id,
            Name = u.Name,
            Email = u.Email
        }).ToList();
        
        return new PagedResult<UserDto>
        {
            Items = userDtos,
            TotalCount = totalCount,
            PageNumber = query.PageNumber,
            PageSize = query.PageSize
        };
    }
}

Search with Filtering

public record SearchProductsQuery : IQuery<List<ProductDto>>
{
 public string? SearchTerm { get; init; }
    public decimal? MinPrice { get; init; }
    public decimal? MaxPrice { get; init; }
    public int? CategoryId { get; init; }
    public bool InStockOnly { get; init; }
}

public class SearchProductsQueryHandler : IAsyncQueryHandler<SearchProductsQuery, List<ProductDto>>
{
    private readonly IProductRepository _repository;
    
    public async Task<List<ProductDto>> HandleAsync(
        SearchProductsQuery query, 
        CancellationToken ct = default)
    {
        var products = await _repository.SearchAsync(
            searchTerm: query.SearchTerm,
            minPrice: query.MinPrice,
            maxPrice: query.MaxPrice,
            categoryId: query.CategoryId,
            inStockOnly: query.InStockOnly,
            ct);
        
        return products.Select(p => new ProductDto
        {
            Id = p.Id,
            Name = p.Name,
            Price = p.Price,
            Stock = p.Stock
        }).ToList();
    }
}

Aggregation

public record GetOrderStatisticsQuery : IQuery<OrderStatisticsDto>
{
    public DateTime FromDate { get; init; }
    public DateTime ToDate { get; init; }
}

public record OrderStatisticsDto
{
    public int TotalOrders { get; init; }
    public decimal TotalRevenue { get; init; }
    public decimal AverageOrderValue { get; init; }
    public int UniqueCustomers { get; init; }
}

public class GetOrderStatisticsQueryHandler : IAsyncQueryHandler<GetOrderStatisticsQuery, OrderStatisticsDto>
{
    private readonly IOrderRepository _repository;
    
    public async Task<OrderStatisticsDto> HandleAsync(
        GetOrderStatisticsQuery query, 
        CancellationToken ct = default)
    {
        var orders = await _repository.GetByDateRangeAsync(
            query.FromDate, 
            query.ToDate, 
            ct);
   
        return new OrderStatisticsDto
        {
            TotalOrders = orders.Count,
            TotalRevenue = orders.Sum(o => o.Total),
            AverageOrderValue = orders.Any() ? orders.Average(o => o.Total) : 0,
            UniqueCustomers = orders.Select(o => o.CustomerId).Distinct().Count()
        };
    }
}

Join Multiple Data Sources

public record GetOrderDetailsQuery(int OrderId) : IQuery<OrderDetailsDto>;

public record OrderDetailsDto
{
    public int OrderId { get; init; }
    public CustomerDto Customer { get; init; } = null!;
    public List<OrderItemDto> Items { get; init; } = new();
    public decimal Total { get; init; }
    public OrderStatus Status { get; init; }
}

public class GetOrderDetailsQueryHandler : IAsyncQueryHandler<GetOrderDetailsQuery, OrderDetailsDto>
{
    private readonly IOrderRepository _orderRepository;
    private readonly ICustomerRepository _customerRepository;
    private readonly IProductRepository _productRepository;
    
    public GetOrderDetailsQueryHandler(
        IOrderRepository orderRepository,
        ICustomerRepository customerRepository,
        IProductRepository productRepository)
    {
        _orderRepository = orderRepository;
        _customerRepository = customerRepository;
        _productRepository = productRepository;
    }
    
    public async Task<OrderDetailsDto> HandleAsync(
        GetOrderDetailsQuery query, 
        CancellationToken ct = default)
    {
        var order = await _orderRepository.GetByIdAsync(query.OrderId, ct)
            ?? throw new NotFoundException($"Order {query.OrderId} not found");
     
        var customer = await _customerRepository.GetByIdAsync(order.CustomerId, ct)
            ?? throw new NotFoundException($"Customer {order.CustomerId} not found");
        
        var orderItems = new List<OrderItemDto>();
        foreach (var item in order.Items)
        {
            var product = await _productRepository.GetByIdAsync(item.ProductId, ct);
            orderItems.Add(new OrderItemDto
            {
                ProductId = item.ProductId,
                ProductName = product?.Name ?? "Unknown",
                Quantity = item.Quantity,
                Price = item.Price
            });
        }
        
        return new OrderDetailsDto
        {
            OrderId = order.Id,
            Customer = new CustomerDto
            {
                Id = customer.Id,
                Name = customer.Name,
                Email = customer.Email
            },
            Items = orderItems,
            Total = order.Total,
            Status = order.Status
        };
    }
}

Cached Query

public record GetProductCatalogQuery : IQuery<List<ProductDto>>;

public class GetProductCatalogQueryHandler : IAsyncQueryHandler<GetProductCatalogQuery, List<ProductDto>>
{
    private readonly IProductRepository _repository;
    private readonly IMemoryCache _cache;
    private readonly ILogger<GetProductCatalogQueryHandler> _logger;
    
    private const string CacheKey = "ProductCatalog";
    private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
    
    public async Task<List<ProductDto>> HandleAsync(
        GetProductCatalogQuery query, 
        CancellationToken ct = default)
    {
        // Try get from cache
        if (_cache.TryGetValue<List<ProductDto>>(CacheKey, out var cachedProducts))
        {
            _logger.LogInformation("Returning cached product catalog");
            return cachedProducts!;
        }
        
        // Cache miss - fetch from database
        _logger.LogInformation("Cache miss - fetching product catalog from database");
        var products = await _repository.GetAllAsync(ct);

        var productDtos = products.Select(p => new ProductDto
        {
            Id = p.Id,
            Name = p.Name,
            Price = p.Price,
            Stock = p.Stock
        }).ToList();
        
        // Store in cache
        _cache.Set(CacheKey, productDtos, CacheDuration);
        
        return productDtos;
    }
}

🛡️ Error Handling

Not Found Pattern

public class GetUserByIdQueryHandler : IAsyncQueryHandler<GetUserByIdQuery, UserDto>
{
    private readonly IUserRepository _repository;
    
    public async Task<UserDto> HandleAsync(GetUserByIdQuery query, CancellationToken ct = default)
    {
        var user = await _repository.GetByIdAsync(query.UserId, ct);
        
        if (user == null)
        {
            throw new NotFoundException($"User with ID {query.UserId} not found");
        }
    
        return new UserDto { Id = user.Id, Name = user.Name, Email = user.Email };
    }
}

Nullable Return Type

public record GetUserByEmailQuery(string Email) : IQuery<UserDto?>;

public class GetUserByEmailQueryHandler : IAsyncQueryHandler<GetUserByEmailQuery, UserDto?>
{
    private readonly IUserRepository _repository;
    
    public async Task<UserDto?> HandleAsync(GetUserByEmailQuery query, CancellationToken ct = default)
    {
        var user = await _repository.FindByEmailAsync(query.Email, ct);
        
        // Return null if not found
        if (user == null)
            return null;
        
        return new UserDto { Id = user.Id, Name = user.Name, Email = user.Email };
    }
}

Result Pattern

public record Result<T>
{
    public bool IsSuccess { get; init; }
    public T? Data { get; init; }
    public string? ErrorMessage { get; init; }
    
    public static Result<T> Success(T data) => new() { IsSuccess = true, Data = data };
    public static Result<T> Failure(string error) => new() { IsSuccess = false, ErrorMessage = error };
}

public record GetUserByIdQuery(int UserId) : IQuery<Result<UserDto>>;

public class GetUserByIdQueryHandler : IAsyncQueryHandler<GetUserByIdQuery, Result<UserDto>>
{
    private readonly IUserRepository _repository;
    
    public async Task<Result<UserDto>> HandleAsync(GetUserByIdQuery query, CancellationToken ct = default)
    {
        try
        {
            var user = await _repository.GetByIdAsync(query.UserId, ct);
   
            if (user == null)
                return Result<UserDto>.Failure($"User with ID {query.UserId} not found");
   
            var dto = new UserDto { Id = user.Id, Name = user.Name, Email = user.Email };
            return Result<UserDto>.Success(dto);
        }
        catch (Exception ex)
        {
            return Result<UserDto>.Failure($"Error fetching user: {ex.Message}");
        }
    }
}

✅ Best Practices

1. Use Descriptive Names

// ✅ Good - clear what it does
public record GetUserByIdQuery(int UserId) : IQuery<UserDto>;
public record SearchProductsByCategoryQuery(int CategoryId) : IQuery<List<ProductDto>>;
public record CalculateOrderTotalQuery(int OrderId) : IQuery<decimal>;

// ❌ Bad - unclear intent
public record UserQuery(int Id) : IQuery<UserDto>;
public record ProductQuery(int CategoryId) : IQuery<List<ProductDto>>;
public record TotalQuery(int Id) : IQuery<decimal>;

2. Don't Modify State

// ✅ Good - read-only query
public class GetUserByIdQueryHandler : IAsyncQueryHandler<GetUserByIdQuery, UserDto>
{
    public async Task<UserDto> HandleAsync(GetUserByIdQuery query, CancellationToken ct = default)
    {
        var user = await _repository.GetByIdAsync(query.UserId, ct);
        return MapToDto(user);
    }
}

// ❌ Bad - modifying state in query
public class GetUserByIdQueryHandler : IAsyncQueryHandler<GetUserByIdQuery, UserDto>
{
    public async Task<UserDto> HandleAsync(GetUserByIdQuery query, CancellationToken ct = default)
    {
        var user = await _repository.GetByIdAsync(query.UserId, ct);
   
        // Don't do this in a query!
        user.LastAccessedAt = DateTime.UtcNow;
        await _repository.UpdateAsync(user, ct);
        
        return MapToDto(user);
    }
}

3. Use DTOs for Responses

// ✅ Good - DTO response
public record UserDto
{
    public int Id { get; init; }
    public required string Name { get; init; }
    public required string Email { get; init; }
}

public record GetUserQuery(int UserId) : IQuery<UserDto>;

// ❌ Bad - returning domain entity
public record GetUserQuery(int UserId) : IQuery<User>; // User is domain entity

4. Keep Queries Focused

// ✅ Good - focused queries
public record GetUserByIdQuery(int UserId) : IQuery<UserDto>;
public record GetUserOrdersQuery(int UserId) : IQuery<List<OrderDto>>;
public record GetUserAddressesQuery(int UserId) : IQuery<List<AddressDto>>;

// ❌ Bad - one query doing too much
public record GetCompleteUserDataQuery(int UserId) : IQuery<CompleteUserDto>;
// Where CompleteUserDto includes everything: profile, orders, addresses, payments, etc.

5. Use Pagination for Large Datasets

// ✅ Good - paginated
public record GetOrdersQuery : IQuery<PagedResult<OrderDto>>
{
    public int PageNumber { get; init; } = 1;
    public int PageSize { get; init; } = 20;
}

// ❌ Bad - fetching all data
public record GetAllOrdersQuery : IQuery<List<OrderDto>>; // Could return millions of records

6. Make Queries Immutable

// ✅ Good - immutable
public record GetUserByIdQuery(int UserId) : IQuery<UserDto>;

// ✅ Good - immutable with init
public record SearchUsersQuery : IQuery<List<UserDto>>
{
    public required string SearchTerm { get; init; }
    public int PageNumber { get; init; } = 1;
}

// ❌ Bad - mutable
public class SearchUsersQuery : IQuery<List<UserDto>>
{
    public string SearchTerm { get; set; }
    public int PageNumber { get; set; }
}

🎓 Next Steps


← Commands | Home | Events →

⚠️ **GitHub.com Fallback** ⚠️