VSATutorialSeptember 2026 · 9 min read

VSA Todo App — Part 2: CQRS Command Handlers in Practice

TL;DR

Part 2 implements Create, Update, and Delete Todo commands with VSA. We compare two production patterns: MediatR-based handlers with record types (from Blazor CRM) and manual handler classes with ApiResponse wrappers (from MVC Project Manager). You'll learn when each approach makes sense and how they both fit within a VSA folder structure.

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

Help Us Grow

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

Command handlers are the heart of any VSA application. They handle writes — creating, updating, and deleting data. In the VSA pattern, each command handler is a self-contained file that defines its own request DTO, response DTO, and handler logic. This is radically different from layered architectures where a single "Create Order" operation touches a Controller, Service, Repository, Domain model, and DTO across five separate files. In VSA, you open one file and see the entire write operation.

There are two production-proven approaches to CQRS command handlers in VSA: the MediatR pattern used by the Blazor CRM Todo feature, and the manual handler pattern used by the MVC Project Manager Todo feature. Both follow the same VSA folder structure. Both keep all DTOs and logic in one file. The difference is how they're invoked and how they return results. Let's implement both and understand the trade-offs.

Pattern 1: MediatR Handlers (Blazor CRM Style)

The Blazor CRM Todo feature uses MediatR with C# record types for commands and queries. Each handler implements IRequestHandler<TRequest, TResponse>. The Create Todo handler lives in Features/Utilities/Todo/Cqrs/CreateTodoHandler.cs:

public class CreateTodoRequest
{
    public string? Name { get; set; }
    public string? Description { get; set; }
    public DateTime? StartTime { get; set; }
    public DateTime? EndTime { get; set; }
    public bool IsCompleted { get; set; }
}

public class CreateTodoResponse
{
    public string? Id { get; set; }
    public string? Name { get; set; }
}

public record CreateTodoCommand(CreateTodoRequest Data) : IRequest<CreateTodoResponse>;

public class CreateTodoHandler : IRequestHandler<CreateTodoCommand, CreateTodoResponse>
{
    private readonly AppDbContext _context;
    public CreateTodoHandler(AppDbContext context) => _context = context;

    public async Task<CreateTodoResponse> Handle(
        CreateTodoCommand request, CancellationToken cancellationToken)
    {
        var entity = new Data.Entities.Todo
        {
            Name = request.Data.Name,
            Description = request.Data.Description,
            StartTime = request.Data.StartTime,
            EndTime = request.Data.EndTime,
            IsCompleted = request.Data.IsCompleted
        };
        _context.Todo.Add(entity);
        await _context.SaveChangesAsync(cancellationToken);
        return new CreateTodoResponse { Id = entity.Id, Name = entity.Name };
    }
}

The handler is invoked via mediator.Send(new CreateTodoCommand(request)) from the endpoint. MediatR handles the dispatch, so the endpoint doesn't know which class implements the handler. This decoupling is useful when you want pipeline behaviors like logging, validation, or transaction management applied automatically to every command.

The Update Todo handler follows the same pattern but adds null-checking and returns a Success flag:

public record UpdateTodoCommand(UpdateTodoRequest Data) : IRequest<UpdateTodoResponse>;

public class UpdateTodoHandler : IRequestHandler<UpdateTodoCommand, UpdateTodoResponse>
{
    public async Task<UpdateTodoResponse> Handle(
        UpdateTodoCommand request, CancellationToken cancellationToken)
    {
        var entity = await _context.Todo
            .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken);
        if (entity == null)
            return new UpdateTodoResponse { Id = request.Data.Id, Success = false };

        entity.Name = request.Data.Name;
        entity.Description = request.Data.Description;
        entity.StartTime = request.Data.StartTime;
        entity.EndTime = request.Data.EndTime;
        entity.IsCompleted = request.Data.IsCompleted;
        await _context.SaveChangesAsync(cancellationToken);

        return new UpdateTodoResponse { Id = entity.Id, Success = true };
    }
}

The Delete handler is even simpler — it returns a bool directly instead of a response object:

public record DeleteTodoByIdCommand(DeleteTodoByIdRequest Data) : IRequest<bool>;

