13 KiB
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:
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
recordfor 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:
namespace MyApp.Queries;
public record GetUserQuery
{
public int UserId { get; init; }
}
Key Points:
- ✅ Use
recordfor 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:
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:
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:
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:
services.AddQuery<TQuery, TResult, THandler>();
Step 5: Test Your Query
Using HTTP GET
Run your application:
dotnet run
The query is automatically exposed at:
GET /api/query/getUser?userId=1
POST /api/query/getUser
Test with curl (GET):
curl "http://localhost:5000/api/query/getUser?userId=1"
Expected response:
{
"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:
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
// 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
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=1POST /api/query/getUser |
SearchProductsQuery |
GET /api/query/searchProducts?keyword=...POST /api/query/searchProducts |
ListOrdersQuery |
GET /api/query/listOrdersPOST /api/query/listOrders |
Rules:
- Strips "Query" suffix
- Converts to lowerCamelCase
- Prefixes with
/api/query/ - Creates both GET and POST endpoints
Custom Endpoint Names
Use the [QueryName] attribute:
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:
// 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:
curl "http://localhost:5000/api/query/listUsers?page=1&pageSize=10"
Returning Complex Types
Queries can return nested DTOs:
// 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:
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:
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:
- Check your error handling - are you throwing KeyNotFoundException?
- Verify data actually exists
- Ensure query parameters are passed correctly
Query Parameters Not Binding
Problem: Parameters are null or default values
Solutions:
- Check property names match query string (case-insensitive)
- For GET, use query string:
?userId=1 - For POST, use JSON body:
{"userId": 1}
Query Too Slow
Problem: Query takes too long to execute
Solutions:
- Add database indexes
- Use projections (select only needed columns)
- Implement pagination
- Consider caching
- 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 →
See Also
- Queries Overview - Deep dive into queries
- Dynamic Queries - Advanced querying with filters
- Query Authorization - Secure your queries
- Best Practices: Query Design - Query optimization patterns