Compare commits

...

4 Commits

Author SHA1 Message Date
55f1324286 Merge pull request 'feat/claude-code-harness' (#2) from feat/claude-code-harness into main
All checks were successful
Publish NuGets / build (release) Successful in 34s
Reviewed-on: #2
2026-03-12 06:44:11 -04:00
Mathias Beaulieu-Duncan
b34bf874b4 Remove Claude harness — replaced by claude-cqrs-plugin
The in-repo .claude/ harness (rules, skills, settings) is superseded by
the standalone claude-cqrs-plugin which provides the same guidance as a
reusable plugin across all Svrnty.CQRS projects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 06:42:50 -04:00
Mathias Beaulieu-Duncan
c6de10b98b Move UseSvrntyCqrs() from MinimalApi to core Svrnty.CQRS package
gRPC-only projects couldn't call app.UseSvrntyCqrs() without adding the
MinimalApi package. The method only calls ExecuteMappingCallbacks() which
is already in core — it had no MinimalApi dependency. Adds ASP.NET Core
FrameworkReference to the core package.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 06:38:26 -04:00
Mathias Beaulieu-Duncan
3945c1a158 Add project-init agent for scaffolding new CQRS projects
Scaffolds a complete Svrnty.CQRS project from a natural language
description — creates solution, web project, DAL with PostgreSQL,
entities, Program.cs, first feature, proto file, and .editorconfig.
Defaults to gRPC-only; MinimalApi added only on request.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 06:38:26 -04:00
13 changed files with 319 additions and 858 deletions

View File

@ -1 +0,0 @@
1.0.0

View File

@ -1,100 +0,0 @@
---
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

View File

@ -1,97 +0,0 @@
---
paths:
- "*QueryableProvider*.cs"
- "*AlterQueryable*.cs"
- "*DynamicQuery*.cs"
- "*Interceptor*.cs"
---
# Dynamic Queries — The Default Query Pattern
**99% of queries should be dynamic queries.** They provide pagination, filtering, sorting, grouping, and aggregation for free.
## The Standard Pattern: IQueryableProviderOverride
```csharp
// File: Features/Orders/OrderQueryableProvider.cs
public class OrderQueryableProvider : IQueryableProviderOverride<Order>
{
private readonly AppDbContext _db;
public OrderQueryableProvider(AppDbContext db) => _db = db;
public Task<IQueryable<Order>> GetQueryableAsync(object query, CancellationToken ct = default)
=> Task.FromResult(_db.Orders.AsQueryable());
}
```
Registration — one line:
```csharp
builder.Services.AddDynamicQueryWithProvider<Order, OrderQueryableProvider>();
```
This automatically creates endpoints with full filtering, sorting, and pagination support.
## Required Dependencies
These must be registered before `AddSvrntyCqrs`:
```csharp
builder.Services.AddTransient<IAsyncQueryableService, SimpleAsyncQueryableService>();
builder.Services.AddTransient<IQueryHandlerAsync, QueryHandlerAsync>();
```
## Alter Queryable Services (Security Filters, Tenant Isolation)
Use `IAlterQueryableService` to modify the queryable before execution — for security filters, tenant isolation, default ordering, etc.
```csharp
public class OrderTenantFilter : IAlterQueryableService<Order, Order>
{
private readonly ITenantContext _tenant;
public OrderTenantFilter(ITenantContext tenant) => _tenant = tenant;
public Task<IQueryable<Order>> AlterQueryableAsync(
IQueryable<Order> query,
IDynamicQuery dynamicQuery,
CancellationToken ct = default)
{
return Task.FromResult(query.Where(o => o.TenantId == _tenant.Id));
}
}
```
Registration:
```csharp
builder.Services.AddAlterQueryable<Order, Order, OrderTenantFilter>();
```
## Interceptors
Up to 5 interceptors per query type. These modify the PoweredSoft DynamicQuery criteria at query build time.
```csharp
builder.Services.AddDynamicQueryInterceptor<Order, Order, OrderInterceptor>();
```
## Source to Destination Mapping
When the entity type differs from the DTO:
```csharp
// Provider returns the source entity queryable
public class OrderQueryableProvider : IQueryableProviderOverride<Order> { ... }
// Registration maps source -> destination
builder.Services.AddDynamicQueryWithProvider<Order, OrderDto, OrderQueryableProvider>();
```
## Key Interfaces
```csharp
IQueryableProvider<TSource>
Task<IQueryable<TSource>> GetQueryableAsync(object query, CancellationToken ct)
IQueryableProviderOverride<TSource> : IQueryableProvider<TSource>
// Marker interface — same method, signals override registration
IAlterQueryableService<TSource, TDestination>
Task<IQueryable<TSource>> AlterQueryableAsync(IQueryable<TSource> query, IDynamicQuery dynamicQuery, CancellationToken ct)
```

View File

@ -1,109 +0,0 @@
---
paths:
- "**/*.proto"
- "**/Protos/**"
- "*Grpc*"
---
# gRPC Integration
## Proto File Conventions
Proto files live in `Protos/` directory. The source generator (`Svrnty.CQRS.Grpc.Generators`) auto-generates service implementations at compile time.
### Service Structure
```protobuf
service CommandService {
rpc PlaceOrder (PlaceOrderCommandRequest) returns (PlaceOrderCommandResponse);
}
service QueryService {
rpc FetchUserById (FetchUserByIdQueryRequest) returns (FetchUserByIdQueryResponse);
}
service DynamicQueryService {
rpc QueryOrders (DynamicQueryOrdersRequest) returns (DynamicQueryOrdersResponse);
}
```
### Naming Rules
- RPC method name: command/query name without the `Command`/`Query` suffix
- Request message: `{Name}CommandRequest` or `{Name}QueryRequest`
- Response message: `{Name}CommandResponse` or `{Name}QueryResponse`
- Dynamic query request: `DynamicQuery{PluralEntity}Request`
- Dynamic query response: `DynamicQuery{PluralEntity}Response`
- Fields use `snake_case` (proto convention), mapped case-insensitively to C# `PascalCase` properties
### Field Mapping
C# property names must match proto field names (case-insensitive):
| C# Property | Proto Field |
|---------------|-----------------|
| `UserId` | `user_id` |
| `Name` | `name` |
| `EmailAddress`| `email_address` |
| `OrderItems` | `order_items` |
### Type Mapping
| C# | Proto |
|-------------|-------------|
| `int` | `int32` |
| `long` | `int64` |
| `string` | `string` |
| `bool` | `bool` |
| `double` | `double` |
| `float` | `float` |
| `List<T>` | `repeated T`|
| complex type| `message` |
### Dynamic Query Messages (standard, reuse across entities)
```protobuf
message DynamicQueryFilter {
string path = 1;
int32 type = 2; // PoweredSoft.DynamicQuery.Core.FilterType
string value = 3;
repeated DynamicQueryFilter and = 4;
repeated DynamicQueryFilter or = 5;
}
message DynamicQuerySort {
string path = 1;
bool ascending = 2;
}
message DynamicQueryGroup {
string path = 1;
}
message DynamicQueryAggregate {
string path = 1;
int32 type = 2; // PoweredSoft.DynamicQuery.Core.AggregateType
}
```
## Source Generator Behavior
The generator in `Svrnty.CQRS.Grpc.Generators` auto-creates:
- `CommandServiceImpl` — implements `CommandService.CommandServiceBase`
- `QueryServiceImpl` — implements `QueryService.QueryServiceBase`
- `DynamicQueryServiceImpl` — implements `DynamicQueryService.DynamicQueryServiceBase`
- `GrpcServiceRegistration` — auto-registration code
Generated implementations handle:
1. Request-to-POCO property mapping
2. Validator invocation (if registered) with Google Rich Error Model errors
3. Handler invocation with proper `CancellationToken`
4. DI scope management via `IServiceScopeFactory`
## Validation in gRPC
Validation errors return `google.rpc.Status` with `BadRequest` detail containing `FieldViolations`. This is automatic — do not manually validate in handlers.
## Registration
```csharp
builder.Services.AddSvrntyCqrs(cqrs =>
{
cqrs.AddGrpc(grpc => grpc.EnableReflection()); // reflection for grpcurl/grpcui
});
```

View File

@ -1,60 +0,0 @@
---
paths:
- "*Validator*.cs"
- "*Validation*"
---
# FluentValidation Integration
## Validator Pattern
Validators live in the **same file** as their command/query, named `{CommandName}Validator`:
```csharp
public class PlaceOrderCommandValidator : AbstractValidator<PlaceOrderCommand>
{
public PlaceOrderCommandValidator()
{
RuleFor(x => x.CustomerId)
.NotEmpty()
.WithMessage("Customer is required");
RuleFor(x => x.Items)
.NotEmpty()
.WithMessage("At least one item is required");
}
}
```
## Registration
Validators are registered alongside their handler using the FluentValidation overloads:
```csharp
// Command without result + validator
services.AddCommand<DeactivateAccountCommand, DeactivateAccountCommandHandler, DeactivateAccountCommandValidator>();
// Command with result + validator
services.AddCommand<PlaceOrderCommand, int, PlaceOrderCommandHandler, PlaceOrderCommandValidator>();
// Query + validator
services.AddQuery<FetchOrderByIdQuery, Order, FetchOrderByIdQueryHandler, FetchOrderByIdQueryValidator>();
```
These come from `Svrnty.CQRS.FluentValidation.ServiceCollectionExtensions` — they call the base `AddCommand`/`AddQuery` then register `IValidator<T>`.
## Error Formats by Protocol
The framework handles validation execution automatically. Errors are returned as:
- **HTTP (Minimal API)**: RFC 7807 Problem Details — `400 Bad Request` with structured error body
- **gRPC**: Google Rich Error Model — `google.rpc.Status` with `BadRequest` detail containing `FieldViolations`
You do NOT need to manually invoke validation in handlers. The endpoint layer (Minimal API or gRPC source-generated service) handles it.
## Rules
- Inherit from `AbstractValidator<T>`, not `IValidator<T>` directly
- Define all rules in the constructor
- Always include `.WithMessage()` for user-facing error messages
- Validator constructor can inject services for async/database validation

View File

@ -1,79 +0,0 @@
{
"permissions": {
"allow": [
"Bash(dotnet build:*)",
"Bash(dotnet clean:*)",
"Bash(dotnet restore:*)",
"Bash(dotnet run:*)",
"Bash(dotnet test:*)",
"Bash(dotnet format:*)",
"Bash(dotnet add:*)",
"Bash(dotnet remove:*)",
"Bash(dotnet sln:*)",
"Bash(dotnet --list-sdks:*)",
"Bash(dotnet tool install:*)",
"Bash(dotnet ef:*)",
"Bash(grpcurl:*)",
"Bash(protogen:*)",
"Bash(mkdir:*)",
"Bash(ls:*)",
"Bash(git status:*)",
"Bash(git log:*)",
"Bash(git diff:*)",
"Bash(git branch:*)",
"Bash(git stash:*)",
"Bash(git add:*)",
"Bash(git checkout:*)",
"WebSearch",
"WebFetch(domain:learn.microsoft.com)",
"WebFetch(domain:github.com)",
"WebFetch(domain:www.nuget.org)",
"WebFetch(domain:stackoverflow.com)"
],
"ask": [
"Bash(dotnet publish:*)",
"Bash(dotnet pack:*)",
"Bash(git push:*)",
"Bash(git commit:*)"
]
},
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash -c 'INPUT=$(cat); FILE=$(echo \"$INPUT\" | jq -r \".tool_input.file_path // empty\"); if [[ -n \"$FILE\" && \"$FILE\" == *.cs ]]; then dotnet format --include \"$FILE\" --no-restore --diagnostics IDE0005 IDE0055 IDE0161 2>/dev/null || true; fi'",
"timeout": 15,
"statusMessage": "Formatting..."
}
]
},
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash -c 'INPUT=$(cat); FILE=$(echo \"$INPUT\" | jq -r \".tool_input.file_path // empty\"); if [[ -n \"$FILE\" && \"$FILE\" == *.proto ]]; then dotnet build --no-restore 2>&1 | grep -E \"(error|warning)\" | head -10; fi'",
"timeout": 30,
"statusMessage": "Validating proto..."
}
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "bash -c 'BUILD=$(dotnet build --no-restore 2>&1); if echo \"$BUILD\" | grep -q \"Build FAILED\"; then echo \"BUILD FAILED — errors below:\" >&2; echo \"$BUILD\" | grep -E \"error CS\" >&2; exit 2; fi'",
"timeout": 60,
"statusMessage": "Verifying build..."
}
]
}
]
}
}

