| name | cqrs-pattern |
| description | Exact file layout and code shapes for commands, queries, handlers, and validators in this repo. Use when adding or modifying a use case in src/Application. |
File layout
One folder per action under src/Application/<Feature>/<Action>/:
src/Application/Todos/Create/
CreateTodoCommand.cs
CreateTodoCommandHandler.cs
CreateTodoCommandValidator.cs
Queries follow the same shape with Query instead of Command:
src/Application/Todos/GetById/
GetTodoByIdQuery.cs
GetTodoByIdQueryHandler.cs
TodoResponse.cs # query response DTO lives next to the query
Command — public sealed, implements ICommand or ICommand<T>
using Application.Abstractions.Messaging;
namespace Application.Todos.Create;
public sealed class CreateTodoCommand : ICommand<Guid>
{
public Guid UserId { get; set; }
public string Description { get; set; }
public DateTime? DueDate { get; set; }
public List<string> Labels { get; set; } = [];
public Priority Priority { get; set; }
}
- Use
ICommand when there is no result, ICommand<T> when there is.
- Plain POCO with public getters/setters — no constructor, no immutability ceremony in this template.
Handler — internal sealed, primary-constructor DI, returns Result<T>
internal sealed class CreateTodoCommandHandler(
IApplicationDbContext context,
IDateTimeProvider dateTimeProvider,
IUserContext userContext)
: ICommandHandler<CreateTodoCommand, Guid>
{
public async Task<Result<Guid>> Handle(CreateTodoCommand command, CancellationToken cancellationToken)
{
if (userContext.UserId != command.UserId)
{
return Result.Failure<Guid>(UserErrors.Unauthorized());
}
User? user = await context.Users.AsNoTracking()
.SingleOrDefaultAsync(u => u.Id == command.UserId, cancellationToken);
if (user is null)
{
return Result.Failure<Guid>(UserErrors.NotFound(command.UserId));
}
var todoItem = new TodoItem { };
todoItem.Raise(new TodoItemCreatedDomainEvent(todoItem.Id));
context.TodoItems.Add(todoItem);
await context.SaveChangesAsync(cancellationToken);
return todoItem.Id;
}
}
Handler rules:
- Always
internal sealed.
- Always primary-constructor DI (no explicit constructor).
- Return
Result.Success / Result.Failure<T> — never throw for expected failures.
- Use
AsNoTracking() for reads inside a mutating handler.
- Raise domain events on the entity before
SaveChangesAsync.
- Take
CancellationToken and pass it through every async call.
Query — read-only, no mutations, no domain events
public sealed class GetTodoByIdQuery(Guid todoItemId) : IQuery<TodoResponse>;
internal sealed class GetTodoByIdQueryHandler(IApplicationDbContext context)
: IQueryHandler<GetTodoByIdQuery, TodoResponse>
{
public async Task<Result<TodoResponse>> Handle(GetTodoByIdQuery query, CancellationToken cancellationToken)
{
TodoResponse? todo = await context.TodoItems
.Where(t => t.Id == query.TodoItemId)
.Select(t => new TodoResponse())
.SingleOrDefaultAsync(cancellationToken);
return todo is null
? Result.Failure<TodoResponse>(TodoItemErrors.NotFound(query.TodoItemId))
: todo;
}
}
Validator — public, FluentValidation
using FluentValidation;
namespace Application.Todos.Create;
public class CreateTodoCommandValidator : AbstractValidator<CreateTodoCommand>
{
public CreateTodoCommandValidator()
{
RuleFor(c => c.UserId).NotEmpty();
RuleFor(c => c.Priority).IsInEnum();
RuleFor(c => c.Description).NotEmpty().MaximumLength(255);
RuleFor(c => c.DueDate).GreaterThanOrEqualTo(DateTime.Today).When(x => x.DueDate.HasValue);
}
}
- Public class,
AbstractValidator<TCommand>.
- Lives in the same folder as the command/query.
- Auto-wired by the
ValidationDecorator behavior — no manual registration.
- No validator for trivial or parameter-less queries.
Wiring
Handlers and validators are auto-registered by Application/DependencyInjection.cs via assembly scanning. You do not need to touch DI when adding a new command, query, handler, or validator.
Don'ts
- Don't inject a DbContext directly — use
IApplicationDbContext.
- Don't call
HttpContext or any ASP.NET type from a handler. Use IUserContext for the current user.
- Don't reference
Infrastructure.* types from a handler.
- Don't add MediatR. Endpoints inject
ICommandHandler<,> / IQueryHandler<,> directly.
- Don't put the response DTO in
Domain — response types live next to the query in Application.