Skip to content

Query Module

Sébastien Bernier edited this page Feb 23, 2026 · 6 revisions

The Query module in Arcanic Mediator provides a powerful and flexible way to handle read operations in your application. Queries represent requests for data and are a core component of the Command Query Responsibility Segregation (CQRS) pattern.

Table of Contents

Overview

Queries in Arcanic Mediator are messages that represent read operations - requests for data from your application. They follow the query pattern and provide several benefits:

  • Separation of Concerns - Read operations are separated from write operations
  • Optimized Data Access - Queries can be optimized for specific read scenarios
  • Caching Support - Built-in pipeline support for response caching
  • Testability - Queries and handlers can be easily unit tested
  • Performance - Optimized for fast data retrieval
  • Consistency - Standardized approach to handling data requests

Key Concepts

  • Query - A message that represents a request for data
  • Query Handler - The class responsible for executing the query and returning data
  • DTOs/ViewModels - Data transfer objects that represent the query response
  • Pre-Handler - Executes before the main handler (authorization, parameter validation)
  • Post-Handler - Executes after the main handler (response transformation, logging)
  • Pipeline Behavior - Cross-cutting concerns like caching, performance monitoring

Installation

Install the Query module package:

dotnet add package Arcanic.Mediator.Query

For abstractions only (useful in domain/application layers):

dotnet add package Arcanic.Mediator.Query.Abstractions

Basic Usage

1. Define a Query

Queries always return data, so they must specify a return type:

using Arcanic.Mediator.Query.Abstractions;

// Simple query for single entity
public class GetUserByIdQuery : IQuery<UserDto>
{
    public int UserId { get; set; }
}

// Query with multiple parameters
public class GetProductsQuery : IQuery<IEnumerable<ProductDto>>
{
    public int CategoryId { get; set; }
    public decimal? MinPrice { get; set; }
    public decimal? MaxPrice { get; set; }
    public string? SearchTerm { get; set; }
}

// Query with pagination
public class GetOrdersPagedQuery : IQuery<PagedResult<OrderDto>>
{
    public int CustomerId { get; set; }
    public int Page { get; set; } = 1;
    public int PageSize { get; set; } = 10;
    public string? SortBy { get; set; }
    public bool SortDescending { get; set; } = false;
}

2. Define DTOs/ViewModels

// Simple DTO
public class UserDto
{
    public int Id { get; set; }
    public string FirstName { get; set; } = string.Empty;
    public string LastName { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; }
}

// Complex DTO with nested data
public class ProductDto
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Description { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public CategoryDto Category { get; set; } = new();
    public IEnumerable<ProductImageDto> Images { get; set; } = new List<ProductImageDto>();
}

// Paginated result wrapper
public class PagedResult<T>
{
    public IEnumerable<T> Items { get; set; } = new List<T>();
    public int TotalCount { get; set; }
    public int Page { get; set; }
    public int PageSize { get; set; }
    public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
    public bool HasNextPage => Page < TotalPages;
    public bool HasPreviousPage => Page > 1;
}

3. Create Query Handlers

using Arcanic.Mediator.Query.Abstractions.Handler;

// Simple query handler
public class GetUserByIdQueryHandler : IQueryHandler<GetUserByIdQuery, UserDto>
{
    private readonly IUserRepository _userRepository;
    private readonly IMapper _mapper;

    public GetUserByIdQueryHandler(IUserRepository userRepository, IMapper mapper)
    {
        _userRepository = userRepository;
        _mapper = mapper;
    }

    public async Task<UserDto> HandleAsync(GetUserByIdQuery request, CancellationToken cancellationToken = default)
    {
        var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
        if (user == null)
            throw new NotFoundException($"User with ID {request.UserId} not found");

        return _mapper.Map<UserDto>(user);
    }
}

// Complex query handler with filtering
public class GetProductsQueryHandler : IQueryHandler<GetProductsQuery, IEnumerable<ProductDto>>
{
    private readonly IProductRepository _productRepository;
    private readonly IMapper _mapper;
    private readonly ILogger<GetProductsQueryHandler> _logger;

    public GetProductsQueryHandler(
        IProductRepository productRepository, 
        IMapper mapper,
        ILogger<GetProductsQueryHandler> logger)
    {
        _productRepository = productRepository;
        _mapper = mapper;
        _logger = logger;
    }

    public async Task<IEnumerable<ProductDto>> HandleAsync(GetProductsQuery request, CancellationToken cancellationToken = default)
    {
        _logger.LogDebug("Getting products for category {CategoryId} with search term '{SearchTerm}'", 
            request.CategoryId, request.SearchTerm);

        var products = await _productRepository.GetFilteredAsync(
            categoryId: request.CategoryId,
            minPrice: request.MinPrice,
            maxPrice: request.MaxPrice,
            searchTerm: request.SearchTerm,
            cancellationToken: cancellationToken);

        return _mapper.Map<IEnumerable<ProductDto>>(products);
    }
}

