VSATutorialSeptember 2026 · 8 min read

VSA Todo App — Part 3: Query Handlers & Response DTO Patterns

TL;DR

Part 3 focuses on query handlers in VSA — how to design DTOs for list and detail queries, when to use Include vs projection, how to avoid N+1 queries, the IHasAuditDisplay pattern for resolving audit emails, and user lookup handlers for dropdown population. Code from both Blazor CRM and MVC Project Manager.

Part 3 of 20 in the VSA Todo App Tutorial Series | ← Previous: Part 2|Next: Part 4 →

Help Us Grow

Love this tutorial? Explore our ready-to-use enterprise starter kits built with VSA in .NET 10.

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.

Key Takeaways

  • Use AsNoTracking() for all read queries — it eliminates change tracking overhead
  • Use Include for child entities in detail queries, omit it in list queries for performance
  • Always project to DTOs with Select — never return entities from query handlers
  • The IHasAuditDisplay pattern separates ID storage from email resolution for clean audit display
  • User lookup handlers belong in the feature folder — each feature owns its UI dependencies

Frequently Asked Questions

Q: Should I use Include or Select projection in VSA query handlers?

Always use Select projection to DTOs. Include loads entire entities with all columns and navigation properties, which is wasteful for read queries. Select generates SQL that fetches only the columns your DTO needs. Use Include only when you need the full entity graph for writes, not reads.

Q: How do I avoid N+1 queries in VSA?

Use Include/ThenInclude for eager loading in detail queries, and use Select projection with child collections mapped to DTO lists. EF Core translates this to a single SQL query with JOINs. Never use lazy loading in VSA handlers — it causes N+1 queries that are hard to detect.

Q: Where should I put shared query logic in VSA?

If multiple feature slices need the same query, extract it to a shared query handler in a Shared/ or Common/ folder. But be conservative — most "shared" queries end up being slightly different per feature. VSA favors duplication over premature abstraction. Wait until you have three identical uses before extracting.

Part 3 of 20 in the VSA Todo App Tutorial Series | ← Previous: Part 2|Next: Part 4 →

Ready to study real VSA code?

Every Indotalent product is a complete .NET 10 application built with Vertical Slice Architecture. Complete source code — $21 each.

Explore Products

Looking for a ready-to-use traditional monolithic multilayered clean architecture?

Monolithic Clean Architecture — 1,300+ devs, 500+ forks. Clean Arch + CQRS + Repository Pattern. Free & open source for commercial use. ⭐ Star our repo or ❤️ buy our products — your support means everything!

Star on GitHub