View File

@ -1,50 +0,0 @@
{
"permissions": {
"allow": [
"Bash(dotnet clean:*)",
"Bash(dotnet run)",
"Bash(dotnet add:*)",
"Bash(timeout 5 dotnet run:*)",
"Bash(dotnet remove:*)",
"Bash(netstat:*)",
"Bash(findstr:*)",
"Bash(cat:*)",
"Bash(taskkill:*)",
"WebSearch",
"Bash(dotnet tool install:*)",
"Bash(protogen:*)",
"Bash(timeout 15 dotnet run:*)",
"Bash(where:*)",
"Bash(timeout 30 dotnet run:*)",
"Bash(timeout 60 dotnet run:*)",
"Bash(timeout 120 dotnet run:*)",
"Bash(git add:*)",
"Bash(curl:*)",
"Bash(timeout 3 cmd:*)",
"Bash(timeout:*)",
"Bash(tasklist:*)",
"Bash(dotnet build:*)",
"Bash(dotnet --list-sdks:*)",
"Bash(dotnet sln:*)",
"Bash(pkill:*)",
"Bash(python3:*)",
"Bash(grpcurl:*)",
"Bash(lsof:*)",
"Bash(xargs kill -9)",
"Bash(dotnet run:*)",
"Bash(find:*)",
"Bash(dotnet pack:*)",
"Bash(unzip:*)",
"WebFetch(domain:andrewlock.net)",
"WebFetch(domain:github.com)",
"WebFetch(domain:stackoverflow.com)",
"WebFetch(domain:www.kenmuse.com)",
"WebFetch(domain:blog.rsuter.com)",
"WebFetch(domain:natemcmaster.com)",
"WebFetch(domain:www.nuget.org)",
"Bash(mkdir:*)"
],
"deny": [],
"ask": []
}
}