// Paginated query handler
public class GetOrdersPagedQueryHandler : IQueryHandler<GetOrdersPagedQuery, PagedResult<OrderDto>>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IMapper _mapper;

    public GetOrdersPagedQueryHandler(IOrderRepository orderRepository, IMapper mapper)
    {
        _orderRepository = orderRepository;
        _mapper = mapper;
    }

    public async Task<PagedResult<OrderDto>> HandleAsync(GetOrdersPagedQuery request, CancellationToken cancellationToken = default)
    {
        var (orders, totalCount) = await _orderRepository.GetPagedAsync(
            customerId: request.CustomerId,
            page: request.Page,
            pageSize: request.PageSize,
            sortBy: request.SortBy,
            sortDescending: request.SortDescending,
            cancellationToken: cancellationToken);

        var orderDtos = _mapper.Map<IEnumerable<OrderDto>>(orders);

        return new PagedResult<OrderDto>
        {
            Items = orderDtos,
            TotalCount = totalCount,
            Page = request.Page,
            PageSize = request.PageSize
        };
    }
}

4. Configure Services

using Arcanic.Mediator;
using Arcanic.Mediator.Query;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddArcanicMediator()
    .AddQueries(Assembly.GetExecutingAssembly());

var app = builder.Build();

5. Use in Controllers

using Microsoft.AspNetCore.Mvc;
using Arcanic.Mediator.Request.Abstractions;

[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    private readonly IMediator _mediator;

    public UsersController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpGet("{id}")]
    public async Task<ActionResult<UserDto>> GetUser(int id)
    {
        var user = await _mediator.SendAsync(new GetUserByIdQuery { UserId = id });
        return Ok(user);
    }
}

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IMediator _mediator;

    public ProductsController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpGet]
    public async Task<ActionResult<IEnumerable<ProductDto>>> GetProducts(
        [FromQuery] int categoryId,
        [FromQuery] decimal? minPrice,
        [FromQuery] decimal? maxPrice,
        [FromQuery] string? searchTerm)
    {
        var query = new GetProductsQuery
        {
            CategoryId = categoryId,
            MinPrice = minPrice,
            MaxPrice = maxPrice,
            SearchTerm = searchTerm
        };

        var products = await _mediator.SendAsync(query);
        return Ok(products);
    }
}

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly IMediator _mediator;

    public OrdersController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpGet("customer/{customerId}")]
    public async Task<ActionResult<PagedResult<OrderDto>>> GetCustomerOrders(
        int customerId,
        [FromQuery] int page = 1,
        [FromQuery] int pageSize = 10,
        [FromQuery] string? sortBy = null,
        [FromQuery] bool sortDescending = false)
    {
        var query = new GetOrdersPagedQuery
        {
            CustomerId = customerId,
            Page = page,
            PageSize = pageSize,
            SortBy = sortBy,
            SortDescending = sortDescending
        };

        var result = await _mediator.SendAsync(query);
        return Ok(result);
    }
}

Query Types

Simple Entity Queries

For retrieving single entities:

public class GetProductByIdQuery : IQuery<ProductDto>
{
    public int ProductId { get; set; }
}

public class GetUserByEmailQuery : IQuery<UserDto?>
{
    public string Email { get; set; } = string.Empty;
}

public class GetCategoryBySlugQuery : IQuery<CategoryDto?>
{
    public string Slug { get; set; } = string.Empty;
}

Collection Queries

For retrieving multiple entities:

public class GetActiveUsersQuery : IQuery<IEnumerable<UserDto>>
{
}

public class GetFeaturedProductsQuery : IQuery<IEnumerable<ProductDto>>
{
    public int Count { get; set; } = 10;
}

public class GetRecentOrdersQuery : IQuery<IEnumerable<OrderDto>>
{
    public int Days { get; set; } = 30;
}

Search and Filter Queries

For complex data retrieval:

public class SearchProductsQuery : IQuery<IEnumerable<ProductDto>>
{
    public string SearchTerm { get; set; } = string.Empty;
    public int? CategoryId { get; set; }
    public decimal? MinPrice { get; set; }
    public decimal? MaxPrice { get; set; }
    public bool InStock { get; set; } = true;
    public ProductSortBy SortBy { get; set; } = ProductSortBy.Name;
}

public class GetCustomerOrdersQuery : IQuery<IEnumerable<OrderDto>>
{
    public int CustomerId { get; set; }
    public DateTime? StartDate { get; set; }
    public DateTime? EndDate { get; set; }
    public OrderStatus? Status { get; set; }
}

Paginated Queries

For large datasets:

public class GetUsersPagedQuery : IQuery<PagedResult<UserDto>>
{
    public int Page { get; set; } = 1;
    public int PageSize { get; set; } = 25;
    public string? SearchTerm { get; set; }
    public UserSortBy SortBy { get; set; } = UserSortBy.LastName;
    public bool SortDescending { get; set; } = false;
}

Aggregation and Statistics Queries

For calculated data:

public class GetSalesStatisticsQuery : IQuery<SalesStatisticsDto>
{
    public DateTime StartDate { get; set; }
    public DateTime EndDate { get; set; }
}

public class GetDashboardSummaryQuery : IQuery<DashboardSummaryDto>
{
    public int UserId { get; set; }
}

public class GetInventoryReportQuery : IQuery<InventoryReportDto>
{
    public int? CategoryId { get; set; }
    public bool LowStockOnly { get; set; } = false;
}

