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.