dotnet-cqrs/docs/getting-started/04-first-query.md

570 lines
13 KiB
Markdown

# Your First Query
Build your first query handler to retrieve data via HTTP or gRPC.
## What You'll Build
In this guide, you'll create a `GetUserQuery` that:
- ✅ Accepts a user ID
- ✅ Retrieves user data
- ✅ Returns a DTO (Data Transfer Object)
- ✅ Supports both HTTP GET and POST
## Step 1: Create a DTO
DTOs represent the data you return from queries. Create `Models/UserDto.cs`:
```csharp
namespace MyApp.Models;
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; }
}
```
**Key Points:**
- ✅ Use `record` for immutability
- ✅ Only include data needed by clients
- ✅ Never expose domain entities directly
- ✅ Can be different from your database model
## Step 2: Create the Query
Queries define what data you're asking for. Create `Queries/GetUserQuery.cs`:
```csharp
namespace MyApp.Queries;
public record GetUserQuery
{
public int UserId { get; init; }
}
```
**Key Points:**
- ✅ Use `record` for immutability
- ✅ Name should end with "Query" (convention)
- ✅ Contains only the parameters needed to fetch data
- ✅ No business logic
## Step 3: Create the Handler
Handlers execute the query logic. Create `Queries/GetUserQueryHandler.cs`:
```csharp
using Svrnty.CQRS.Abstractions;
using MyApp.Models;
namespace MyApp.Queries;
public class GetUserQueryHandler : IQueryHandler<GetUserQuery, UserDto>
{
// In-memory data for demo purposes
private static readonly List<User> _users = new()
{
new User { Id = 1, Name = "Alice Smith", Email = "alice@example.com" },
new User { Id = 2, Name = "Bob Johnson", Email = "bob@example.com" },
};
public Task<UserDto> HandleAsync(GetUserQuery query, CancellationToken cancellationToken)
{
var user = _users.FirstOrDefault(u => u.Id == query.UserId);
if (user == null)
{
throw new KeyNotFoundException($"User with ID {query.UserId} not found");
}
var dto = new UserDto
{
Id = user.Id,
Name = user.Name,
Email = user.Email,
CreatedAt = user.CreatedAt
};
return Task.FromResult(dto);
}
}
```
**Handler Interface:**
```csharp
IQueryHandler<TQuery, TResult>
```
- `TQuery`: Your query type (GetUserQuery)
- `TResult`: Return type (UserDto)
**Note:** Queries ALWAYS return a result (unlike commands).
## Step 4: Register the Handler
In `Program.cs`, register the query handler:
```csharp
using Svrnty.CQRS.Abstractions;
var builder = WebApplication.CreateBuilder(args);
// Register CQRS core services
builder.Services.AddSvrntyCQRS();
builder.Services.AddDefaultCommandDiscovery();
builder.Services.AddDefaultQueryDiscovery(); // ← Add this for queries
// Register command (from previous guide)
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
// Register query
builder.Services.AddQuery<GetUserQuery, UserDto, GetUserQueryHandler>();
var app = builder.Build();
// Map CQRS endpoints
app.UseSvrntyCqrs();
app.Run();
```
**Registration Syntax:**
```csharp
services.AddQuery<TQuery, TResult, THandler>();
```
## Step 5: Test Your Query
### Using HTTP GET
Run your application:
```bash
dotnet run
```
The query is automatically exposed at:
```
GET /api/query/getUser?userId=1
POST /api/query/getUser
```
Test with curl (GET):
```bash
curl "http://localhost:5000/api/query/getUser?userId=1"
```
Expected response:
```json
{
"id": 1,
"name": "Alice Smith",
"email": "alice@example.com",
"createdAt": "2025-01-15T10:30:00Z"
}
```
### Using HTTP POST
You can also POST the query parameters:
```bash
curl -X POST http://localhost:5000/api/query/getUser \
-H "Content-Type: application/json" \
-d '{"userId": 1}'
```
Same response as GET.
### Using Swagger
Navigate to:
```
http://localhost:5000/swagger
```
You'll see your query listed under "Queries" with both GET and POST endpoints.
## Complete Example with Repository
Here's a more realistic example using dependency injection:
### Update the Repository
```csharp
// Repositories/IUserRepository.cs
namespace MyApp.Repositories;
public interface IUserRepository
{
Task<User?> GetByIdAsync(int id, CancellationToken cancellationToken);
Task<List<User>> GetAllAsync(CancellationToken cancellationToken);
}
// Repositories/InMemoryUserRepository.cs
public class InMemoryUserRepository : IUserRepository
{
private readonly List<User> _users = new()
{
new User { Id = 1, Name = "Alice Smith", Email = "alice@example.com" },
new User { Id = 2, Name = "Bob Johnson", Email = "bob@example.com" },
};
public Task<User?> GetByIdAsync(int id, CancellationToken cancellationToken)
{
var user = _users.FirstOrDefault(u => u.Id == id);
return Task.FromResult(user);
}
public Task<List<User>> GetAllAsync(CancellationToken cancellationToken)
{
return Task.FromResult(_users.ToList());
}
}
```
### Update the Handler
```csharp
using Svrnty.CQRS.Abstractions;
using MyApp.Models;
using MyApp.Repositories;
namespace MyApp.Queries;
public class GetUserQueryHandler : IQueryHandler<GetUserQuery, UserDto>
{
private readonly IUserRepository _userRepository;
private readonly ILogger<GetUserQueryHandler> _logger;
public GetUserQueryHandler(
IUserRepository userRepository,
ILogger<GetUserQueryHandler> logger)
{
_userRepository = userRepository;
_logger = logger;
}
public async Task<UserDto> HandleAsync(GetUserQuery query, CancellationToken cancellationToken)
{
_logger.LogInformation("Fetching user {UserId}", query.UserId);
var user = await _userRepository.GetByIdAsync(query.UserId, cancellationToken);
if (user == null)
{
throw new KeyNotFoundException($"User with ID {query.UserId} not found");
}
return new UserDto
{
Id = user.Id,
Name = user.Name,
Email = user.Email,
CreatedAt = user.CreatedAt
};
}
}
```
## Query Naming Conventions
### Automatic Endpoint Names
Endpoints are generated from the query class name:
| Class Name | HTTP Endpoints |
|------------|----------------|
| `GetUserQuery` | `GET /api/query/getUser?userId=1`<br>`POST /api/query/getUser` |
| `SearchProductsQuery` | `GET /api/query/searchProducts?keyword=...`<br>`POST /api/query/searchProducts` |
| `ListOrdersQuery` | `GET /api/query/listOrders`<br>`POST /api/query/listOrders` |
**Rules:**
1. Strips "Query" suffix
2. Converts to lowerCamelCase
3. Prefixes with `/api/query/`
4. Creates both GET and POST endpoints
### Custom Endpoint Names
Use the `[QueryName]` attribute:
```csharp
using Svrnty.CQRS.Abstractions;
[QueryName("user")]
public record GetUserQuery
{
public int UserId { get; init; }
}
```
Endpoints become:
```
GET /api/query/user?userId=1
POST /api/query/user
```
## Returning Collections
Queries can return lists or collections:
```csharp
// Query
public record ListUsersQuery
{
public int Page { get; init; } = 1;
public int PageSize { get; init; } = 10;
}
// Handler
public class ListUsersQueryHandler : IQueryHandler<ListUsersQuery, List<UserDto>>
{
private readonly IUserRepository _userRepository;
public ListUsersQueryHandler(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public async Task<List<UserDto>> HandleAsync(ListUsersQuery query, CancellationToken cancellationToken)
{
var users = await _userRepository.GetAllAsync(cancellationToken);
var dtos = users
.Skip((query.Page - 1) * query.PageSize)
.Take(query.PageSize)
.Select(u => new UserDto
{
Id = u.Id,
Name = u.Name,
Email = u.Email,
CreatedAt = u.CreatedAt
})
.ToList();
return dtos;
}
}
// Registration
builder.Services.AddQuery<ListUsersQuery, List<UserDto>, ListUsersQueryHandler>();
```
Test with:
```bash
curl "http://localhost:5000/api/query/listUsers?page=1&pageSize=10"
```
## Returning Complex Types
Queries can return nested DTOs:
```csharp
// DTOs
public record OrderDto
{
public int OrderId { get; init; }
public CustomerDto Customer { get; init; } = null!;
public List<OrderItemDto> Items { get; init; } = new();
public decimal TotalAmount { get; init; }
}
public record CustomerDto
{
public int Id { get; init; }
public string Name { get; init; } = string.Empty;
}
public record OrderItemDto
{
public string ProductName { get; init; } = string.Empty;
public int Quantity { get; init; }
public decimal Price { get; init; }
}
// Query
public record GetOrderQuery
{
public int OrderId { get; init; }
}
// Handler
public class GetOrderQueryHandler : IQueryHandler<GetOrderQuery, OrderDto>
{
public async Task<OrderDto> HandleAsync(GetOrderQuery query, CancellationToken cancellationToken)
{
// Fetch and map your data
return new OrderDto
{
OrderId = query.OrderId,
Customer = new CustomerDto { Id = 1, Name = "Alice" },
Items = new List<OrderItemDto>
{
new() { ProductName = "Widget", Quantity = 2, Price = 10.00m }
},
TotalAmount = 20.00m
};
}
}
```
## Error Handling
### Not Found
Throw `KeyNotFoundException` for missing entities:
```csharp
public async Task<UserDto> HandleAsync(GetUserQuery query, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(query.UserId, cancellationToken);
if (user == null)
{
throw new KeyNotFoundException($"User with ID {query.UserId} not found");
}
return MapToDto(user);
}
```
HTTP response:
```
404 Not Found
```
### Validation Errors
Throw `ArgumentException` for invalid input:
```csharp
public async Task<UserDto> HandleAsync(GetUserQuery query, CancellationToken cancellationToken)
{
if (query.UserId <= 0)
{
throw new ArgumentException("UserId must be greater than 0", nameof(query.UserId));
}
// ... fetch user
}
```
HTTP response:
```
400 Bad Request
```
## Best Practices
### ✅ DO
- **Return DTOs** - Never return domain entities
- **Keep queries simple** - One query = one data need
- **Use async/await** - Even for in-memory data
- **Include only needed data** - Don't over-fetch
- **Support GET and POST** - Both are generated automatically
- **Use meaningful names** - GetUser, SearchOrders, ListProducts
- **Handle not found** - Throw KeyNotFoundException
### ❌ DON'T
- **Don't modify state** - Queries should be read-only
- **Don't use queries for commands** - Use commands to change state
- **Don't return IQueryable** - Always materialize results
- **Don't include sensitive data** - Filter out passwords, tokens, etc.
- **Don't ignore pagination** - For large result sets
- **Don't fetch unnecessary data** - Use projections
## GET vs POST for Queries
### When to Use GET
- ✅ Simple parameters (IDs, strings, numbers)
- ✅ No sensitive data in parameters
- ✅ Results can be cached
- ✅ Idempotent operations
Example:
```
GET /api/query/getUser?userId=123
```
### When to Use POST
- ✅ Complex parameters (objects, arrays)
- ✅ Sensitive data in parameters
- ✅ Long query strings
- ✅ Need request body
Example:
```
POST /api/query/searchOrders
{
"filters": { "status": "completed", "customerId": 123 },
"sorts": [{ "field": "orderDate", "direction": "desc" }],
"page": 1,
"pageSize": 20
}
```
**Good news:** Svrnty.CQRS creates **both** endpoints automatically!
## Troubleshooting
### Query Returns 404
**Problem:** Endpoint exists but always returns 404
**Solutions:**
1. Check your error handling - are you throwing KeyNotFoundException?
2. Verify data actually exists
3. Ensure query parameters are passed correctly
### Query Parameters Not Binding
**Problem:** Parameters are null or default values
**Solutions:**
1. Check property names match query string (case-insensitive)
2. For GET, use query string: `?userId=1`
3. For POST, use JSON body: `{"userId": 1}`
### Query Too Slow
**Problem:** Query takes too long to execute
**Solutions:**
1. Add database indexes
2. Use projections (select only needed columns)
3. Implement pagination
4. Consider caching
5. Use dynamic queries for flexible filtering
## What's Next?
Now that you can query data, let's add validation to ensure data quality!
**Continue to [Adding Validation](05-adding-validation.md) →**
## See Also
- [Queries Overview](../core-features/queries/README.md) - Deep dive into queries
- [Dynamic Queries](../core-features/dynamic-queries/README.md) - Advanced querying with filters
- [Query Authorization](../core-features/queries/query-authorization.md) - Secure your queries
- [Best Practices: Query Design](../best-practices/query-design.md) - Query optimization patterns