Query Handlers

Simple Data Retrieval Handler

public class GetProductByIdQueryHandler : IQueryHandler<GetProductByIdQuery, ProductDto>
{
    private readonly IProductRepository _productRepository;
    private readonly IMapper _mapper;

    public GetProductByIdQueryHandler(IProductRepository productRepository, IMapper mapper)
    {
        _productRepository = productRepository;
        _mapper = mapper;
    }

    public async Task<ProductDto> HandleAsync(GetProductByIdQuery request, CancellationToken cancellationToken = default)
    {
        var product = await _productRepository.GetByIdWithIncludesAsync(
            request.ProductId, 
            p => p.Category, 
            p => p.Images,
            cancellationToken);

        if (product == null)
            throw new NotFoundException($"Product with ID {request.ProductId} not found");

        return _mapper.Map<ProductDto>(product);
    }
}

Search Handler with Multiple Data Sources

public class SearchProductsQueryHandler : IQueryHandler<SearchProductsQuery, IEnumerable<ProductDto>>
{
    private readonly IProductRepository _productRepository;
    private readonly ISearchService _searchService;
    private readonly IMapper _mapper;
    private readonly ILogger<SearchProductsQueryHandler> _logger;

    public SearchProductsQueryHandler(
        IProductRepository productRepository,
        ISearchService searchService,
        IMapper mapper,
        ILogger<SearchProductsQueryHandler> logger)
    {
        _productRepository = productRepository;
        _searchService = searchService;
        _mapper = mapper;
        _logger = logger;
    }

    public async Task<IEnumerable<ProductDto>> HandleAsync(SearchProductsQuery request, CancellationToken cancellationToken = default)
    {
        _logger.LogDebug("Searching products with term '{SearchTerm}'", request.SearchTerm);

        IEnumerable<Product> products;

        // Use full-text search if search term is provided
        if (!string.IsNullOrWhiteSpace(request.SearchTerm))
        {
            var searchResults = await _searchService.SearchProductsAsync(
                request.SearchTerm, cancellationToken);
            
            var productIds = searchResults.Select(r => r.ProductId).ToList();
            products = await _productRepository.GetByIdsWithIncludesAsync(
                productIds, 
                p => p.Category,
                cancellationToken);
        }
        else
        {
            // Use repository filtering if no search term
            products = await _productRepository.GetFilteredAsync(
                categoryId: request.CategoryId,
                minPrice: request.MinPrice,
                maxPrice: request.MaxPrice,
                inStock: request.InStock,
                cancellationToken: cancellationToken);
        }

        // Apply sorting
        products = request.SortBy switch
        {
            ProductSortBy.Name => products.OrderBy(p => p.Name),
            ProductSortBy.Price => products.OrderBy(p => p.Price),
            ProductSortBy.CreatedDate => products.OrderByDescending(p => p.CreatedAt),
            ProductSortBy.Rating => products.OrderByDescending(p => p.AverageRating),
            _ => products.OrderBy(p => p.Name)
        };

        return _mapper.Map<IEnumerable<ProductDto>>(products);
    }
}

Complex Aggregation Handler

public class GetSalesStatisticsQueryHandler : IQueryHandler<GetSalesStatisticsQuery, SalesStatisticsDto>
{
    private readonly ISalesRepository _salesRepository;
    private readonly IOrderRepository _orderRepository;
    private readonly ILogger<GetSalesStatisticsQueryHandler> _logger;

    public GetSalesStatisticsQueryHandler(
        ISalesRepository salesRepository,
        IOrderRepository orderRepository,
        ILogger<GetSalesStatisticsQueryHandler> logger)
    {
        _salesRepository = salesRepository;
        _orderRepository = orderRepository;
        _logger = logger;
    }

    public async Task<SalesStatisticsDto> HandleAsync(GetSalesStatisticsQuery request, CancellationToken cancellationToken = default)
    {
        _logger.LogDebug("Calculating sales statistics from {StartDate} to {EndDate}", 
            request.StartDate, request.EndDate);

        // Execute multiple queries in parallel
        var salesTask = _salesRepository.GetSalesSummaryAsync(request.StartDate, request.EndDate, cancellationToken);
        var ordersTask = _orderRepository.GetOrderStatisticsAsync(request.StartDate, request.EndDate, cancellationToken);
        var topProductsTask = _salesRepository.GetTopSellingProductsAsync(request.StartDate, request.EndDate, 10, cancellationToken);

        await Task.WhenAll(salesTask, ordersTask, topProductsTask);

        var sales = await salesTask;
        var orderStats = await ordersTask;
        var topProducts = await topProductsTask;

        return new SalesStatisticsDto
        {
            TotalRevenue = sales.TotalRevenue,
            TotalOrders = orderStats.TotalOrders,
            AverageOrderValue = sales.TotalRevenue / Math.Max(orderStats.TotalOrders, 1),
            TopSellingProducts = topProducts.Select(p => new TopProductDto
            {
                ProductId = p.ProductId,
                ProductName = p.ProductName,
                QuantitySold = p.QuantitySold,
                Revenue = p.Revenue
            }).ToList(),
            PeriodStart = request.StartDate,
            PeriodEnd = request.EndDate
        };
    }
}