public class DeleteTodoByIdHandler : IRequestHandler<DeleteTodoByIdCommand, bool>
{
    public async Task<bool> Handle(DeleteTodoByIdCommand request, CancellationToken ct)
    {
        var entity = await _context.Todo
            .FirstOrDefaultAsync(x => x.Id == request.Data.Id, ct);
        if (entity == null) return false;
        _context.Todo.Remove(entity);
        await _context.SaveChangesAsync(ct);
        return true;
    }
}

Pattern 2: Manual Handlers (MVC Project Manager Style)

The MVC Project Manager Todo feature doesn't use MediatR at all. Instead, handlers are plain classes with a HandleAsync method that returns ApiResponse<T> — a standardized wrapper with Success, Data, and Message fields. This pattern is simpler, has zero library dependencies, and makes error handling explicit:

public class CreateTodoHandler
{
    private readonly AppDbContext _context;
    public CreateTodoHandler(AppDbContext context) => _context = context;

    public async Task<ApiResponse<CreateTodoResponse>> HandleAsync(
        CreateTodoRequest request, CancellationToken cancellationToken)
    {
        var validator = new CreateTodoValidator();
        var validationResult = await validator.ValidateAsync(request, cancellationToken);
        if (!validationResult.IsValid)
            return ApiResponse<CreateTodoResponse>.Fail(
                "Validation failed", validationResult.ToDictionary());

        var entity = new Todo { Name = request.Name, /* ... */ };
        _context.Todo.Add(entity);
        await _context.SaveChangesAsync(cancellationToken);

        return ApiResponse<CreateTodoResponse>.Success(
            new CreateTodoResponse { Id = entity.Id, Name = entity.Name },
            "Todo created successfully");
    }
}

The ApiResponse<T> pattern is a game-changer for VSA. Instead of throwing exceptions for validation failures, the handler returns a structured error response that the endpoint can forward directly to the client. This keeps the handler's error contract explicit and testable. The MVC Project Manager calls validators inside the handler — the Blazor CRM uses client-side validation in the UI. Both approaches are valid; the choice depends on whether you need server-side validation guarantees.

When to Use Each Pattern

Use MediatR when: you want pipeline behaviors (logging, validation, transactions) applied automatically, you have many handlers and want consistent dispatch, or your team is already familiar with MediatR. The Blazor CRM uses MediatR because its 50+ feature slices benefit from centralized cross-cutting concerns.

Use manual handlers when: you want zero library dependencies, you prefer explicit error handling via ApiResponse<T>, or your application has fewer features. The MVC Project Manager uses manual handlers because its simpler structure benefits from direct, traceable handler calls without an intermediary bus.

Both patterns coexist perfectly in VSA because the folder structure doesn't change. Whether you use IMediator.Send() or new Handler().HandleAsync(), the handler file, the request/response DTOs, and the endpoint all live in the same feature folder. The architecture remains identical — only the invocation mechanism differs.

Key Takeaways

  • VSA command handlers co-locate request DTO, response DTO, and handler logic in a single file
  • MediatR provides automatic dispatch and pipeline behaviors but adds a library dependency
  • Manual handlers with ApiResponse<T> are simpler, more explicit, and have zero dependencies
  • Both patterns use the same VSA folder structure — the architecture doesn't change, only the invocation
  • The Update handler pattern (null check + Success flag) prevents silent failures

Frequently Asked Questions

Q: Should I use MediatR or manual handlers for VSA?

Start with manual handlers if your app has fewer than 20 features — the simplicity and zero-dependency approach pays off. Switch to MediatR when you need pipeline behaviors (logging, validation, transactions) applied consistently across many handlers. Both patterns use the same VSA folder structure.

Q: Should command and query DTOs be separate?

Yes. Commands represent write operations (Create, Update, Delete) and should be named accordingly. Queries represent reads and should never modify data. Keeping them separate enforces CQRS discipline — you can optimize reads and writes independently later.

Q: Where should I put validation — in the handler or in the endpoint?

Server-side validation belongs in the handler (as MVC Project Manager does). This ensures validation runs regardless of how the handler is called — from an API endpoint, a background job, or a test. Client-side validation in Blazor/MVC is a UX convenience, not a security boundary. We'll cover this in detail in Part 4.

Q: Can I mix MediatR and manual handlers in the same VSA app?

Yes. The folder structure is identical. You can use MediatR for complex features that benefit from pipeline behaviors and manual handlers for simple CRUD. The VSA architecture doesn't care how handlers are invoked — only that they're co-located with their feature.

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

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