- 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>
3.0 KiB
3.0 KiB
| paths | |||||
|---|---|---|---|---|---|
|
Commands & Queries
Commands Are Domain Actions, Not CRUD
Commands express user intent. Name them as the business action being performed.
PlaceOrderCommandnotCreateOrderCommandApproveExpenseCommandnotUpdateExpenseCommandDeactivateAccountCommandnotDeleteAccountCommand
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
recordtypes with{ get; set; }properties - Default string properties to
string.Empty, collections to[] - Naming: endpoint name is auto-derived by stripping
Command/Querysuffix and converting to lowerCamelCase