Cached Query Handler

public class GetFeaturedProductsQueryHandler : IQueryHandler<GetFeaturedProductsQuery, IEnumerable<ProductDto>>
{
    private readonly IProductRepository _productRepository;
    private readonly IDistributedCache _cache;
    private readonly IMapper _mapper;
    private readonly ILogger<GetFeaturedProductsQueryHandler> _logger;

    public GetFeaturedProductsQueryHandler(
        IProductRepository productRepository,
        IDistributedCache cache,
        IMapper mapper,
        ILogger<GetFeaturedProductsQueryHandler> logger)
    {
        _productRepository = productRepository;
        _cache = cache;
        _mapper = mapper;
        _logger = logger;
    }

    public async Task<IEnumerable<ProductDto>> HandleAsync(GetFeaturedProductsQuery request, CancellationToken cancellationToken = default)
    {
        var cacheKey = $"featured_products_{request.Count}";
        
        var cachedResult = await _cache.GetStringAsync(cacheKey, cancellationToken);
        if (cachedResult != null)
        {
            _logger.LogDebug("Returning featured products from cache");
            return JsonSerializer.Deserialize<IEnumerable<ProductDto>>(cachedResult) ?? Enumerable.Empty<ProductDto>();
        }

        _logger.LogDebug("Loading featured products from database");
        
        var products = await _productRepository.GetFeaturedAsync(request.Count, cancellationToken);
        var productDtos = _mapper.Map<IEnumerable<ProductDto>>(products);

        // Cache for 5 minutes
        var cacheOptions = new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
        };

        await _cache.SetStringAsync(
            cacheKey, 
            JsonSerializer.Serialize(productDtos), 
            cacheOptions, 
            cancellationToken);

        return productDtos;
    }
}

Pre and Post Handlers

Pre and post handlers allow you to execute logic before and after the main query handler.

Pre-Handlers (Authorization, Parameter Validation)

using Arcanic.Mediator.Query.Abstractions.Handler;

// Authorization pre-handler
public class GetUserByIdQueryAuthorizationPreHandler : IQueryPreHandler<GetUserByIdQuery>
{
    private readonly ICurrentUser _currentUser;
    private readonly IAuthorizationService _authorizationService;

    public GetUserByIdQueryAuthorizationPreHandler(ICurrentUser currentUser, IAuthorizationService authorizationService)
    {
        _currentUser = currentUser;
        _authorizationService = authorizationService;
    }

    public async Task HandleAsync(GetUserByIdQuery request, CancellationToken cancellationToken = default)
    {
        // Check if user can access this user's data
        if (request.UserId != _currentUser.UserId)
        {
            var authResult = await _authorizationService.AuthorizeAsync(
                _currentUser.User, "CanViewOtherUsers");

            if (!authResult.Succeeded)
            {
                throw new UnauthorizedAccessException("You can only view your own user data");
            }
        }
    }
}

// Parameter validation pre-handler
public class GetOrdersPagedQueryValidationPreHandler : IQueryPreHandler<GetOrdersPagedQuery>
{
    public async Task HandleAsync(GetOrdersPagedQuery request, CancellationToken cancellationToken = default)
    {
        if (request.Page < 1)
            throw new ValidationException("Page must be greater than 0");

        if (request.PageSize < 1 || request.PageSize > 100)
            throw new ValidationException("Page size must be between 1 and 100");

        if (request.CustomerId <= 0)
            throw new ValidationException("Customer ID must be greater than 0");

        await Task.CompletedTask;
    }
}

// Data access pre-handler
public class SearchProductsQueryPreHandler : IQueryPreHandler<SearchProductsQuery>
{
    private readonly ISearchQueryOptimizer _searchOptimizer;

    public SearchProductsQueryPreHandler(ISearchQueryOptimizer searchOptimizer)
    {
        _searchOptimizer = searchOptimizer;
    }

    public async Task HandleAsync(SearchProductsQuery request, CancellationToken cancellationToken = default)
    {
        // Optimize search query parameters
        if (!string.IsNullOrWhiteSpace(request.SearchTerm))
        {
            request.SearchTerm = await _searchOptimizer.OptimizeSearchTermAsync(request.SearchTerm, cancellationToken);
        }

        // Set reasonable defaults
        if (request.MinPrice.HasValue && request.MaxPrice.HasValue && request.MinPrice > request.MaxPrice)
        {
            (request.MinPrice, request.MaxPrice) = (request.MaxPrice, request.MinPrice);
        }
    }
}

Post-Handlers (Logging, Response Transformation)

// Logging post-handler
public class GetProductsQueryLoggingPostHandler : IQueryPostHandler<GetProductsQuery>
{
    private readonly ILogger<GetProductsQueryLoggingPostHandler> _logger;

    public GetProductsQueryLoggingPostHandler(ILogger<GetProductsQueryLoggingPostHandler> logger)
    {
        _logger = logger;
    }