View File

@ -1,103 +0,0 @@
---
name: add-command
description: Scaffold a new CQRS command with handler, validator, and DI registration. Use when the user wants to add a new command to a project.
argument-hint: <description of the command in natural language>
---
# Add Command
Scaffold a new command based on: $ARGUMENTS
## Instructions
### 1. Determine the command name
The name must express **domain intent**, not CRUD. If the user's description sounds like CRUD, translate it:
- "create a user" → `RegisterUserCommand`
- "update the order status" → contextual: `ShipOrderCommand`, `CancelOrderCommand`, `ApproveOrderCommand`
- "delete an account" → `DeactivateAccountCommand` or `CloseAccountCommand`
Ask the user to clarify if the intent is ambiguous (e.g., "update order" could mean many things).
### 2. Determine the feature folder
Place the file in the appropriate feature folder: `Features/{DomainArea}/`
If the folder doesn't exist, create it. If the project doesn't use `Features/` yet, check the existing structure and follow the same pattern.
### 3. Create the file
Create a single file `Features/{DomainArea}/{CommandName}Command.cs` containing all three classes:
```csharp
using FluentValidation;
using Svrnty.CQRS.Abstractions;
namespace {ProjectNamespace}.Features.{DomainArea};
// 1. Command POCO — record type, properties with defaults
public record {CommandName}Command
{
// Properties based on user description
// Strings default to string.Empty, collections to []
}
// 2. Validator — rules in constructor, always include .WithMessage()
public class {CommandName}CommandValidator : AbstractValidator<{CommandName}Command>
{
public {CommandName}CommandValidator()
{
// Validation rules based on command properties
}
}
// 3. Handler — always async with CancellationToken
public class {CommandName}CommandHandler : ICommandHandler<{CommandName}Command, {ResultType}>
{
public Task<{ResultType}> HandleAsync({CommandName}Command command, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
}
```
If the command has **no return value**, use `ICommandHandler<{CommandName}Command>` (single generic param) with `Task HandleAsync(...)`.
### 4. Register in DI
Find the project's service registration (typically `Program.cs` or a dedicated registration method) and add:
```csharp
// With validator (preferred)
builder.Services.AddCommand<{CommandName}Command, {ResultType}, {CommandName}CommandHandler, {CommandName}CommandValidator>();
// Without validator
builder.Services.AddCommand<{CommandName}Command, {ResultType}, {CommandName}CommandHandler>();
// No return value + validator
builder.Services.AddCommand<{CommandName}Command, {CommandName}CommandHandler, {CommandName}CommandValidator>();
```
Ensure the `using Svrnty.CQRS.FluentValidation;` namespace is imported if using the validator overload.
### 5. Proto message (if project uses gRPC)
If the project has a `Protos/` directory, add the corresponding proto message:
```protobuf
// In the CommandService definition
rpc {CommandName} ({CommandName}CommandRequest) returns ({CommandName}CommandResponse);
// Request message
message {CommandName}CommandRequest {
// fields matching command properties, snake_case, numbered sequentially
}
// Response message
message {CommandName}CommandResponse {
{result_type} result = 1; // omit if command has no return value
}
```
### 6. Summary
After creating everything, list what was created and where.

