-
Notifications
You must be signed in to change notification settings - Fork 0
Query Module
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.
- Overview
- Installation
- Basic Usage
- Query Types
- Query Handlers
- Pre and Post Handlers
- Query Pipeline Behaviors
- Registration and Configuration
- Best Practices
- Testing
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
- 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
Install the Query module package:
dotnet add package Arcanic.Mediator.QueryFor abstractions only (useful in domain/application layers):
dotnet add package Arcanic.Mediator.Query.AbstractionsQueries 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;
}// 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;
}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
};
}
}using Arcanic.Mediator;
using Arcanic.Mediator.Query;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddArcanicMediator()
.AddQueries(Assembly.GetExecutingAssembly());
var app = builder.Build();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);
}
}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;
}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;
}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; }
}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;
}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;
}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);
}
}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);
}
}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
};
}
}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 allow you to execute logic before and after the main query handler.
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);
}
}
}// 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);
}
}Pipeline behaviors provide cross-cutting concerns that execute around query handlers.
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);
}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;
}
}
}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);
}
}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;
}
}using Arcanic.Mediator;
using Arcanic.Mediator.Query;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddArcanicMediator()
.AddQueries(Assembly.GetExecutingAssembly());
var app = builder.Build();builder.Services.AddArcanicMediator()
// Add pipeline behaviors
.AddQueryPipelineBehavior(typeof(CachingQueryPipelineBehavior<,>))
.AddQueryPipelineBehavior(typeof(PerformanceQueryPipelineBehavior<,>))
.AddQueryPipelineBehavior(typeof(AuthorizationQueryPipelineBehavior<,>))
// Register queries
.AddQueries(Assembly.GetExecutingAssembly());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();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>();
}// ✅ 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
}// ✅ 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>// 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
}// 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; }
}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);
}
}// ✅ 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>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);
}
}
}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));
}
}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());
}
}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);
}
}- Commands Module - Learn about write operations and command handling
- Event Module - Implement domain events and event handling
- Pipeline Behaviors - Advanced cross-cutting concerns
- Getting Started - Basic setup and first steps
- Sample Projects - Complete examples