    public async Task HandleAsync(GetProductsQuery request, CancellationToken cancellationToken = default)
    {
        _logger.LogInformation("Products query executed: Category={CategoryId}, Search='{SearchTerm}', PriceRange={MinPrice}-{MaxPrice}",
            request.CategoryId, request.SearchTerm, request.MinPrice, request.MaxPrice);
        
        await Task.CompletedTask;
    }
}

// Analytics tracking post-handler
public class SearchProductsQueryAnalyticsPostHandler : IQueryPostHandler<SearchProductsQuery>
{
    private readonly IAnalyticsService _analyticsService;
    private readonly ICurrentUser _currentUser;

    public SearchProductsQueryAnalyticsPostHandler(IAnalyticsService analyticsService, ICurrentUser currentUser)
    {
        _analyticsService = analyticsService;
        _currentUser = currentUser;
    }

    public async Task HandleAsync(SearchProductsQuery request, CancellationToken cancellationToken = default)
    {
        await _analyticsService.TrackSearchAsync(new SearchAnalyticsEvent
        {
            UserId = _currentUser.UserId,
            SearchTerm = request.SearchTerm,
            CategoryId = request.CategoryId,
            Filters = new
            {
                MinPrice = request.MinPrice,
                MaxPrice = request.MaxPrice,
                InStock = request.InStock
            },
            Timestamp = DateTime.UtcNow
        }, cancellationToken);
    }
}

Query Pipeline Behaviors

Pipeline behaviors provide cross-cutting concerns that execute around query handlers.

Response Caching

using Arcanic.Mediator.Query.Abstractions.Pipeline;
using Microsoft.Extensions.Caching.Memory;

public class CachingQueryPipelineBehavior<TQuery, TResponse> : IQueryPipelineBehavior<TQuery, TResponse>
    where TQuery : IQuery<TResponse>
{
    private readonly IMemoryCache _cache;
    private readonly ILogger<CachingQueryPipelineBehavior<TQuery, TResponse>> _logger;

    public CachingQueryPipelineBehavior(IMemoryCache cache, ILogger<CachingQueryPipelineBehavior<TQuery, TResponse>> logger)
    {
        _cache = cache;
        _logger = logger;
    }

    public async Task<TResponse> HandleAsync(TQuery query, PipelineDelegate<TResponse> next, CancellationToken cancellationToken = default)
    {
        // Only cache queries that implement ICacheable
        if (query is not ICacheable cacheableQuery)
        {
            return await next(cancellationToken);
        }

        var cacheKey = cacheableQuery.CacheKey;
        
        if (_cache.TryGetValue(cacheKey, out TResponse cachedResult))
        {
            _logger.LogDebug("Cache hit for query {QueryName}: {CacheKey}", typeof(TQuery).Name, cacheKey);
            return cachedResult;
        }

        _logger.LogDebug("Cache miss for query {QueryName}: {CacheKey}", typeof(TQuery).Name, cacheKey);

        var result = await next(cancellationToken);
        
        var cacheOptions = new MemoryCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = cacheableQuery.CacheDuration,
            Priority = CacheItemPriority.Normal,
            Size = 1
        };

        _cache.Set(cacheKey, result, cacheOptions);
        
        return result;
    }
}

// Interface for cacheable queries
public interface ICacheable
{
    string CacheKey { get; }
    TimeSpan CacheDuration { get; }
}

// Example cacheable query
public class GetFeaturedProductsQuery : IQuery<IEnumerable<ProductDto>>, ICacheable
{
    public int Count { get; set; } = 10;
    
    public string CacheKey => $"featured_products_{Count}";
    public TimeSpan CacheDuration => TimeSpan.FromMinutes(5);
}

Query Performance Monitoring

public class PerformanceQueryPipelineBehavior<TQuery, TResponse> : IQueryPipelineBehavior<TQuery, TResponse>
    where TQuery : IQuery<TResponse>
{
    private readonly ILogger<PerformanceQueryPipelineBehavior<TQuery, TResponse>> _logger;
    private readonly IMetricsCollector _metrics;

    public PerformanceQueryPipelineBehavior(
        ILogger<PerformanceQueryPipelineBehavior<TQuery, TResponse>> logger,
        IMetricsCollector metrics)
    {
        _logger = logger;
        _metrics = metrics;
    }

    public async Task<TResponse> HandleAsync(TQuery query, PipelineDelegate<TResponse> next, CancellationToken cancellationToken = default)
    {
        var stopwatch = System.Diagnostics.Stopwatch.StartNew();
        var queryName = typeof(TQuery).Name;

        try
        {
            var result = await next(cancellationToken);
            
            stopwatch.Stop();
            var elapsed = stopwatch.ElapsedMilliseconds;

            // Record metrics
            _metrics.RecordQueryExecution(queryName, elapsed);
            
            // Log slow queries
            if (elapsed > 1000) // 1 second
            {
                _logger.LogWarning("Slow query detected: {QueryName} took {ElapsedMs}ms. Query: {@Query}", 
                    queryName, elapsed, query);
            }
            else
            {
                _logger.LogDebug("Query {QueryName} completed in {ElapsedMs}ms", queryName, elapsed);
            }

            return result;
        }
        catch (Exception ex)
        {
            stopwatch.Stop();
            _metrics.RecordQueryFailure(queryName, stopwatch.ElapsedMilliseconds);
            
            _logger.LogError(ex, "Query {QueryName} failed after {ElapsedMs}ms", 
                queryName, stopwatch.ElapsedMilliseconds);
            throw;
        }
    }
}

