- 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>
101 lines
3.0 KiB
Markdown
101 lines
3.0 KiB
Markdown
---
|
|
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:
|
|
|
|
```csharp
|
|
// 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
|
|
|
|
```csharp
|
|
// 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
|
|
|
|
```csharp
|
|
// 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
|