Query handlers are the read side of CQRS in VSA. While command handlers write data, query handlers read it — and reading well requires different design decisions. Should you return entities or DTOs? When should you use Include vs Select projection? How do you handle child entities and audit data without N+1 queries? This part answers all of these questions with real code from both the Blazor CRM and MVC Project Manager Todo implementations.
Both codebases implement two main query patterns: a list query that returns summarized data for display in a table, and a detail query that returns a single record with all its child entities. The patterns are identical in structure — only the DTO shapes differ. Let's build both.
The Detail Query: GetTodoById with Child Items
The detail query returns a single Todo with all its child items. The Blazor CRM implementation uses Include for the navigation property and Select for projection:
public class GetTodoByIdHandler : IRequestHandler<GetTodoByIdQuery, GetTodoByIdResponse?>
{
public async Task<GetTodoByIdResponse?> Handle(
GetTodoByIdQuery request, CancellationToken cancellationToken)
{
return await _context.Todo
.AsNoTracking()
.Include(x => x.TodoItemList)
.Where(x => x.Id == request.Id)
.Select(x => new GetTodoByIdResponse
{
Id = x.Id, AutoNumber = x.AutoNumber,
Name = x.Name, Description = x.Description,
StartTime = x.StartTime, EndTime = x.EndTime,
IsCompleted = x.IsCompleted,
CreatedAt = x.CreatedAt, CreatedBy = x.CreatedBy,
UpdatedAt = x.UpdatedAt, UpdatedBy = x.UpdatedBy,
TodoItems = x.TodoItemList
.OrderBy(i => i.StartTime)
.Select(i => new TodoItemResponse
{
Id = i.Id, Name = i.Name,
Description = i.Description,
StartTime = i.StartTime, EndTime = i.EndTime,
IsCompleted = i.IsCompleted
}).ToList()
})
.FirstOrDefaultAsync(cancellationToken);
}
}
Key decisions here: AsNoTracking() for read-only performance, Include to eager-load child items (avoiding lazy loading and N+1), and Select projection to shape exactly the data the client needs. The child TodoItemResponse DTO is defined in the same file — co-location keeps the query self-documenting.
The List Query: GetTodoList with Audit Fields
The list query returns all todos ordered by creation date. It includes audit fields so the UI can display who created and last modified each record:
public class GetTodoListHandler : IRequestHandler<GetTodoListQuery, List<GetTodoListResponse>>
{
public async Task<List<GetTodoListResponse>> Handle(
GetTodoListQuery request, CancellationToken cancellationToken)
{
return await _context.Todo
.AsNoTracking()
.OrderByDescending(x => x.CreatedAt)
.Select(x => new GetTodoListResponse
{
Id = x.Id, AutoNumber = x.AutoNumber,
Name = x.Name, Description = x.Description,
StartTime = x.StartTime, EndTime = x.EndTime,
IsCompleted = x.IsCompleted,
CreatedAt = x.CreatedAt, CreatedBy = x.CreatedBy,
UpdatedAt = x.UpdatedAt, UpdatedBy = x.UpdatedBy
})
.ToListAsync(cancellationToken);
}
}
Notice what's missing: no Include for child items. The list view doesn't need child data, so we don't load it. This is the power of DTO projection — you fetch exactly what the UI needs, no more. The MVC Project Manager takes this further with server-side pagination via DataTables (we'll cover that in Part 8).
The IHasAuditDisplay Pattern
Audit fields store user IDs (CreatedBy, UpdatedBy), but the UI needs user emails. The MVC Project Manager solves this with IHasAuditDisplay — an interface that resolves IDs to emails after the query runs:
public interface IHasAuditDisplay
{
string? CreatedBy { get; set; }
string? UpdatedBy { get; set; }
string? CreatedByEmail { get; set; }
string? UpdatedByEmail { get; set; }
}
public class TodoListItem : IHasAuditDisplay
{
public string? Id { get; set; }
public string? Name { get; set; }
public string? CreatedBy { get; set; }
public string? UpdatedBy { get; set; }
public string? CreatedByEmail { get; set; } // resolved post-query
public string? UpdatedByEmail { get; set; } // resolved post-query
}
The handler queries users in a separate lookup after fetching todos, then resolves emails into the DTO. This keeps the main query fast (no joins to the Users table) while still providing human-readable audit data to the UI.
User Lookup Handler
The MVC Project Manager includes a dedicated GetTodoUserLookupHandler for populating owner dropdowns:
public class GetTodoUserLookupHandler
{
public async Task<ApiResponse<List<UserLookupDto>>> HandleAsync(CancellationToken ct)
{
var users = await _context.Users
.AsNoTracking()
.Where(u => u.IsActive)
.Select(u => new UserLookupDto { Id = u.Id, Text = u.Email })
.ToListAsync(ct);
return ApiResponse<List<UserLookupDto>>.Success(users);
}
}
This handler is a query — it reads data — but it serves a UI concern, not a Todo concern. In VSA, it still lives in the Todo feature folder because it's specific to the Todo feature's needs. If other features need user lookups, they'd have their own or share a common one — the key is that each feature owns its dependencies.
DTO Projection vs Entity Return
Always return DTOs from query handlers, never entities. Entities have navigation properties, change tracking overhead, and expose internal structure. DTOs are flat, serializable, and contain exactly what the client needs. The Select projection in EF Core translates to efficient SQL that only selects the columns you need — better for performance and security.