Query Authorization

using Microsoft.AspNetCore.Authorization;

public class AuthorizationQueryPipelineBehavior<TQuery, TResponse> : IQueryPipelineBehavior<TQuery, TResponse>
    where TQuery : IQuery<TResponse>
{
    private readonly ICurrentUser _currentUser;
    private readonly IAuthorizationService _authorizationService;

    public AuthorizationQueryPipelineBehavior(ICurrentUser currentUser, IAuthorizationService authorizationService)
    {
        _currentUser = currentUser;
        _authorizationService = authorizationService;
    }

    public async Task<TResponse> HandleAsync(TQuery query, PipelineDelegate<TResponse> next, CancellationToken cancellationToken = default)
    {
        // Check for authorization attributes
        var authorizeAttribute = typeof(TQuery).GetCustomAttribute<AuthorizeAttribute>();
        if (authorizeAttribute != null)
        {
            var policy = authorizeAttribute.Policy ?? typeof(TQuery).Name;
            var authorizationResult = await _authorizationService.AuthorizeAsync(
                _currentUser.User, query, policy);

            if (!authorizationResult.Succeeded)
            {
                throw new UnauthorizedAccessException($"User not authorized to execute {typeof(TQuery).Name}");
            }
        }

        return await next(cancellationToken);
    }
}

Query Result Transformation

public class ResultTransformationQueryPipelineBehavior<TQuery, TResponse> : IQueryPipelineBehavior<TQuery, TResponse>
    where TQuery : IQuery<TResponse>
{
    private readonly ICurrentUser _currentUser;
    private readonly ILogger<ResultTransformationQueryPipelineBehavior<TQuery, TResponse>> _logger;

    public ResultTransformationQueryPipelineBehavior(ICurrentUser currentUser, ILogger<ResultTransformationQueryPipelineBehavior<TQuery, TResponse>> logger)
    {
        _currentUser = currentUser;
        _logger = logger;
    }

    public async Task<TResponse> HandleAsync(TQuery query, PipelineDelegate<TResponse> next, CancellationToken cancellationToken = default)
    {
        var result = await next(cancellationToken);

        // Apply user-specific transformations
        if (result is IUserContextAware contextAware)
        {
            contextAware.ApplyUserContext(_currentUser);
        }

        // Apply security filtering
        if (result is IEnumerable<ISecurityFilterable> securityFilterable)
        {
            var filteredItems = securityFilterable.Where(item => item.CanUserView(_currentUser));
            result = (TResponse)filteredItems;
        }

        return result;
    }
}

Registration and Configuration

Basic Registration

using Arcanic.Mediator;
using Arcanic.Mediator.Query;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddArcanicMediator()
    .AddQueries(Assembly.GetExecutingAssembly());

var app = builder.Build();

Registration with Pipeline Behaviors

builder.Services.AddArcanicMediator()
    // Add pipeline behaviors
    .AddQueryPipelineBehavior(typeof(CachingQueryPipelineBehavior<,>))
    .AddQueryPipelineBehavior(typeof(PerformanceQueryPipelineBehavior<,>))
    .AddQueryPipelineBehavior(typeof(AuthorizationQueryPipelineBehavior<,>))
    // Register queries
    .AddQueries(Assembly.GetExecutingAssembly());

Advanced Configuration with Dependencies

var builder = WebApplication.CreateBuilder(args);

// Add dependencies
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<ISearchService, SearchService>();

// Add AutoMapper
builder.Services.AddAutoMapper(Assembly.GetExecutingAssembly());

// Add Caching
builder.Services.AddMemoryCache(options =>
{
    options.SizeLimit = 1000;
    options.CompactionPercentage = 0.75;
});

builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis");
});

// Add Authorization
builder.Services.AddAuthorization();
builder.Services.AddScoped<ICurrentUser, CurrentUser>();

// Add Analytics
builder.Services.AddScoped<IAnalyticsService, AnalyticsService>();
builder.Services.AddSingleton<IMetricsCollector, MetricsCollector>();

// Add Mediator with all features
builder.Services.AddArcanicMediator()
    .AddQueryPipelineBehavior(typeof(PerformanceQueryPipelineBehavior<,>))
    .AddQueryPipelineBehavior(typeof(AuthorizationQueryPipelineBehavior<,>))
    .AddQueryPipelineBehavior(typeof(CachingQueryPipelineBehavior<,>))
    .AddQueries(Assembly.GetExecutingAssembly());

var app = builder.Build();

Conditional Registration

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddArcanicMediator()
    .AddQueries(Assembly.GetExecutingAssembly());

// Add caching only in production
if (builder.Environment.IsProduction())
{
    builder.Services.AddStackExchangeRedisCache(options =>
    {
        options.Configuration = builder.Configuration.GetConnectionString("Redis");
    });
    builder.Services.AddArcanicMediatorQueryPipelineBehavior(typeof(CachingQueryPipelineBehavior<,>));
}
else
{
    builder.Services.AddMemoryCache();
}

