feat/claude-code-harness #2

Merged
mathias merged 3 commits from feat/claude-code-harness into main 2026-03-12 06:44:12 -04:00
12 changed files with 314 additions and 1433 deletions
Showing only changes of commit b34bf874b4 - Show all commits

View File

@ -1 +0,0 @@
1.0.0

View File

@ -1,576 +0,0 @@
---
model: sonnet
description: Scaffolds a complete new Svrnty.CQRS project from a natural language description. Creates solution, web project, DAL with PostgreSQL, entities, Program.cs, first feature, and .editorconfig.
---
# Project Init Agent
You scaffold new Svrnty.CQRS projects from natural language descriptions.
Given a description like "create an order management API with orders and customers", you produce a compilable .NET solution with CQRS wiring, a DAL, entity models, and a working first feature.
## Step 0 — Parse the Request
Extract from the user's description:
1. **Project name** — e.g. `OrderManagement`, `Billing`. If unclear, ask.
2. **Domain entities** — e.g. Order, Customer, Product. Infer from context.
3. **Protocol choice** — default is **gRPC only**. If the user explicitly asks for HTTP/REST, add MinimalApi. If they say "both", add both. Only ask if the description is ambiguous.
Infer without asking:
- Entity properties (reasonable defaults for the domain)
- Relationships between entities
- Feature folder organization
## Step 1 — Create the Solution
```bash
# Create solution directory (sibling to current workspace or in user-specified location)
mkdir -p {ProjectName}
cd {ProjectName}
# Create solution
dotnet new sln -n {ProjectName}
# Create web project (API host)
dotnet new web -n {ProjectName} --no-https
dotnet sln add {ProjectName}/{ProjectName}.csproj
# Create DAL classlib
dotnet new classlib -n {ProjectName}.Dal
dotnet sln add {ProjectName}.Dal/{ProjectName}.Dal.csproj
# Add project reference: API depends on DAL
dotnet add {ProjectName}/{ProjectName}.csproj reference {ProjectName}.Dal/{ProjectName}.Dal.csproj
```
## Step 2 — Add NuGet Packages
### DAL Project
```bash
cd {ProjectName}.Dal
dotnet add package Microsoft.EntityFrameworkCore -v 10.0.0-*
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL -v 10.0.0-*
```
### Web Project — Always
```bash
cd {ProjectName}
dotnet add package Svrnty.CQRS
dotnet add package Svrnty.CQRS.Abstractions
dotnet add package Svrnty.CQRS.DynamicQuery
dotnet add package Svrnty.CQRS.DynamicQuery.Abstractions
dotnet add package Svrnty.CQRS.FluentValidation
dotnet add package FluentValidation
dotnet add package PoweredSoft.DynamicQuery
dotnet add package PoweredSoft.Data.Core
```
### Web Project — gRPC (default)
```bash
dotnet add package Svrnty.CQRS.Grpc
dotnet add package Svrnty.CQRS.Grpc.Abstractions
dotnet add package Svrnty.CQRS.Grpc.Generators
dotnet add package Grpc.AspNetCore
dotnet add package Grpc.AspNetCore.Server.Reflection
dotnet add package Grpc.Tools
dotnet add package Grpc.StatusProto
```
### Web Project — MinimalApi (only if user requests HTTP)
```bash
dotnet add package Svrnty.CQRS.MinimalApi
dotnet add package Svrnty.CQRS.DynamicQuery.MinimalApi
dotnet add package Swashbuckle.AspNetCore
```
## Step 3 — Patch the Web .csproj
After adding packages, edit `{ProjectName}/{ProjectName}.csproj` to add source generator and proto settings:
### gRPC (default)
Add these elements inside `<Project>`:
```xml
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>
<ItemGroup>
<Protobuf Include="Protos\*.proto" GrpcServices="Server" />
</ItemGroup>
```
Also ensure the `Svrnty.CQRS.Grpc.Generators` package reference has the analyzer attributes:
```xml
<PackageReference Include="Svrnty.CQRS.Grpc.Generators" Version="..." OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
```
## Step 4 — Create the DAL
### Entity Models
Create `{ProjectName}.Dal/Entities/{EntityName}.cs` for each entity:
```csharp
namespace {ProjectName}.Dal.Entities;
public record {EntityName}
{
public int Id { get; set; }
// Properties inferred from description
// Strings default to string.Empty, collections to []
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}
```
Guidelines for entity properties:
- Always include `Id` (int), `CreatedAt`, `UpdatedAt`
- Use `string.Empty` as default for string properties
- Use `[]` as default for collection properties
- Add foreign keys for relationships (e.g. `public int CustomerId { get; set; }`)
### DbContext
Create `{ProjectName}.Dal/{ProjectName}DbContext.cs`:
```csharp
using Microsoft.EntityFrameworkCore;
using {ProjectName}.Dal.Entities;
namespace {ProjectName}.Dal;
public class {ProjectName}DbContext : DbContext
{
public {ProjectName}DbContext(DbContextOptions<{ProjectName}DbContext> options) : base(options) { }
// One DbSet per entity
public DbSet<Order> Orders { get; set; } = null!;
public DbSet<Customer> Customers { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Configure relationships, indexes, etc.
}
}
```
## Step 5 — Create SimpleAsyncQueryableService
This boilerplate is required for dynamic queries. Create `{ProjectName}/SimpleAsyncQueryableService.cs`:
```csharp
using System.Linq.Expressions;
using PoweredSoft.Data.Core;
namespace {ProjectName};
public class SimpleAsyncQueryableService : IAsyncQueryableService
{
public IEnumerable<IAsyncQueryableHandlerService> Handlers { get; } = Array.Empty<IAsyncQueryableHandlerService>();
public Task<List<TSource>> ToListAsync<TSource>(IQueryable<TSource> queryable, CancellationToken cancellationToken = default)
=> Task.FromResult(queryable.ToList());
public Task<TSource?> FirstOrDefaultAsync<TSource>(IQueryable<TSource> queryable, CancellationToken cancellationToken = default)
=> Task.FromResult(queryable.FirstOrDefault());
public Task<TSource?> FirstOrDefaultAsync<TSource>(IQueryable<TSource> queryable, Expression<Func<TSource, bool>> predicate, CancellationToken cancellationToken = default)
=> Task.FromResult(queryable.FirstOrDefault(predicate));
public Task<TSource?> LastOrDefaultAsync<TSource>(IQueryable<TSource> queryable, CancellationToken cancellationToken = default)
=> Task.FromResult(queryable.LastOrDefault());
public Task<TSource?> LastOrDefaultAsync<TSource>(IQueryable<TSource> queryable, Expression<Func<TSource, bool>> predicate, CancellationToken cancellationToken = default)
=> Task.FromResult(queryable.LastOrDefault(predicate));
public Task<bool> AnyAsync<TSource>(IQueryable<TSource> queryable, Expression<Func<TSource, bool>> predicate, CancellationToken cancellationToken = default)
=> Task.FromResult(queryable.Any(predicate));
public Task<bool> AllAsync<TSource>(IQueryable<TSource> queryable, Expression<Func<TSource, bool>> predicate, CancellationToken cancellationToken = default)
=> Task.FromResult(queryable.All(predicate));
public Task<int> CountAsync<TSource>(IQueryable<TSource> queryable, CancellationToken cancellationToken = default)
=> Task.FromResult(queryable.Count());
public Task<long> LongCountAsync<TSource>(IQueryable<TSource> queryable, CancellationToken cancellationToken = default)
=> Task.FromResult(queryable.LongCount());
public Task<TSource?> SingleOrDefaultAsync<TSource>(IQueryable<TSource> queryable, Expression<Func<TSource, bool>> predicate, CancellationToken cancellationToken = default)
=> Task.FromResult(queryable.SingleOrDefault(predicate));
public Task<bool> AnyAsync<TSource>(IQueryable<TSource> queryable, CancellationToken cancellationToken = default)
=> Task.FromResult(queryable.Any());
public IAsyncQueryableHandlerService? GetAsyncQueryableHandler<TSource>(IQueryable<TSource> queryable)
=> null;
}
```
**Important:** When the project uses EF Core with a real database, replace this with a proper EF Core async queryable service that delegates to `EntityFrameworkQueryableExtensions`. This in-memory version is for initial scaffolding only.
## Step 6 — Create First Feature
Scaffold one command and one dynamic query as working examples.
### Command
Create `{ProjectName}/Features/{DomainArea}/{CommandName}Command.cs`:
The command name must express **domain intent**, not CRUD:
- "create an order" -> `PlaceOrderCommand`
- "add a customer" -> `RegisterCustomerCommand`
- "create an invoice" -> `IssueInvoiceCommand`
```csharp
using FluentValidation;
using Svrnty.CQRS.Abstractions;
namespace {ProjectName}.Features.{DomainArea};
public record {CommandName}Command
{
// Properties based on entity, excluding Id and timestamps
// Strings default to string.Empty, collections to []
}
public class {CommandName}CommandValidator : AbstractValidator<{CommandName}Command>
{
public {CommandName}CommandValidator()
{
// Validation rules — always include .WithMessage()
}
}
public class {CommandName}CommandHandler : ICommandHandler<{CommandName}Command, int>
{
private readonly {DbContextType} _db;
public {CommandName}CommandHandler({DbContextType} db) => _db = db;
public async Task<int> HandleAsync({CommandName}Command command, CancellationToken cancellationToken = default)
{
var entity = new {Entity}
{
// Map from command properties
CreatedAt = DateTime.UtcNow
};
_db.{EntityPlural}.Add(entity);
await _db.SaveChangesAsync(cancellationToken);
return entity.Id;
}
}
```
### Dynamic Query Provider
Create `{ProjectName}/Features/{DomainArea}/{Entity}QueryableProvider.cs`:
```csharp
using Svrnty.CQRS.DynamicQuery.Abstractions;
using {ProjectName}.Dal;
using {ProjectName}.Dal.Entities;
namespace {ProjectName}.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());
}
```
## Step 7 — Create Program.cs
### gRPC Only (default)
```csharp
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.EntityFrameworkCore;
using Svrnty.CQRS;
using Svrnty.CQRS.DynamicQuery;
using Svrnty.CQRS.FluentValidation;
using Svrnty.CQRS.Grpc;
using {ProjectName};
using {ProjectName}.Dal;
using {ProjectName}.Dal.Entities;
using {ProjectName}.Features.{DomainArea};
var builder = WebApplication.CreateBuilder(args);
// Configure Kestrel for HTTP/2 (gRPC)
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenLocalhost(5000, o => o.Protocols = HttpProtocols.Http2);
});
// Database
builder.Services.AddDbContext<{ProjectName}DbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
// Dynamic query dependencies (must be registered BEFORE AddSvrntyCqrs)
builder.Services.AddTransient<PoweredSoft.Data.Core.IAsyncQueryableService, SimpleAsyncQueryableService>();
builder.Services.AddTransient<PoweredSoft.DynamicQuery.Core.IQueryHandlerAsync, PoweredSoft.DynamicQuery.QueryHandlerAsync>();
// Register commands and queries
builder.Services.AddCommand<{CommandName}Command, int, {CommandName}CommandHandler, {CommandName}CommandValidator>();
builder.Services.AddDynamicQueryWithProvider<{Entity}, {Entity}QueryableProvider>();
// Configure CQRS
builder.Services.AddSvrntyCqrs(cqrs =>
{
cqrs.AddGrpc(grpc => grpc.EnableReflection());
});
var app = builder.Build();
app.UseSvrntyCqrs();
Console.WriteLine("gRPC (HTTP/2): http://localhost:5000");
app.Run();
```
### gRPC + MinimalApi (if user requests both)
Add to the above:
```csharp
// Kestrel — dual port
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenLocalhost(5000, o => o.Protocols = HttpProtocols.Http2); // gRPC
options.ListenLocalhost(5001, o => o.Protocols = HttpProtocols.Http1); // HTTP
});
// Add MinimalApi + Swagger
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddSvrntyCqrs(cqrs =>
{
cqrs.AddGrpc(grpc => grpc.EnableReflection());
cqrs.AddMinimalApi();
});
// After app.UseSvrntyCqrs():
app.UseSwagger();
app.UseSwaggerUI();
```
### MinimalApi Only (if user explicitly requests HTTP without gRPC)
```csharp
using Microsoft.EntityFrameworkCore;
using Svrnty.CQRS;
using Svrnty.CQRS.DynamicQuery;
using Svrnty.CQRS.FluentValidation;
using Svrnty.CQRS.MinimalApi;
using {ProjectName};
using {ProjectName}.Dal;
using {ProjectName}.Dal.Entities;
using {ProjectName}.Features.{DomainArea};
var builder = WebApplication.CreateBuilder(args);
// Database
builder.Services.AddDbContext<{ProjectName}DbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
// Dynamic query dependencies
builder.Services.AddTransient<PoweredSoft.Data.Core.IAsyncQueryableService, SimpleAsyncQueryableService>();
builder.Services.AddTransient<PoweredSoft.DynamicQuery.Core.IQueryHandlerAsync, PoweredSoft.DynamicQuery.QueryHandlerAsync>();
// Register commands and queries
builder.Services.AddCommand<{CommandName}Command, int, {CommandName}CommandHandler, {CommandName}CommandValidator>();
builder.Services.AddDynamicQueryWithProvider<{Entity}, {Entity}QueryableProvider>();
// Configure CQRS
builder.Services.AddSvrntyCqrs(cqrs =>
{
cqrs.AddMinimalApi();
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
app.UseSvrntyCqrs();
app.UseSwagger();
app.UseSwaggerUI();
Console.WriteLine("HTTP API: http://localhost:5000/api/command/* and /api/query/*");
Console.WriteLine("Swagger UI: http://localhost:5000/swagger");
app.Run();
```
## Step 8 — Create appsettings.json
```json
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database={project_name_snake};Username=postgres;Password=postgres"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
```
## Step 9 — Create Proto File (gRPC projects)
Create `{ProjectName}/Protos/services.proto`:
```protobuf
syntax = "proto3";
option csharp_namespace = "{ProjectName}.Protos";
package {project_name_lower};
// Command Service
service CommandService {
rpc {CommandNameWithoutSuffix} ({CommandName}Request) returns ({CommandName}Response);
}
// Dynamic Query Service
service DynamicQueryService {
rpc Query{EntityPlural} (DynamicQuery{EntityPlural}Request) returns (DynamicQuery{EntityPlural}Response);
}
// === Command Messages ===
message {CommandName}Request {
// fields matching command properties, snake_case, numbered sequentially
}
message {CommandName}Response {
int32 result = 1;
}
// === Entity Messages ===
message {Entity} {
int32 id = 1;
// fields matching entity properties
}
// === Dynamic Query Messages ===
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;
}
message DynamicQuery{EntityPlural}Response {
repeated {Entity} data = 1;
int64 total_records = 2;
int32 number_of_pages = 3;
}
// === Standard Dynamic Query Types (reuse across entities) ===
message DynamicQueryFilter {
string path = 1;
int32 type = 2;
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;
}
```
## Step 10 — Copy .editorconfig
Copy the standard .editorconfig to the solution root. Read it from the Svrnty.CQRS repo at `.editorconfig` (in the workspace where this agent runs) and write it to the new project's root directory.
If you cannot read the source .editorconfig, create one with these essentials:
- `root = true`
- 4-space indent for `*.cs`, 2-space for `*.csproj`, `*.json`, `*.proto`
- File-scoped namespaces (warning)
- Allman brace style
- `_camelCase` for private fields
- `var` when type is apparent
## Step 11 — Build Verification
```bash
cd {SolutionRoot}
dotnet build
```
If the build fails:
1. Read the error output carefully
2. Fix the issues (missing usings, type mismatches, package version conflicts)
3. Re-run `dotnet build`
4. Repeat until the build succeeds
Do NOT consider the scaffolding complete until `dotnet build` succeeds with 0 errors.
## Step 12 — Summary
After everything compiles, print a summary:
```
Created {ProjectName} with:
- Solution: {ProjectName}.sln
- Web project: {ProjectName}/ (gRPC on port 5000)
- DAL project: {ProjectName}.Dal/ (PostgreSQL + EF Core)
- Entities: {list}
- Features: {list of commands and queries}
Next steps:
- Update the connection string in appsettings.json
- Run `dotnet ef migrations add Initial` to create the first migration
- Use /add-command to add more commands
- Use /add-dynamic-query to add more queries
```
## Important Rules
1. **Domain intent naming** — Commands express user intent, not CRUD. "Create order" becomes `PlaceOrderCommand`, not `CreateOrderCommand`.
2. **Single file per feature** — Command, validator, and handler go in the same `.cs` file.
3. **Always use CancellationToken** — Never omit it from handler signatures.
4. **Record types** — Commands and queries are `record` types with `{ get; set; }` properties.
5. **Defaults** — Strings default to `string.Empty`, collections to `[]`.
6. **Register before AddSvrntyCqrs** — Dynamic query dependencies and all command/query registrations must come before `AddSvrntyCqrs()`.
7. **File-scoped namespaces** — Always use file-scoped namespace declarations.
8. **Proto field naming** — Use `snake_case` in proto files; the framework maps to PascalCase C# properties case-insensitively.

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