View File

@ -1,125 +0,0 @@
---
name: add-dynamic-query
description: Scaffold a dynamic query with IQueryableProviderOverride for automatic pagination, filtering, and sorting. This is the DEFAULT choice for any list/collection query.
argument-hint: <entity name and optional description>
---
# Add Dynamic Query
Scaffold a dynamic query based on: $ARGUMENTS
**This is the default query pattern.** It gives pagination, filtering, sorting, grouping, and aggregation for free.
## Instructions
### 1. Identify the entity
Determine the source entity type from the user's description. If the project uses a DAL with EF Core, the entity should already exist in the DbContext.
### 2. Determine the feature folder
Place the file in: `Features/{DomainArea}/`
### 3. Create the queryable provider
Create `Features/{DomainArea}/{Entity}QueryableProvider.cs`:
```csharp
using Svrnty.CQRS.DynamicQuery.Abstractions;
namespace {ProjectNamespace}.Features.{DomainArea};
public class {Entity}QueryableProvider : IQueryableProviderOverride<{Entity}>
{
private readonly {DbContextType} _db;
public {Entity}QueryableProvider({DbContextType} db) => _db = db;
public Task<IQueryable<{Entity}>> GetQueryableAsync(object query, CancellationToken cancellationToken = default)
=> Task.FromResult(_db.{EntityPlural}.AsQueryable());
}
```
If the entity needs a DTO projection (different shape for the API), note this for the registration step.
### 4. Create alter queryable service (if needed)
If the entity needs security filtering, tenant isolation, or default ordering, create `Features/{DomainArea}/{Entity}AlterQueryable.cs`:
```csharp
using Svrnty.CQRS.DynamicQuery.Abstractions;
namespace {ProjectNamespace}.Features.{DomainArea};
public class {Entity}AlterQueryable : IAlterQueryableService<{Entity}, {EntityOrDto}>
{
public Task<IQueryable<{Entity}>> AlterQueryableAsync(
IQueryable<{Entity}> query,
IDynamicQuery dynamicQuery,
CancellationToken cancellationToken = default)
{
// Add default ordering, security filters, etc.
return Task.FromResult(query.OrderByDescending(x => x.CreatedAt));
}
}
```
### 5. Register in DI
Find the project's service registration and add:
```csharp
// Basic — entity returned as-is
builder.Services.AddDynamicQueryWithProvider<{Entity}, {Entity}QueryableProvider>();
// With DTO projection — entity mapped to DTO
builder.Services.AddDynamicQueryWithProvider<{Entity}, {EntityDto}, {Entity}QueryableProvider>();
// With alter service (add after provider registration)
builder.Services.AddAlterQueryable<{Entity}, {EntityOrDto}, {Entity}AlterQueryable>();
```
### 6. Ensure dynamic query dependencies are registered
Check that these are registered (typically once per project, before `AddSvrntyCqrs`):
```csharp
builder.Services.AddTransient<IAsyncQueryableService, SimpleAsyncQueryableService>();
builder.Services.AddTransient<IQueryHandlerAsync, QueryHandlerAsync>();
```
If they're already present, don't add duplicates.
### 7. Proto message (if project uses gRPC)
```protobuf
// In the DynamicQueryService definition
rpc Query{EntityPlural} (DynamicQuery{EntityPlural}Request) returns (DynamicQuery{EntityPlural}Response);
// Entity message (if not already defined)
message {Entity} {
// fields matching entity properties
}
// Request — uses standard dynamic query fields
message DynamicQuery{EntityPlural}Request {
int32 page = 1;
int32 page_size = 2;
repeated DynamicQueryFilter filters = 3;
repeated DynamicQuerySort sorts = 4;
repeated DynamicQueryGroup groups = 5;
repeated DynamicQueryAggregate aggregates = 6;
}
// Response
message DynamicQuery{EntityPlural}Response {
repeated {Entity} data = 1;
int64 total_records = 2;
int32 number_of_pages = 3;
}
```
The standard `DynamicQueryFilter`, `DynamicQuerySort`, `DynamicQueryGroup`, and `DynamicQueryAggregate` messages should already be defined — reuse them.
### 8. Summary
After creating everything, list what was created and where.

View File