// Add performance monitoring in production
if (builder.Environment.IsProduction())
{
    builder.Services.AddSingleton<IMetricsCollector, MetricsCollector>();
    builder.Services.AddArcanicMediatorQueryPipelineBehavior(typeof(PerformanceQueryPipelineBehavior<,>));
}

// Add search service if configured
if (builder.Configuration.GetSection("ElasticSearch").Exists())
{
    builder.Services.AddElasticSearch();
    builder.Services.AddScoped<ISearchService, ElasticSearchService>();
}
else
{
    builder.Services.AddScoped<ISearchService, DatabaseSearchService>();
}

Best Practices

1. Keep Queries Focused and Single-Purpose

// ✅ Good - Single responsibility
public class GetUserProfileQuery : IQuery<UserProfileDto>
{
    public int UserId { get; set; }
}

public class GetUserOrderHistoryQuery : IQuery<IEnumerable<OrderDto>>
{
    public int UserId { get; set; }
    public int MaxResults { get; set; } = 10;
}

// ❌ Bad - Multiple responsibilities
public class GetUserDataQuery : IQuery<CompleteUserDataDto>
{
    public int UserId { get; set; }
    public bool IncludeOrders { get; set; }
    public bool IncludePreferences { get; set; }
    public bool IncludeActivity { get; set; }
    // Too much optional data
}

2. Use Appropriate Return Types

// ✅ Good - Specific DTOs
public class GetProductQuery : IQuery<ProductDto>
public class SearchProductsQuery : IQuery<IEnumerable<ProductDto>>
public class GetProductsPagedQuery : IQuery<PagedResult<ProductDto>>

// ✅ Good - Nullable for optional data
public class GetUserByEmailQuery : IQuery<UserDto?>
{
    public string Email { get; set; } = string.Empty;
}

// ❌ Bad - Generic object return
public class GetDataQuery : IQuery<object>

3. Implement Proper Caching Strategy

// Implement ICacheable for cacheable queries
public class GetCategoriesQuery : IQuery<IEnumerable<CategoryDto>>, ICacheable
{
    public string CacheKey => "all_categories";
    public TimeSpan CacheDuration => TimeSpan.FromHours(1); // Long cache for stable data
}

public class GetUserNotificationsQuery : IQuery<IEnumerable<NotificationDto>>, ICacheable
{
    public int UserId { get; set; }
    
    public string CacheKey => $"user_notifications_{UserId}";
    public TimeSpan CacheDuration => TimeSpan.FromMinutes(5); // Short cache for dynamic data
}

4. Handle Large Datasets Properly

// Use pagination for large datasets
public class GetOrdersQuery : IQuery<PagedResult<OrderDto>>
{
    public int Page { get; set; } = 1;
    public int PageSize { get; set; } = 25;
    
    // Validate page size in pre-handler or pipeline
    public bool IsValidPageSize() => PageSize > 0 && PageSize <= 100;
}

// Use streaming for very large datasets
public class ExportUsersQuery : IQuery<IAsyncEnumerable<UserExportDto>>
{
    public DateTime? StartDate { get; set; }
    public DateTime? EndDate { get; set; }
}

5. Optimize Database Queries

public class GetProductWithDetailsQueryHandler : IQueryHandler<GetProductWithDetailsQuery, ProductDetailDto>
{
    private readonly IProductRepository _productRepository;
    private readonly IMapper _mapper;

    public async Task<ProductDetailDto> HandleAsync(GetProductWithDetailsQuery request, CancellationToken cancellationToken = default)
    {
        // ✅ Good - Include related data in single query
        var product = await _productRepository.GetByIdWithIncludesAsync(
            request.ProductId,
            p => p.Category,
            p => p.Images,
            p => p.Reviews,
            cancellationToken);

        if (product == null)
            throw new NotFoundException($"Product with ID {request.ProductId} not found");

        return _mapper.Map<ProductDetailDto>(product);
    }
}

6. Use Meaningful Query Names

// ✅ Good - Descriptive names
public class GetActiveProductsByCategoryQuery : IQuery<IEnumerable<ProductDto>>
public class SearchProductsByNameAndPriceQuery : IQuery<IEnumerable<ProductDto>>
public class GetCustomerOrdersSummaryQuery : IQuery<CustomerOrdersSummaryDto>

// ❌ Bad - Generic names
public class ProductQuery : IQuery<IEnumerable<ProductDto>>
public class GetDataQuery : IQuery<object>
public class UserStuffQuery : IQuery<UserDto>

7. Implement Proper Error Handling

public class GetUserByIdQueryHandler : IQueryHandler<GetUserByIdQuery, UserDto>
{
    public async Task<UserDto> HandleAsync(GetUserByIdQuery request, CancellationToken cancellationToken = default)
    {
        try
        {
            var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
            
            // Return null for not found, or throw NotFoundException based on requirements
            if (user == null)
                throw new NotFoundException($"User with ID {request.UserId} not found");

            return _mapper.Map<UserDto>(user);
        }
        catch (NotFoundException)
        {
            throw; // Re-throw domain exceptions
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error retrieving user {UserId}", request.UserId);
            throw new ApplicationException("An error occurred while retrieving the user", ex);
        }
    }
}

