12 KiB
12 KiB
Basic Queries
Common query patterns for retrieving data.
Overview
Basic queries are the most common read operations in CQRS applications. This guide covers standard patterns for fetching entities, lists, and aggregated data.
Single Entity Queries
Get by ID
public record GetUserQuery
{
public int UserId { get; init; }
}
public record UserDto
{
public int Id { get; init; }
public string Name { get; init; } = string.Empty;
public string Email { get; init; } = string.Empty;
public DateTime CreatedAt { get; init; }
}
public class GetUserQueryHandler : IQueryHandler<GetUserQuery, UserDto>
{
private readonly ApplicationDbContext _context;
public async Task<UserDto> HandleAsync(GetUserQuery query, CancellationToken cancellationToken)
{
var user = await _context.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.Id == query.UserId, cancellationToken);
if (user == null)
throw new KeyNotFoundException($"User {query.UserId} not found");
return new UserDto
{
Id = user.Id,
Name = user.Name,
Email = user.Email,
CreatedAt = user.CreatedAt
};
}
}
Get by Unique Field
public record GetUserByEmailQuery
{
public string Email { get; init; } = string.Empty;
}
public class GetUserByEmailQueryHandler : IQueryHandler<GetUserByEmailQuery, UserDto>
{
public async Task<UserDto> HandleAsync(GetUserByEmailQuery query, CancellationToken cancellationToken)
{
var user = await _context.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.Email == query.Email, cancellationToken);
if (user == null)
throw new KeyNotFoundException($"User with email {query.Email} not found");
return MapToDto(user);
}
}
List Queries
Simple List
public record ListUsersQuery
{
}
public class ListUsersQueryHandler : IQueryHandler<ListUsersQuery, List<UserDto>>
{
public async Task<List<UserDto>> HandleAsync(ListUsersQuery query, CancellationToken cancellationToken)
{
var users = await _context.Users
.AsNoTracking()
.OrderBy(u => u.Name)
.ToListAsync(cancellationToken);
return users.Select(u => new UserDto
{
Id = u.Id,
Name = u.Name,
Email = u.Email,
CreatedAt = u.CreatedAt
}).ToList();
}
}
Paginated List
public record ListUsersQuery
{
public int Page { get; init; } = 1;
public int PageSize { get; init; } = 10;
}
public record PagedResult<T>
{
public List<T> Items { get; init; } = new();
public int TotalCount { get; init; }
public int Page { get; init; }
public int PageSize { get; init; }
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
}
public class ListUsersQueryHandler : IQueryHandler<ListUsersQuery, PagedResult<UserDto>>
{
public async Task<PagedResult<UserDto>> HandleAsync(ListUsersQuery query, CancellationToken cancellationToken)
{
var totalCount = await _context.Users.CountAsync(cancellationToken);
var users = await _context.Users
.AsNoTracking()
.OrderBy(u => u.Name)
.Skip((query.Page - 1) * query.PageSize)
.Take(query.PageSize)
.ToListAsync(cancellationToken);
return new PagedResult<UserDto>
{
Items = users.Select(MapToDto).ToList(),
TotalCount = totalCount,
Page = query.Page,
PageSize = query.PageSize
};
}
}
Filtered List
public record ListActiveUsersQuery
{
public bool? IsActive { get; init; }
}
public class ListActiveUsersQueryHandler : IQueryHandler<ListActiveUsersQuery, List<UserDto>>
{
public async Task<List<UserDto>> HandleAsync(ListActiveUsersQuery query, CancellationToken cancellationToken)
{
var usersQuery = _context.Users.AsNoTracking();
if (query.IsActive.HasValue)
{
usersQuery = usersQuery.Where(u => u.IsActive == query.IsActive.Value);
}
var users = await usersQuery
.OrderBy(u => u.Name)
.ToListAsync(cancellationToken);
return users.Select(MapToDto).ToList();
}
}
Search Queries
Text Search
public record SearchUsersQuery
{
public string Keyword { get; init; } = string.Empty;
public int Page { get; init; } = 1;
public int PageSize { get; init; } = 10;
}
public class SearchUsersQueryHandler : IQueryHandler<SearchUsersQuery, PagedResult<UserDto>>
{
public async Task<PagedResult<UserDto>> HandleAsync(SearchUsersQuery query, CancellationToken cancellationToken)
{
var usersQuery = _context.Users.AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Keyword))
{
var keyword = query.Keyword.ToLower();
usersQuery = usersQuery.Where(u =>
u.Name.ToLower().Contains(keyword) ||
u.Email.ToLower().Contains(keyword));
}
var totalCount = await usersQuery.CountAsync(cancellationToken);
var users = await usersQuery
.OrderBy(u => u.Name)
.Skip((query.Page - 1) * query.PageSize)
.Take(query.PageSize)
.ToListAsync(cancellationToken);
return new PagedResult<UserDto>
{
Items = users.Select(MapToDto).ToList(),
TotalCount = totalCount,
Page = query.Page,
PageSize = query.PageSize
};
}
}
Multi-Criteria Search
public record SearchProductsQuery
{
public string? Keyword { get; init; }
public string? Category { get; init; }
public decimal? MinPrice { get; init; }
public decimal? MaxPrice { get; init; }
public bool? InStock { get; init; }
}
public class SearchProductsQueryHandler : IQueryHandler<SearchProductsQuery, List<ProductDto>>
{
public async Task<List<ProductDto>> HandleAsync(SearchProductsQuery query, CancellationToken cancellationToken)
{
var productsQuery = _context.Products.AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Keyword))
{
var keyword = query.Keyword.ToLower();
productsQuery = productsQuery.Where(p =>
p.Name.ToLower().Contains(keyword) ||
p.Description.ToLower().Contains(keyword));
}
if (!string.IsNullOrWhiteSpace(query.Category))
{
productsQuery = productsQuery.Where(p => p.Category == query.Category);
}
if (query.MinPrice.HasValue)
{
productsQuery = productsQuery.Where(p => p.Price >= query.MinPrice.Value);
}
if (query.MaxPrice.HasValue)
{
productsQuery = productsQuery.Where(p => p.Price <= query.MaxPrice.Value);
}
if (query.InStock.HasValue)
{
productsQuery = productsQuery.Where(p => p.Stock > 0);
}
var products = await productsQuery
.OrderBy(p => p.Name)
.ToListAsync(cancellationToken);
return products.Select(MapToDto).ToList();
}
}
Aggregation Queries
Count
public record GetUserCountQuery
{
}
public class GetUserCountQueryHandler : IQueryHandler<GetUserCountQuery, int>
{
public async Task<int> HandleAsync(GetUserCountQuery query, CancellationToken cancellationToken)
{
return await _context.Users.CountAsync(cancellationToken);
}
}
Sum and Average
public record GetOrderStatisticsQuery
{
public int CustomerId { get; init; }
}
public record OrderStatistics
{
public int TotalOrders { get; init; }
public decimal TotalAmount { get; init; }
public decimal AverageOrderValue { get; init; }
}
public class GetOrderStatisticsQueryHandler : IQueryHandler<GetOrderStatisticsQuery, OrderStatistics>
{
public async Task<OrderStatistics> HandleAsync(GetOrderStatisticsQuery query, CancellationToken cancellationToken)
{
var statistics = await _context.Orders
.Where(o => o.CustomerId == query.CustomerId)
.GroupBy(o => o.CustomerId)
.Select(g => new OrderStatistics
{
TotalOrders = g.Count(),
TotalAmount = g.Sum(o => o.TotalAmount),
AverageOrderValue = g.Average(o => o.TotalAmount)
})
.FirstOrDefaultAsync(cancellationToken);
return statistics ?? new OrderStatistics();
}
}
Complex Queries
Nested Data
public record GetOrderWithDetailsQuery
{
public int OrderId { get; init; }
}
public record OrderDto
{
public int Id { get; init; }
public CustomerDto Customer { get; init; } = null!;
public List<OrderItemDto> Items { get; init; } = new();
public decimal TotalAmount { get; init; }
}
public class GetOrderWithDetailsQueryHandler : IQueryHandler<GetOrderWithDetailsQuery, OrderDto>
{
public async Task<OrderDto> HandleAsync(GetOrderWithDetailsQuery query, CancellationToken cancellationToken)
{
var order = await _context.Orders
.AsNoTracking()
.Include(o => o.Customer)
.Include(o => o.Items)
.ThenInclude(i => i.Product)
.FirstOrDefaultAsync(o => o.Id == query.OrderId, cancellationToken);
if (order == null)
throw new KeyNotFoundException($"Order {query.OrderId} not found");
return new OrderDto
{
Id = order.Id,
Customer = new CustomerDto
{
Id = order.Customer.Id,
Name = order.Customer.Name
},
Items = order.Items.Select(i => new OrderItemDto
{
ProductName = i.Product.Name,
Quantity = i.Quantity,
Price = i.Price
}).ToList(),
TotalAmount = order.TotalAmount
};
}
}
Performance Optimization
Use AsNoTracking
// ✅ Good - AsNoTracking for read-only queries
var users = await _context.Users
.AsNoTracking()
.ToListAsync(cancellationToken);
// ❌ Bad - Change tracking overhead
var users = await _context.Users
.ToListAsync(cancellationToken);
Use Projections
// ✅ Good - Select only needed columns
var users = await _context.Users
.AsNoTracking()
.Select(u => new UserDto
{
Id = u.Id,
Name = u.Name,
Email = u.Email
})
.ToListAsync(cancellationToken);
// ❌ Bad - Fetch entire entity then map
var users = await _context.Users
.AsNoTracking()
.ToListAsync(cancellationToken);
var dtos = users.Select(MapToDto).ToList();
Avoid N+1 Queries
// ✅ Good - Use Include for related data
var orders = await _context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.ToListAsync(cancellationToken);
// ❌ Bad - N+1 query problem
var orders = await _context.Orders.ToListAsync(cancellationToken);
foreach (var order in orders)
{
order.Customer = await _context.Customers.FindAsync(order.CustomerId);
}
Best Practices
✅ DO
- Use AsNoTracking for read-only queries
- Use projections (Select) to fetch only needed data
- Always materialize queries (ToListAsync, FirstOrDefaultAsync)
- Throw KeyNotFoundException for missing entities
- Use pagination for large result sets
- Optimize with indexes
- Accept CancellationToken
❌ DON'T
- Don't return IQueryable
- Don't return domain entities
- Don't modify state
- Don't fetch unnecessary data
- Don't skip pagination
- Don't ignore performance