570 lines
13 KiB
Markdown
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
|