Testing

Unit Testing Query Handlers

public class GetUserByIdQueryHandlerTests
{
    private readonly Mock<IUserRepository> _mockUserRepository;
    private readonly Mock<IMapper> _mockMapper;
    private readonly GetUserByIdQueryHandler _handler;

    public GetUserByIdQueryHandlerTests()
    {
        _mockUserRepository = new Mock<IUserRepository>();
        _mockMapper = new Mock<IMapper>();
        _handler = new GetUserByIdQueryHandler(_mockUserRepository.Object, _mockMapper.Object);
    }

    [Fact]
    public async Task HandleAsync_ValidUserId_ReturnsUserDto()
    {
        // Arrange
        var userId = 123;
        var query = new GetUserByIdQuery { UserId = userId };
        
        var user = new User { Id = userId, FirstName = "John", LastName = "Doe", Email = "john@example.com" };
        var userDto = new UserDto { Id = userId, FirstName = "John", LastName = "Doe", Email = "john@example.com" };

        _mockUserRepository.Setup(x => x.GetByIdAsync(userId, It.IsAny<CancellationToken>()))
            .ReturnsAsync(user);
        
        _mockMapper.Setup(x => x.Map<UserDto>(user))
            .Returns(userDto);

        // Act
        var result = await _handler.HandleAsync(query, CancellationToken.None);

        // Assert
        Assert.NotNull(result);
        Assert.Equal(userId, result.Id);
        Assert.Equal("John", result.FirstName);
        Assert.Equal("Doe", result.LastName);
        Assert.Equal("john@example.com", result.Email);
    }

    [Fact]
    public async Task HandleAsync_UserNotFound_ThrowsNotFoundException()
    {
        // Arrange
        var userId = 999;
        var query = new GetUserByIdQuery { UserId = userId };

        _mockUserRepository.Setup(x => x.GetByIdAsync(userId, It.IsAny<CancellationToken>()))
            .ReturnsAsync((User?)null);

        // Act & Assert
        await Assert.ThrowsAsync<NotFoundException>(() => 
            _handler.HandleAsync(query, CancellationToken.None));
    }
}

Testing Cached Queries

public class CachedQueryTests
{
    private readonly Mock<IProductRepository> _mockRepository;
    private readonly IMemoryCache _cache;
    private readonly Mock<ILogger<CachingQueryPipelineBehavior<GetFeaturedProductsQuery, IEnumerable<ProductDto>>>> _mockLogger;
    private readonly CachingQueryPipelineBehavior<GetFeaturedProductsQuery, IEnumerable<ProductDto>> _behavior;

    public CachedQueryTests()
    {
        _mockRepository = new Mock<IProductRepository>();
        _cache = new MemoryCache(new MemoryCacheOptions());
        _mockLogger = new Mock<ILogger<CachingQueryPipelineBehavior<GetFeaturedProductsQuery, IEnumerable<ProductDto>>>>();
        _behavior = new CachingQueryPipelineBehavior<GetFeaturedProductsQuery, IEnumerable<ProductDto>>(_cache, _mockLogger.Object);
    }

    [Fact]
    public async Task HandleAsync_CacheMiss_CallsRepositoryAndCachesResult()
    {
        // Arrange
        var query = new GetFeaturedProductsQuery { Count = 5 };
        var products = new List<ProductDto>
        {
            new() { Id = 1, Name = "Product 1" },
            new() { Id = 2, Name = "Product 2" }
        };

        var callCount = 0;
        Task<IEnumerable<ProductDto>> Next(CancellationToken ct)
        {
            callCount++;
            return Task.FromResult(products.AsEnumerable());
        }

        // Act
        var result1 = await _behavior.HandleAsync(query, Next, CancellationToken.None);
        var result2 = await _behavior.HandleAsync(query, Next, CancellationToken.None);

        // Assert
        Assert.Equal(1, callCount); // Repository called only once
        Assert.Equal(products.Count(), result1.Count());
        Assert.Equal(products.Count(), result2.Count());
    }
}

Integration Testing

public class GetUserByIdQueryIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;
    private readonly HttpClient _client;

    public GetUserByIdQueryIntegrationTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory;
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task GetUser_ValidId_ReturnsUserData()
    {
        // Arrange
        var userId = 1;

        // Act
        var response = await _client.GetAsync($"/api/users/{userId}");

        // Assert
        response.EnsureSuccessStatusCode();
        var content = await response.Content.ReadAsStringAsync();
        var user = JsonSerializer.Deserialize<UserDto>(content, new JsonSerializerOptions 
        { 
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase 
        });
        
        Assert.NotNull(user);
        Assert.Equal(userId, user.Id);
        Assert.NotEmpty(user.FirstName);
        Assert.NotEmpty(user.LastName);
        Assert.NotEmpty(user.Email);
    }

    [Fact]
    public async Task GetUser_InvalidId_ReturnsNotFound()
    {
        // Arrange
        var userId = 99999;

        // Act
        var response = await _client.GetAsync($"/api/users/{userId}");

        // Assert
        Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
    }
}

Next Steps

Related Documentation

Clone this wiki locally