@ -1,86 +0,0 @@
---
name: add-query
description: Scaffold a new regular CQRS query with handler and DI registration. Use ONLY for single-entity lookups or non-queryable results. For list/collection queries, use /add-dynamic-query instead.
argument-hint: <description of the query in natural language>
---
# Add Query
Scaffold a new regular query based on: $ARGUMENTS
## Important: Is This the Right Skill?
**This skill is for the rare case.** Most queries should be dynamic queries (`/add-dynamic-query`).
Only use this for:
- Single entity by ID (e.g., `FetchOrderByIdQuery`)
- Non-entity results (e.g., `GetDashboardStatsQuery`, `CheckAvailabilityQuery`)
- Complex aggregation not expressible as IQueryable
If the user is asking for a list/collection query with filtering, sorting, or pagination → suggest `/add-dynamic-query` instead.
## Instructions
### 1. Determine the query name
Use descriptive, domain-oriented names:
- `FetchOrderByIdQuery` — fetching a single entity
- `GetSystemHealthQuery` — non-entity result
- `CheckInventoryAvailabilityQuery` — domain-specific check
### 2. Determine the feature folder
Place the file in: `Features/{DomainArea}/`
### 3. Create the file
Create `Features/{DomainArea}/{QueryName}Query.cs`:
```csharp
using Svrnty.CQRS.Abstractions;
namespace {ProjectNamespace}.Features.{DomainArea};
// 1. Query POCO
public record {QueryName}Query
{
// Parameters — e.g., Id for lookups
}
// 2. Handler
public class {QueryName}QueryHandler : IQueryHandler<{QueryName}Query, {ResultType}>
{
public Task<{ResultType}> HandleAsync({QueryName}Query query, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
}
```
### 4. Register in DI
```csharp
builder.Services.AddQuery<{QueryName}Query, {ResultType}, {QueryName}QueryHandler>();
// With validator (if needed)
builder.Services.AddQuery<{QueryName}Query, {ResultType}, {QueryName}QueryHandler, {QueryName}QueryValidator>();
```
### 5. Proto message (if project uses gRPC)
```protobuf
// In the QueryService definition
rpc {QueryName} ({QueryName}QueryRequest) returns ({QueryName}QueryResponse);
message {QueryName}QueryRequest {
// fields matching query properties
}
message {QueryName}QueryResponse {
{ResultMessage} result = 1;
}
```
### 6. Summary
After creating everything, list what was created and where.

361
CLAUDE.md
View File

