dotnet-cqrs/docs/core-features/queries/basic-queries.md

459 lines
12 KiB
Markdown

# 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
```csharp
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
```csharp
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
```csharp
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
```csharp
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
```csharp
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
```csharp
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
```csharp
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
```csharp
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
```csharp
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
```csharp
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
```csharp
// ✅ 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
```csharp
// ✅ 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
```csharp
// ✅ 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
## See Also
- [Query Registration](query-registration.md)
- [Query Authorization](query-authorization.md)
- [Dynamic Queries](../dynamic-queries/README.md)
- [Best Practices: Query Design](../../best-practices/query-design.md)