diff --git a/.claude/.harness-version b/.claude/.harness-version deleted file mode 100644 index 3eefcb9..0000000 --- a/.claude/.harness-version +++ /dev/null @@ -1 +0,0 @@ -1.0.0 diff --git a/.claude/agents/project-init.md b/.claude/agents/project-init.md deleted file mode 100644 index b5138e4..0000000 --- a/.claude/agents/project-init.md +++ /dev/null @@ -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 ``: - -```xml - - true - $(BaseIntermediateOutputPath)Generated - - - - - -``` - -Also ensure the `Svrnty.CQRS.Grpc.Generators` package reference has the analyzer attributes: - -```xml - -``` - -## 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 Orders { get; set; } = null!; - public DbSet 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 Handlers { get; } = Array.Empty(); - - public Task> ToListAsync(IQueryable queryable, CancellationToken cancellationToken = default) - => Task.FromResult(queryable.ToList()); - - public Task FirstOrDefaultAsync(IQueryable queryable, CancellationToken cancellationToken = default) - => Task.FromResult(queryable.FirstOrDefault()); - - public Task FirstOrDefaultAsync(IQueryable queryable, Expression> predicate, CancellationToken cancellationToken = default) - => Task.FromResult(queryable.FirstOrDefault(predicate)); - - public Task LastOrDefaultAsync(IQueryable queryable, CancellationToken cancellationToken = default) - => Task.FromResult(queryable.LastOrDefault()); - - public Task LastOrDefaultAsync(IQueryable queryable, Expression> predicate, CancellationToken cancellationToken = default) - => Task.FromResult(queryable.LastOrDefault(predicate)); - - public Task AnyAsync(IQueryable queryable, Expression> predicate, CancellationToken cancellationToken = default) - => Task.FromResult(queryable.Any(predicate)); - - public Task AllAsync(IQueryable queryable, Expression> predicate, CancellationToken cancellationToken = default) - => Task.FromResult(queryable.All(predicate)); - - public Task CountAsync(IQueryable queryable, CancellationToken cancellationToken = default) - => Task.FromResult(queryable.Count()); - - public Task LongCountAsync(IQueryable queryable, CancellationToken cancellationToken = default) - => Task.FromResult(queryable.LongCount()); - - public Task SingleOrDefaultAsync(IQueryable queryable, Expression> predicate, CancellationToken cancellationToken = default) - => Task.FromResult(queryable.SingleOrDefault(predicate)); - - public Task AnyAsync(IQueryable queryable, CancellationToken cancellationToken = default) - => Task.FromResult(queryable.Any()); - - public IAsyncQueryableHandlerService? GetAsyncQueryableHandler(IQueryable 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 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> 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(); -builder.Services.AddTransient(); - -// 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(); -builder.Services.AddTransient(); - -// 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. diff --git a/.claude/rules/commands-queries.md b/.claude/rules/commands-queries.md deleted file mode 100644 index 19a62eb..0000000 --- a/.claude/rules/commands-queries.md +++ /dev/null @@ -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 Items { get; set; } = []; -} - -public class PlaceOrderCommandValidator : AbstractValidator -{ - public PlaceOrderCommandValidator() - { - RuleFor(x => x.CustomerId).NotEmpty(); - RuleFor(x => x.Items).NotEmpty(); - } -} - -public class PlaceOrderCommandHandler : ICommandHandler -{ - public Task HandleAsync(PlaceOrderCommand command, CancellationToken cancellationToken = default) - { - // implementation - } -} -``` - -## Handler Interfaces - -```csharp -// Command with no result -ICommandHandler - Task HandleAsync(TCommand command, CancellationToken cancellationToken = default) - -// Command with result -ICommandHandler - Task HandleAsync(TCommand command, CancellationToken cancellationToken = default) - -// Query (always returns result) — only for single-entity lookups or non-queryable data -IQueryHandler - Task HandleAsync(TQuery query, CancellationToken cancellationToken = default) -``` - -## Registration - -```csharp -// Command without result -services.AddCommand(); - -// Command with result -services.AddCommand(); - -// Command with result + validator (from Svrnty.CQRS.FluentValidation) -services.AddCommand(); - -// Regular query — ONLY for single-entity lookups or non-queryable results -services.AddQuery(); -``` - -## 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 diff --git a/.claude/rules/dynamic-query.md b/.claude/rules/dynamic-query.md deleted file mode 100644 index 5cb3fec..0000000 --- a/.claude/rules/dynamic-query.md +++ /dev/null @@ -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 -{ - private readonly AppDbContext _db; - public OrderQueryableProvider(AppDbContext db) => _db = db; - - public Task> GetQueryableAsync(object query, CancellationToken ct = default) - => Task.FromResult(_db.Orders.AsQueryable()); -} -``` - -Registration — one line: -```csharp -builder.Services.AddDynamicQueryWithProvider(); -``` - -This automatically creates endpoints with full filtering, sorting, and pagination support. - -## Required Dependencies - -These must be registered before `AddSvrntyCqrs`: -```csharp -builder.Services.AddTransient(); -builder.Services.AddTransient(); -``` - -## 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 -{ - private readonly ITenantContext _tenant; - public OrderTenantFilter(ITenantContext tenant) => _tenant = tenant; - - public Task> AlterQueryableAsync( - IQueryable query, - IDynamicQuery dynamicQuery, - CancellationToken ct = default) - { - return Task.FromResult(query.Where(o => o.TenantId == _tenant.Id)); - } -} -``` - -Registration: -```csharp -builder.Services.AddAlterQueryable(); -``` - -## Interceptors - -Up to 5 interceptors per query type. These modify the PoweredSoft DynamicQuery criteria at query build time. - -```csharp -builder.Services.AddDynamicQueryInterceptor(); -``` - -## Source to Destination Mapping - -When the entity type differs from the DTO: -```csharp -// Provider returns the source entity queryable -public class OrderQueryableProvider : IQueryableProviderOverride { ... } - -// Registration maps source -> destination -builder.Services.AddDynamicQueryWithProvider(); -``` - -## Key Interfaces - -```csharp -IQueryableProvider - Task> GetQueryableAsync(object query, CancellationToken ct) - -IQueryableProviderOverride : IQueryableProvider - // Marker interface — same method, signals override registration - -IAlterQueryableService - Task> AlterQueryableAsync(IQueryable query, IDynamicQuery dynamicQuery, CancellationToken ct) -``` diff --git a/.claude/rules/grpc.md b/.claude/rules/grpc.md deleted file mode 100644 index a2472ce..0000000 --- a/.claude/rules/grpc.md +++ /dev/null @@ -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` | `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 -}); -``` diff --git a/.claude/rules/validation.md b/.claude/rules/validation.md deleted file mode 100644 index cf89688..0000000 --- a/.claude/rules/validation.md +++ /dev/null @@ -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 -{ - 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(); - -// Command with result + validator -services.AddCommand(); - -// Query + validator -services.AddQuery(); -``` - -These come from `Svrnty.CQRS.FluentValidation.ServiceCollectionExtensions` — they call the base `AddCommand`/`AddQuery` then register `IValidator`. - -## 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`, not `IValidator` directly -- Define all rules in the constructor -- Always include `.WithMessage()` for user-facing error messages -- Validator constructor can inject services for async/database validation diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 486f8aa..0000000 --- a/.claude/settings.json +++ /dev/null @@ -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..." - } - ] - } - ] - } -} diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 74814c8..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -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": [] - } -} diff --git a/.claude/skills/add-command/SKILL.md b/.claude/skills/add-command/SKILL.md deleted file mode 100644 index fc1f105..0000000 --- a/.claude/skills/add-command/SKILL.md +++ /dev/null @@ -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: ---- - -# 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. diff --git a/.claude/skills/add-dynamic-query/SKILL.md b/.claude/skills/add-dynamic-query/SKILL.md deleted file mode 100644 index 9bc4c67..0000000 --- a/.claude/skills/add-dynamic-query/SKILL.md +++ /dev/null @@ -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: ---- - -# 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> 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> 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(); -builder.Services.AddTransient(); -``` - -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. diff --git a/.claude/skills/add-query/SKILL.md b/.claude/skills/add-query/SKILL.md deleted file mode 100644 index 9dc4155..0000000 --- a/.claude/skills/add-query/SKILL.md +++ /dev/null @@ -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: ---- - -# 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. diff --git a/CLAUDE.md b/CLAUDE.md index 50ac60d..cb9cfe8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 + Task HandleAsync(TCommand command, CancellationToken cancellationToken = default) + +// Command with result +ICommandHandler + Task HandleAsync(TCommand command, CancellationToken cancellationToken = default) + +// Query (always returns result) +IQueryHandler + Task HandleAsync(TQuery query, CancellationToken cancellationToken = default) +``` + ### Metadata-Driven Discovery The framework uses a **metadata pattern** for runtime discovery: -1. `services.AddCommand()` 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()`, it: + - Registers the handler in DI as `ICommandHandler` + - 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(); +builder.Services.AddCommand(); + +// Add gRPC support +builder.Services.AddGrpc(); + +var app = builder.Build(); + +// Map auto-generated gRPC service implementations +app.MapGrpcService(); +app.MapGrpcService(); + +// 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(); +builder.Services.AddQuery, 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(); +app.MapGrpcService(); +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` - Interface with GetFilters(), GetSorts(), GetGroups(), GetAggregates() +- `IQueryableProvider` - Provides base IQueryable to query against +- `IAlterQueryableService` - Middleware to modify queries (e.g., security filters) +- `DynamicQueryHandler` - 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() + .AddDynamicQueryWithProvider() + .AddAlterQueryable(); + +// 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` +3. Register in DI: `services.AddCommand()` +4. (Optional) Add validator: `services.AddTransient, 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 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