@ -2,9 +2,6 @@
This file provides guidance to AI agents when working with code in this repository.
For domain-specific patterns (commands, queries, validation, gRPC, dynamic queries), see `.claude/rules/`.
For scaffolding commands, see `.claude/skills/` (`/add-command`, `/add-query`, `/add-dynamic-query`).
## Project Overview
This is Svrnty.CQRS, a modern implementation of Command Query Responsibility Segregation (CQRS) for .NET 10. It was forked from PoweredSoft.CQRS and provides:
@ -14,10 +11,11 @@ This is Svrnty.CQRS, a modern implementation of Command Query Responsibility Seg
- Automatic gRPC endpoint generation with source generators and Google Rich Error Model validation
- Dynamic query capabilities (filtering, sorting, grouping, aggregation)
- FluentValidation support with RFC 7807 Problem Details (HTTP) and Google Rich Error Model (gRPC)
- AOT (Ahead-of-Time) compilation compatibility for core packages (where dependencies allow)
## Solution Structure
The solution contains projects organized by responsibility:
The solution contains 11 projects organized by responsibility (10 packages + 1 sample project):
**Abstractions (interfaces and contracts only):**
- `Svrnty.CQRS.Abstractions` - Core interfaces (ICommandHandler, IQueryHandler, discovery contracts)
@ -26,42 +24,81 @@ The solution contains projects organized by responsibility:
**Implementation:**
- `Svrnty.CQRS` - Core discovery and registration logic
- `Svrnty.CQRS.MinimalApi` - Minimal API endpoint mapping for commands/queries
- `Svrnty.CQRS.MinimalApi` - Minimal API endpoint mapping for commands/queries (recommended for HTTP)
- `Svrnty.CQRS.DynamicQuery` - PoweredSoft.DynamicQuery integration for advanced filtering
- `Svrnty.CQRS.DynamicQuery.MinimalApi` - Minimal API endpoint mapping for dynamic queries
- `Svrnty.CQRS.FluentValidation` - Validation integration helpers
- `Svrnty.CQRS.Grpc` - gRPC service implementation support
- `Svrnty.CQRS.Grpc.Generators` - Source generator for .proto files and gRPC service implementations
**Sample:**
- `Svrnty.Sample` - Demo project showcasing both HTTP and gRPC endpoints
**Sample Projects:**
- `Svrnty.Sample` - Comprehensive demo project showcasing both HTTP and gRPC endpoints
**Key Design Principle:** Abstractions projects contain ONLY interfaces/attributes with minimal dependencies. Implementation projects depend on abstractions.
**Key Design Principle:** Abstractions projects contain ONLY interfaces/attributes with minimal dependencies. Implementation projects depend on abstractions. This allows consumers to reference abstractions without pulling in heavy implementation dependencies.
## Build Commands
```bash
dotnet restore # Restore dependencies
dotnet build # Build entire solution
dotnet build -c Release # Build in Release mode
dotnet pack -c Release -o ./artifacts -p:Version=1.0.0 # Create NuGet packages
# Restore dependencies
dotnet restore
# Build entire solution
dotnet build
# Build in Release mode
dotnet build -c Release
# Create NuGet packages (with version)
dotnet pack -c Release -o ./artifacts -p:Version=1.0.0
# Build specific project
dotnet build Svrnty.CQRS/Svrnty.CQRS.csproj
```
## Testing
No test projects currently exist. When adding tests:
- Place them in a `tests/` directory
This repository does not currently contain test projects. When adding tests:
- Place them in a `tests/` directory or alongside source projects
- Name them with `.Tests` suffix (e.g., `Svrnty.CQRS.Tests`)
## Architecture
### Core CQRS Pattern
The framework uses handler interfaces that follow this pattern:
```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)
IQueryHandler<TQuery, TResult>
Task<TResult> HandleAsync(TQuery query, CancellationToken cancellationToken = default)
```
### Metadata-Driven Discovery
The framework uses a **metadata pattern** for runtime discovery:
1. `services.AddCommand<TCommand, THandler>()` registers the handler in DI and creates `ICommandMeta` metadata as a singleton
2. Discovery services (`ICommandDiscovery`, `IQueryDiscovery`) query all registered metadata from DI
3. Endpoint mapping (HTTP and gRPC) uses discovery to dynamically generate endpoints at startup
1. When you register a handler using `services.AddCommand<TCommand, THandler>()`, it:
- Registers the handler in DI as `ICommandHandler<TCommand, THandler>`
- Creates metadata (`ICommandMeta`) describing the command type, handler type, and result type
- Stores metadata as singleton in DI
2. Discovery services (`ICommandDiscovery`, `IQueryDiscovery`) implemented in `Svrnty.CQRS`:
- Query all registered metadata from DI container
- Provide lookup methods: `GetCommand(string name)`, `GetCommands()`, etc.
3. Endpoint mapping (HTTP and gRPC) uses discovery to:
- Enumerate all registered commands/queries
- Dynamically generate endpoints at application startup
- Apply naming conventions (convert to lowerCamelCase)
- Generate gRPC service implementations via source generators
**Key Files:**
- `Svrnty.CQRS.Abstractions/Discovery/` - Metadata interfaces
@ -70,77 +107,307 @@ The framework uses a **metadata pattern** for runtime discovery:
- `Svrnty.CQRS.DynamicQuery.MinimalApi/EndpointRouteBuilderExtensions.cs` - Dynamic query endpoint generation
- `Svrnty.CQRS.Grpc.Generators/` - gRPC service generation via source generators
### Integration
### Integration Options
Commands and queries can be exposed via HTTP (Minimal API), gRPC, or both simultaneously. The fluent configuration API handles all wiring:
There are two primary integration options for exposing commands and queries:
#### Option 1: gRPC (Recommended for performance-critical scenarios)
The **Svrnty.CQRS.Grpc** package with **Svrnty.CQRS.Grpc.Generators** source generator provides high-performance gRPC endpoints:
**Registration:**
```csharp
builder.Services.AddSvrntyCqrs(cqrs =>
{
cqrs.AddGrpc(grpc => grpc.EnableReflection());
cqrs.AddMinimalApi();
});
var builder = WebApplication.CreateBuilder(args);
app.UseSvrntyCqrs(); // Maps all endpoints
// Register CQRS services
builder.Services.AddSvrntyCQRS();
builder.Services.AddDefaultCommandDiscovery();
builder.Services.AddDefaultQueryDiscovery();
// Add your commands and queries
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler>();
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
// Add gRPC support
builder.Services.AddGrpc();
var app = builder.Build();
// Map auto-generated gRPC service implementations
app.MapGrpcService<CommandServiceImpl>();
app.MapGrpcService<QueryServiceImpl>();
// Enable gRPC reflection for tools like grpcurl
app.MapGrpcReflectionService();
app.Run();
```
See `Svrnty.Sample/Program.cs` for a complete working example.
**How It Works:**
1. Define `.proto` files in `Protos/` directory with your commands/queries as messages
2. Source generator automatically creates `CommandServiceImpl` and `QueryServiceImpl` implementations
3. Property names in C# commands must match proto field names (case-insensitive)
4. FluentValidation is automatically integrated with **Google Rich Error Model** for structured validation errors
5. Validation errors return `google.rpc.Status` with `BadRequest` containing `FieldViolations`
**Features:**
- High-performance binary protocol
- Automatic service implementation generation at compile time
- Google Rich Error Model for structured validation errors
- Full FluentValidation integration
- gRPC reflection support for development tools
- Suitable for microservices, internal APIs, and low-latency scenarios
**Key Files:**
- `Svrnty.CQRS.Grpc/` - Runtime support for gRPC services
- `Svrnty.CQRS.Grpc.Generators/` - Source generator for service implementations
#### Option 2: HTTP via Minimal API (Recommended for web/browser scenarios)
The **Svrnty.CQRS.MinimalApi** package provides HTTP endpoints for CQRS commands and queries:
**Registration:**
```csharp
var builder = WebApplication.CreateBuilder(args);
// Register CQRS services
builder.Services.AddSvrntyCQRS();
builder.Services.AddDefaultCommandDiscovery();
builder.Services.AddDefaultQueryDiscovery();
// Add your commands and queries
builder.Services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler>();
builder.Services.AddQuery<PersonQuery, IQueryable<Person>, PersonQueryHandler>();
// Add Swagger (optional)
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Map endpoints (this creates routes automatically)
app.MapSvrntyCommands(); // Maps all commands to POST /api/command/{name}
app.MapSvrntyQueries(); // Maps all queries to POST/GET /api/query/{name}
app.Run();
```
**How It Works:**
1. Extension methods iterate through `ICommandDiscovery` and `IQueryDiscovery`
2. For each command/query, creates Minimal API endpoints using `MapPost()`/`MapGet()`
3. Applies naming conventions (lowerCamelCase)
4. Respects `[CommandControllerIgnore]` and `[QueryControllerIgnore]` attributes
5. Integrates with `ICommandAuthorizationService` and `IQueryAuthorizationService`
6. Supports OpenAPI/Swagger documentation
**Features:**
- Queries support both POST (with JSON body) and GET (with query string parameters)
- Commands only support POST with JSON body
- Authorization via authorization services (returns 401/403 status codes)
- Customizable route prefixes: `MapSvrntyCommands("my-prefix")`
- Automatic OpenAPI tags: "Commands" and "Queries"
- RFC 7807 Problem Details for validation errors
- Full Swagger/OpenAPI support
**Key Files:**
- `Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs` - Main implementation
#### Option 3: Both gRPC and HTTP (Dual Protocol Support)
You can enable both protocols simultaneously, allowing clients to choose their preferred protocol:
```csharp
var builder = WebApplication.CreateBuilder(args);
// Register CQRS services
builder.Services.AddSvrntyCQRS();
builder.Services.AddDefaultCommandDiscovery();
builder.Services.AddDefaultQueryDiscovery();
// Add commands and queries
AddCommands(builder.Services);
AddQueries(builder.Services);
// Add both gRPC and HTTP support
builder.Services.AddGrpc();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Map both gRPC and HTTP endpoints
app.MapGrpcService<CommandServiceImpl>();
app.MapGrpcService<QueryServiceImpl>();
app.MapGrpcReflectionService();
app.MapSvrntyCommands();
app.MapSvrntyQueries();
app.Run();
```
**Benefits:**
- Single codebase supports multiple protocols
- gRPC for high-performance, low-latency scenarios (microservices, internal APIs)
- HTTP for web browsers, legacy clients, and public APIs
- Same commands, queries, and validation logic for both protocols
- Swagger UI available for HTTP endpoints, gRPC reflection for gRPC clients
### Dynamic Query System
Dynamic queries provide OData-like filtering capabilities:
**Core Components:**
- `IDynamicQuery<TSource, TDestination>` - Interface with GetFilters(), GetSorts(), GetGroups(), GetAggregates()
- `IQueryableProvider<TSource>` - Provides base IQueryable to query against
- `IAlterQueryableService<TSource, TDestination>` - Middleware to modify queries (e.g., security filters)
- `DynamicQueryHandler<TSource, TDestination>` - Executes queries using PoweredSoft.DynamicQuery
**Request Flow:**
1. HTTP request with filters/sorts/aggregates
2. Minimal API endpoint receives request
3. DynamicQueryHandler gets base queryable from IQueryableProvider
4. Applies alterations from all registered IAlterQueryableService instances
5. Builds PoweredSoft query criteria
6. Executes and returns IQueryExecutionResult
**Registration Example:**
```csharp
// Register dynamic query
services.AddDynamicQuery<Person, PersonDto>()
.AddDynamicQueryWithProvider<Person, PersonQueryableProvider>()
.AddAlterQueryable<Person, PersonDto, SecurityFilter>();
// Map dynamic query endpoints
app.MapSvrntyDynamicQueries(); // Creates POST/GET /api/query/{queryName} endpoints
```
**Key Files:**
- `Svrnty.CQRS.DynamicQuery/DynamicQueryHandler.cs` - Query execution logic
- `Svrnty.CQRS.DynamicQuery.MinimalApi/EndpointRouteBuilderExtensions.cs` - HTTP endpoint mapping
## Package Configuration
- **Target Framework**: `net10.0` (except DynamicQuery.Abstractions: `netstandard2.1;net10.0`)
All projects target .NET 10.0 and use C# 14, sharing common configuration:
- **Target Framework**: `net10.0` (except DynamicQuery.Abstractions which multi-targets `netstandard2.1;net10.0`)
- **Language Version**: C# 14
- **IsAotCompatible**: Currently set but not enforced (many dependencies are not AOT-compatible yet)
- **Symbols**: Portable debug symbols with source, published as `.snupkg`
- **NuGet metadata**: Icon, README, license (MIT), and repository URL included in packages
- **Authors**: David Lebee, Mathias Beaulieu-Duncan
- **Repository**: https://git.openharbor.io/svrnty/dotnet-cqrs
### Key Dependencies
### Package Dependencies
**Core Dependencies:**
- **Microsoft.Extensions.DependencyInjection.Abstractions**: 10.0.0
- **FluentValidation**: 11.11.0
- **PoweredSoft.DynamicQuery**: 3.0.1
- **Grpc.AspNetCore**: 2.68.0+
- **Grpc.StatusProto**: 2.71.0+ (Rich Error Model)
- **Microsoft.CodeAnalysis.CSharp**: 5.0.0-2.final (source generators, targets netstandard2.0)
- **Pluralize.NET**: 1.0.2
**gRPC Dependencies (for Svrnty.CQRS.Grpc):**
- **Grpc.AspNetCore**: 2.68.0 or later
- **Grpc.AspNetCore.Server.Reflection**: 2.71.0 or later (optional, for reflection)
- **Grpc.StatusProto**: 2.71.0 or later (for Rich Error Model validation)
- **Grpc.Tools**: 2.76.0 or later (for .proto compilation)
**Source Generator Dependencies (for Svrnty.CQRS.Grpc.Generators):**
- **Microsoft.CodeAnalysis.CSharp**: 5.0.0-2.final
- **Microsoft.CodeAnalysis.Analyzers**: 3.11.0
- **Microsoft.Build.Utilities.Core**: 17.0.0
- Targets: netstandard2.0 (for Roslyn compatibility)
## Publishing
NuGet packages publish automatically via GitHub Actions (`.github/workflows/publish-nugets.yml`) when a release is created. Tag becomes the version.
NuGet packages are published automatically via GitHub Actions when a release is created:
**Workflow:** `.github/workflows/publish-nugets.yml`
1. Triggered on release publication
2. Extracts version from release tag
3. Runs `dotnet pack -c Release -p:Version={tag}`
4. Pushes to NuGet.org using `NUGET_API_KEY` secret
**Manual publish:**
```bash
# Manual publish
# Create packages with specific version
dotnet pack -c Release -o ./artifacts -p:Version=1.2.3
# Push to NuGet
dotnet nuget push ./artifacts/*.nupkg --source https://api.nuget.org/v3/index.json --api-key YOUR_KEY
```
## Development Workflow
**Adding a New Feature to the Framework:**
**Adding a New Command/Query Handler:**
1. Create command/query POCO in consumer project
2. Implement handler: `ICommandHandler<TCommand, TResult>`
3. Register in DI: `services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler>()`
4. (Optional) Add validator: `services.AddTransient<IValidator<CreatePersonCommand>, Validator>()`
5. Controller endpoint is automatically generated
**Adding a New Feature to Framework:**
1. Add interface to appropriate Abstractions project
2. Implement in corresponding implementation project
3. Update ServiceCollectionExtensions with registration method
4. When extending discovery, create corresponding metadata classes (implement ICommandMeta/IQueryMeta)
4. Ensure all projects maintain AOT compatibility (unless AspNetCore-specific)
5. Update package version and release notes
**Naming Conventions:**
- Commands/Queries: Use `[CommandName]` or `[QueryName]` attribute for custom names
- Default naming: Strips "Command"/"Query" suffix, converts to lowerCamelCase
- Example: `CreatePersonCommand` -> `createPerson` endpoint
## C# 14 Language Features
The project uses C# 14. Be aware of these reserved keywords:
- **`field`**: Contextual keyword in property accessors for implicit backing fields
- **`extension`**: Reserved for extension containers; use `@extension` for identifiers
The project now uses C# 14, which introduces several new features. Be aware of these breaking changes:
**Potential Breaking Changes:**
- **`field` keyword**: New contextual keyword in property accessors for implicit backing fields
- **`extension` keyword**: Reserved for extension containers; use `@extension` for identifiers
- **`partial` return type**: Cannot use `partial` as return type without escaping
- **Span<T> overload resolution**: New implicit conversions may select different overloads
- **`scoped` as lambda modifier**: Always treated as modifier in lambda parameters
**New Features Available:**
- Extension members (static extension members and extension properties)
- Implicit span conversions
- Unbound generic types with `nameof`
- Lambda parameter modifiers without type specification
- Partial instance constructors and events
- Null-conditional assignment (`?.=` and `?[]=`)
The codebase currently compiles without warnings on C# 14.
## Important Implementation Notes
1. **Async Everywhere**: All handlers are async. Always support CancellationToken.
2. **Generic Type Safety**: Framework relies heavily on generics. Maintain strong typing.
3. **Endpoint Mapping Timing**: Discovery services must be registered before calling `UseSvrntyCqrs()`.
4. **AOT Compatibility**: `IsAotCompatible` is set but not enforced — many dependencies are not AOT-compatible.
1. **AOT Compatibility**: Currently not enforced. The `IsAotCompatible` property is set on some projects but many dependencies (including FluentValidation, PoweredSoft.DynamicQuery) are not AOT-compatible. Future work may address this.
2. **Async Everywhere**: All handlers are async. Always support CancellationToken.
3. **Generic Type Safety**: Framework relies heavily on generics for compile-time safety. When adding features, maintain strong typing.
4. **Metadata Pattern**: When extending discovery, always create corresponding metadata classes (implement ICommandMeta/IQueryMeta).
5. **Endpoint Mapping Timing**: Endpoints are mapped at application startup. Discovery services must be registered before calling `MapSvrntyCommands()`/`MapSvrntyQueries()` or mapping gRPC services.
6. **FluentValidation Integration**:
- For HTTP: Validation happens automatically in the Minimal API pipeline. Errors return RFC 7807 Problem Details.
- For gRPC: Validation happens automatically via source-generated services. Errors return Google Rich Error Model with structured FieldViolations.
- The framework REGISTERS validators in DI; actual validation execution is handled by the endpoint implementations.
7. **DynamicQuery Interceptors**: Support up to 5 interceptors per query type. Interceptors modify PoweredSoft DynamicQuery behavior.
## Common Code Locations
- Handler interfaces: `Svrnty.CQRS.Abstractions/ICommandHandler.cs`, `IQueryHandler.cs`
- Discovery: `Svrnty.CQRS/Discovery/`
- Discovery implementations: `Svrnty.CQRS/Discovery/`
- Service registration: `*/ServiceCollectionExtensions.cs` in each project
- HTTP endpoints: `Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs`
- Dynamic queries: `Svrnty.CQRS.DynamicQuery/DynamicQueryHandler.cs`
- gRPC generators: `Svrnty.CQRS.Grpc.Generators/`
- Sample: `Svrnty.Sample/`
- HTTP endpoint mapping: `Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs`
- Dynamic query logic: `Svrnty.CQRS.DynamicQuery/DynamicQueryHandler.cs`
- Dynamic query endpoints: `Svrnty.CQRS.DynamicQuery.MinimalApi/EndpointRouteBuilderExtensions.cs`
- gRPC support: `Svrnty.CQRS.Grpc/` runtime, `Svrnty.CQRS.Grpc.Generators/` source generators
- Sample application: `Svrnty.Sample/` - demonstrates both HTTP and gRPC integration

View File

@ -26,6 +26,10 @@
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Svrnty.CQRS.Abstractions\Svrnty.CQRS.Abstractions.csproj" />
</ItemGroup>

View File

@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Configuration;
namespace Svrnty.CQRS.MinimalApi;
namespace Svrnty.CQRS;
public static class WebApplicationExtensions
{