dotnet-cqrs/.claude/rules/commands-queries.md
Mathias Beaulieu-Duncan a4525bad6a Add Claude Code harness: rules, skills, hooks, and editorconfig
- Add path-specific rules for commands/queries, dynamic queries, validation, and gRPC
- Add /add-command, /add-query, /add-dynamic-query scaffolding skills
- Add project settings with post-edit formatting, proto validation, and build-gate hooks
- Add .editorconfig codifying existing code style conventions
- Trim CLAUDE.md from 414 to 130 lines (domain details moved to rules)
- Add .harness-version tracking for the shared claude-harness repo

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 03:30:27 -04:00

3.0 KiB

paths
*Command*.cs
*Query*.cs
*Handler*.cs
**/Program.cs
**/ServiceCollectionExtensions.cs

Commands & Queries

Commands Are Domain Actions, Not CRUD

Commands express user intent. Name them as the business action being performed.

  • PlaceOrderCommand not CreateOrderCommand
  • ApproveExpenseCommand not UpdateExpenseCommand
  • DeactivateAccountCommand not DeleteAccountCommand

One CRUD "Update" often becomes multiple distinct commands, each with its own validation and side effects.

File Structure

Command, handler, and validator live in the same file, organized by feature:

// File: Features/Orders/PlaceOrderCommand.cs
public record PlaceOrderCommand
{
    public string CustomerId { get; set; } = string.Empty;
    public List<OrderItem> Items { get; set; } = [];
}

public class PlaceOrderCommandValidator : AbstractValidator<PlaceOrderCommand>
{
    public PlaceOrderCommandValidator()
    {
        RuleFor(x => x.CustomerId).NotEmpty();
        RuleFor(x => x.Items).NotEmpty();
    }
}

public class PlaceOrderCommandHandler : ICommandHandler<PlaceOrderCommand, int>
{
    public Task<int> HandleAsync(PlaceOrderCommand command, CancellationToken cancellationToken = default)
    {
        // implementation
    }
}

Handler Interfaces

// Command with no result
ICommandHandler<TCommand>
    Task HandleAsync(TCommand command, CancellationToken cancellationToken = default)

// Command with result
ICommandHandler<TCommand, TResult>
    Task<TResult> HandleAsync(TCommand command, CancellationToken cancellationToken = default)

// Query (always returns result) — only for single-entity lookups or non-queryable data
IQueryHandler<TQuery, TResult>
    Task<TResult> HandleAsync(TQuery query, CancellationToken cancellationToken = default)

Registration

// Command without result
services.AddCommand<DeactivateAccountCommand, DeactivateAccountCommandHandler>();

// Command with result
services.AddCommand<PlaceOrderCommand, int, PlaceOrderCommandHandler>();

// Command with result + validator (from Svrnty.CQRS.FluentValidation)
services.AddCommand<PlaceOrderCommand, int, PlaceOrderCommandHandler, PlaceOrderCommandValidator>();

// Regular query — ONLY for single-entity lookups or non-queryable results
services.AddQuery<FetchOrderByIdQuery, Order, FetchOrderByIdQueryHandler>();

When to Use Regular IQueryHandler vs Dynamic Query

Use IQueryHandler (rare):

  • Single entity by ID: FetchOrderByIdQuery
  • Non-entity results: GetDashboardStatsQuery
  • Complex aggregation not expressible as IQueryable

Use Dynamic Query (default — see dynamic-query.md rule):

  • Any list/collection query
  • Anything that needs pagination, filtering, or sorting

Rules

  • Always use CancellationToken — never omit it
  • Commands/queries are record types with { get; set; } properties
  • Default string properties to string.Empty, collections to []
  • Naming: endpoint name is auto-derived by stripping Command/Query suffix and converting to lowerCamelCase