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

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 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:

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:

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=1
POST /api/query/getUser
SearchProductsQuery GET /api/query/searchProducts?keyword=...
POST /api/query/searchProducts
ListOrdersQuery GET /api/query/listOrders
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:

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:

  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

See Also