Compare commits

..

21 Commits

Author SHA1 Message Date
jp f6e67986fa docs(cqrs): add architecture diagram, package index, and getting started guide
Co-Authored-By: Svrnty Inc. <jp@svrnty.io>
2026-03-08 14:02:19 -04:00
jp 2c5059d947 docs(libraries): add library manifest and discovery index
Co-Authored-By: Svrnty Inc. <jp@svrnty.io>
2026-03-08 13:59:00 -04:00
jp 5c7736db98 docs(governance): standardize documentation across polyrepo
- CLAUDE.md: repo-specific tech stack, commands, deps (points to root)
- LICENSE: MIT 2026 svrnty (standardized)
- CONTRIBUTING.md: unified workflow, correct co-author email
- SECURITY.md: unified vulnerability reporting policy
- CHANGELOG.md: Keep a Changelog template (if new)
- lefthook.yml: added doc-hygiene hook, improved bootstrap

Co-Authored-By: Svrnty Inc. <jp@svrnty.io>
2026-03-08 12:01:24 -04:00
jp 41eb5b97cb chore: add bootstrap-siblings post-commit hook
Co-Authored-By: Svrnty Inc. <jp@svrnty.io>
2026-03-08 11:32:15 -04:00
jp c7d9228a88 chore: add post-commit repo registry hook
Co-Authored-By: Svrnty Inc. <jp@svrnty.io>
2026-03-08 11:29:59 -04:00
jp 313b8c83ea chore: standardize CLAUDE.md and lefthook hooks
Co-Authored-By: Svrnty Inc. <jp@svrnty.io>
2026-03-08 11:22:05 -04:00
jp 3fa59306c2 docs: add security policy
Co-Authored-By: Svrnty Inc. <eng@svrnty.com>
2026-03-05 05:59:26 -05:00
jp 697b36900b docs: standardize documentation structure
- CLAUDE.md: universal development guidelines
- README.md: project description (consistent template)
- CONTRIBUTING.md: contribution workflow
- CHANGELOG.md: version history

Co-Authored-By: Svrnty Inc. <eng@svrnty.com>
2026-03-05 05:53:27 -05:00
jp 7ef3e56759 chore: remove perfecto references from CLAUDE.md
- Removed perfecto version header
- Replaced perfecto build instructions with generic guidance

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-05 05:18:04 -05:00
Svrnty acde9ec22a test: add FluentValidation tests for command and query validators
Add tests verifying that FluentValidation integrates correctly with
the CQRS discovery and handler registration pipeline.

🤖 Generated with [Claude Code](https://claude.ai/claude-code)

Co-Authored-By: Svrnty Inc. <eng@svrnty.com>
2026-02-28 17:36:34 -05:00
Svrnty 2ff8eae75c docs: update CLAUDE.md via perfecto
🤖 Generated with [Claude Code](https://claude.ai/claude-code)

Co-Authored-By: Svrnty Inc. <eng@svrnty.com>
2026-02-28 17:35:42 -05:00
Svrnty 7e12f73160 docs: add perfecto-managed documentation triad v1.0.0
Co-Authored-By: Svrnty Inc. <eng@svrnty.com>
2026-02-28 12:33:15 -05:00
Svrnty 16ca6f722b ci: add release pipeline with NuGet packaging and quality gates
Triggered by tag push (v*) or manual dispatch. Validates semver tag format,
runs build, test, and format check, then packs NuGet package with version
from tag and creates GitHub Release with artifacts attached.

Co-Authored-By: Svrnty Inc. <eng@svrnty.com>
2026-02-27 22:01:05 -05:00
Svrnty 1f12cc8c59 ci: add CodeQL scanning, format check, and .env.example
Add weekly CodeQL analysis for C# code. Add dotnet format
verification step to CI. Create .env.example documenting
required environment variables.

Co-Authored-By: Svrnty Inc. <eng@svrnty.com>
2026-02-27 21:09:14 -05:00
Svrnty 7ead822067 ci: fix dotnet version to 10.0.x and add concurrency controls
Change CI dotnet-version from 8.x to 10.0.x to match the project's
net10.0 target framework (security.yml already used 10.0.x). Add
concurrency groups and permissions: contents: read to both workflows.

Co-Authored-By: Svrnty Inc. <eng@svrnty.com>
2026-02-27 21:03:50 -05:00
Svrnty 346c4ac77c feat: add build/test CI pipeline and dependabot
- Add .NET CI pipeline (restore, build --warnaserror, test on JP branch)
- Add Dependabot for nuget and github-actions ecosystems

Co-Authored-By: Svrnty Inc. <eng@svrnty.com>
2026-02-27 19:43:43 -05:00
Svrnty 5f3602d071 fix: resolve nullability warnings, add CI/CD and security workflows, harden .gitignore
- Add nullable annotations across discovery interfaces, dynamic query
  models, and filter/aggregate types to eliminate CS8600-series warnings
- Replace unsafe cast in DynamicQueryHandlerBase with pattern match
- Add CI workflow (build --warnaserror + test on JP branch)
- Add weekly security vulnerability scan workflow
- Extend .gitignore with secret/credential patterns (.env, *.key, secrets/, credentials.json)

Co-Authored-By: Svrnty Inc. <eng@svrnty.com>
2026-02-27 19:28:24 -05:00
Svrnty 92231df745 test: add xUnit test project for Svrnty.CQRS core library
Add tests/Svrnty.CQRS.Tests with 61 tests covering:
- CommandMeta and QueryMeta metadata creation and naming conventions
- CommandDiscovery and QueryDiscovery lookup, existence, and enumeration
- DI service registration for commands (with/without result) and queries
- Full pipeline integration (register -> discover -> resolve)
- CqrsBuilder fluent API configuration
- CqrsConfiguration generic storage and mapping callbacks
- Handler execution via DI-resolved instances

Co-Authored-By: Svrnty Inc. <eng@svrnty.com>
2026-02-27 18:38:44 -05:00
Svrnty 5a35e23234 chore: add .editorconfig with standard C# conventions
Adds EditorConfig for consistent formatting across the solution:
- 4-space indentation, UTF-8 encoding, LF line endings
- Standard .NET naming conventions (_camelCase private fields, PascalCase types)
- File-scoped namespace preference matching existing code style
- Allman brace style, var preferences, expression-bodied member rules
- Appropriate indentation for XML/JSON/proto files

Co-Authored-By: Svrnty Inc. <eng@svrnty.com>
2026-02-27 18:37:51 -05:00
Svrnty 148a9573e0 chore: remove .DS_Store files, add to .gitignore, add commit authorship rule
Co-Authored-By: Svrnty Inc. <eng@svrnty.com>
2026-02-27 18:21:25 -05:00
Svrnty 9ed9400e4d docs: add GRAPH.md with unicode architecture diagrams, update CLAUDE.md
- Create GRAPH.md: package layers, metadata-driven endpoint flow
- Add GRAPH.md maintenance rule to CLAUDE.md

Co-Authored-By: Svrnty Inc. <eng@svrnty.com>
2026-02-27 14:31:02 -05:00
83 changed files with 6303 additions and 4724 deletions
Vendored
BIN
View File
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
1.0.0
-100
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
-97
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)
```
-109
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
});
```
-60
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
-79
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..."
}
]
}
]
}
}
-103
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.
-125
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.
-86
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.
+210 -45
View File
@@ -1,5 +1,7 @@
# Top-most EditorConfig file
root = true
# All files
[*]
indent_style = space
indent_size = 4
@@ -8,89 +10,252 @@ charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{csproj,props,targets,xml}]
# XML project files
[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}]
indent_size = 2
# XML config files
[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}]
indent_size = 2
# JSON and YAML files
[*.{json,yml,yaml}]
indent_size = 2
# Markdown files
[*.md]
trim_trailing_whitespace = false
# Proto files
[*.proto]
indent_size = 2
# Solution files
[*.sln]
indent_style = tab
# C# files
[*.cs]
# Namespace
csharp_style_namespace_declarations = file_scoped:warning
# Braces — Allman style
csharp_new_line_before_open_brace = all
#### Core EditorConfig Options ####
# Usings
# Indentation and spacing
indent_size = 4
indent_style = space
tab_width = 4
#### .NET Coding Conventions ####
# Organise usings
dotnet_sort_system_directives_first = true
csharp_using_directive_placement = outside_namespace:warning
dotnet_separate_import_directive_groups = false
# var preferences — use var when type is apparent
# this. and Me. preferences
dotnet_style_qualification_for_field = false:suggestion
dotnet_style_qualification_for_property = false:suggestion
dotnet_style_qualification_for_method = false:suggestion
dotnet_style_qualification_for_event = false:suggestion
# Language keywords vs BCL types preferences
dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
dotnet_style_predefined_type_for_member_access = true:suggestion
# Parentheses preferences
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
# Modifier preferences
dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion
# Expression-level preferences
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_explicit_tuple_names = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_object_initializer = true:suggestion
dotnet_style_prefer_auto_properties = true:suggestion
dotnet_style_prefer_conditional_expression_over_assignment = true:silent
dotnet_style_prefer_conditional_expression_over_return = true:silent
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
dotnet_style_prefer_inferred_tuple_names = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
dotnet_style_prefer_simplified_interpolation = true:suggestion
# Field preferences
dotnet_style_readonly_field = true:suggestion
# Parameter preferences
dotnet_code_quality_unused_parameters = all:suggestion
#### C# Coding Conventions ####
# var preferences
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = true:suggestion
# Expression bodies — prefer for simple members
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
csharp_style_expression_bodied_constructors = false:suggestion
csharp_style_expression_bodied_operators = when_on_single_line:suggestion
csharp_style_expression_bodied_properties = true:suggestion
csharp_style_expression_bodied_accessors = true:suggestion
csharp_style_expression_bodied_lambdas = true:suggestion
# Expression-bodied members
csharp_style_expression_bodied_methods = when_on_single_line:silent
csharp_style_expression_bodied_constructors = false:silent
csharp_style_expression_bodied_operators = when_on_single_line:silent
csharp_style_expression_bodied_properties = when_on_single_line:suggestion
csharp_style_expression_bodied_indexers = when_on_single_line:suggestion
csharp_style_expression_bodied_accessors = when_on_single_line:suggestion
csharp_style_expression_bodied_lambdas = when_on_single_line:silent
csharp_style_expression_bodied_local_functions = when_on_single_line:silent
# Pattern matching
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
# Pattern matching preferences
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
csharp_style_prefer_not_pattern = true:suggestion
csharp_style_prefer_pattern_matching = true:silent
csharp_style_prefer_switch_expression = true:suggestion
# Null checking
csharp_style_throw_expression = true:suggestion
# Null-checking preferences
csharp_style_conditional_delegate_call = true:suggestion
csharp_style_prefer_null_check_over_type_check = true:suggestion
# Modifier preferences — exclude interface members (netstandard2.1 compat)
dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning
# Code-block preferences
csharp_prefer_braces = true:silent
csharp_prefer_simple_using_statement = true:suggestion
csharp_style_prefer_method_group_conversion = true:silent
# Field naming — _camelCase for private fields
dotnet_naming_rule.private_fields_should_be_camel_case.severity = warning
dotnet_naming_rule.private_fields_should_be_camel_case.symbols = private_fields
dotnet_naming_rule.private_fields_should_be_camel_case.style = camel_case_underscore
# Expression-level preferences
csharp_prefer_simple_default_expression = true:suggestion
csharp_style_prefer_local_over_anonymous_function = true:suggestion
csharp_style_prefer_index_operator = true:suggestion
csharp_style_prefer_range_operator = true:suggestion
csharp_style_prefer_tuple_swap = true:suggestion
csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
csharp_style_inlined_variable_declaration = true:suggestion
csharp_style_deconstructed_variable_declaration = true:suggestion
csharp_style_throw_expression = true:suggestion
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, private_protected
dotnet_naming_symbols.private_fields.required_modifiers =
# 'using' directive preferences
csharp_using_directive_placement = outside_namespace:suggestion
dotnet_naming_style.camel_case_underscore.required_prefix = _
dotnet_naming_style.camel_case_underscore.capitalization = camel_case
# Namespace preferences
csharp_style_namespace_declarations = file_scoped:suggestion
# Constants — PascalCase
dotnet_naming_rule.constants_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants
dotnet_naming_rule.constants_should_be_pascal_case.style = pascal_case
#### C# Formatting Rules ####
dotnet_naming_symbols.constants.applicable_kinds = field
dotnet_naming_symbols.constants.required_modifiers = const
# New line preferences
csharp_new_line_before_open_brace = all
csharp_new_line_before_else = true
csharp_new_line_before_catch = true
csharp_new_line_before_finally = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_between_query_expression_clauses = true
dotnet_naming_style.pascal_case.capitalization = pascal_case
# Indentation preferences
csharp_indent_case_contents = true
csharp_indent_switch_labels = true
csharp_indent_labels = one_less_than_current
csharp_indent_block_contents = true
csharp_indent_braces = false
csharp_indent_case_contents_when_block = true
# Interfaces — I prefix
dotnet_naming_rule.interfaces_should_begin_with_i.severity = warning
dotnet_naming_rule.interfaces_should_begin_with_i.symbols = interfaces
dotnet_naming_rule.interfaces_should_begin_with_i.style = begins_with_i
# Space preferences
csharp_space_after_cast = false
csharp_space_after_keywords_in_control_flow_statements = true
csharp_space_between_parentheses = false
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_around_binary_operators = before_and_after
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
csharp_space_between_method_declaration_name_and_open_parenthesis = false
csharp_space_between_method_call_parameter_list_parentheses = false
csharp_space_between_method_call_empty_parameter_list_parentheses = false
csharp_space_between_method_call_name_and_opening_parenthesis = false
csharp_space_after_comma = true
csharp_space_before_comma = false
csharp_space_after_dot = false
csharp_space_before_dot = false
csharp_space_after_semicolon_in_for_statement = true
csharp_space_before_semicolon_in_for_statement = false
csharp_space_around_declaration_statements = false
csharp_space_before_open_square_brackets = false
csharp_space_between_empty_square_brackets = false
csharp_space_between_square_brackets = false
dotnet_naming_symbols.interfaces.applicable_kinds = interface
# Wrapping preferences
csharp_preserve_single_line_statements = false
csharp_preserve_single_line_blocks = true
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.capitalization = pascal_case
#### Naming Conventions ####
# Naming rules
dotnet_naming_rule.interface_should_begin_with_i.severity = suggestion
dotnet_naming_rule.interface_should_begin_with_i.symbols = interface
dotnet_naming_rule.interface_should_begin_with_i.style = begins_with_i
dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.private_or_internal_field_should_be_camel_case_with_underscore.severity = suggestion
dotnet_naming_rule.private_or_internal_field_should_be_camel_case_with_underscore.symbols = private_or_internal_field
dotnet_naming_rule.private_or_internal_field_should_be_camel_case_with_underscore.style = camel_case_with_underscore
dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields
dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case
# Async methods — Async suffix
dotnet_naming_rule.async_methods_should_end_with_async.severity = suggestion
dotnet_naming_rule.async_methods_should_end_with_async.symbols = async_methods
dotnet_naming_rule.async_methods_should_end_with_async.style = ends_with_async
dotnet_naming_rule.type_parameters_should_begin_with_t.severity = suggestion
dotnet_naming_rule.type_parameters_should_begin_with_t.symbols = type_parameters
dotnet_naming_rule.type_parameters_should_begin_with_t.style = begins_with_t
# Symbol specifications
dotnet_naming_symbols.interface.applicable_kinds = interface
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.private_or_internal_field.applicable_kinds = field
dotnet_naming_symbols.private_or_internal_field.applicable_accessibilities = private, internal, private_protected
dotnet_naming_symbols.constant_fields.applicable_kinds = field
dotnet_naming_symbols.constant_fields.applicable_accessibilities = *
dotnet_naming_symbols.constant_fields.required_modifiers = const
dotnet_naming_symbols.async_methods.applicable_kinds = method
dotnet_naming_symbols.async_methods.applicable_accessibilities = *
dotnet_naming_symbols.async_methods.required_modifiers = async
dotnet_naming_symbols.type_parameters.applicable_kinds = type_parameter
dotnet_naming_symbols.type_parameters.applicable_accessibilities = *
# Naming styles
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.capitalization = pascal_case
dotnet_naming_style.camel_case_with_underscore.required_prefix = _
dotnet_naming_style.camel_case_with_underscore.capitalization = camel_case
dotnet_naming_style.ends_with_async.required_suffix = Async
dotnet_naming_style.ends_with_async.capitalization = pascal_case
dotnet_naming_style.begins_with_t.required_prefix = T
dotnet_naming_style.begins_with_t.capitalization = pascal_case
+9
View File
@@ -0,0 +1,9 @@
# dotnet-cqrs Environment Configuration
# Copy to .env and fill in values before running
# NuGet publishing (required for dotnet pack + push)
NUGET_API_KEY=
# Application URLs (for Svrnty.Sample project)
ASPNETCORE_URLS=http://localhost:19898
ASPNETCORE_ENVIRONMENT=Development
+35
View File
@@ -0,0 +1,35 @@
version: 2
updates:
- package-ecosystem: nuget
directory: "/"
schedule:
interval: weekly
target-branch: JP
open-pull-requests-limit: 3
labels:
- "dependencies"
groups:
nuget-all:
patterns:
- "*"
update-types:
- minor
- patch
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
target-branch: JP
open-pull-requests-limit: 1
labels:
- "ci"
- "dependencies"
groups:
actions-all:
patterns:
- "*"
update-types:
- minor
- patch
+37
View File
@@ -0,0 +1,37 @@
name: CI
on:
push:
branches: [JP]
pull_request:
branches: [JP]
concurrency:
group: ci-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
permissions:
contents: read
jobs:
build-and-test:
name: Build & Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Restore
run: dotnet restore Svrnty.CQRS.sln
- name: Build
run: dotnet build Svrnty.CQRS.sln --no-restore --warnaserror
- name: Test
run: dotnet test Svrnty.CQRS.sln --no-build --verbosity normal
- name: Format check
run: dotnet format Svrnty.CQRS.sln --verify-no-changes
+47
View File
@@ -0,0 +1,47 @@
name: CodeQL
on:
push:
branches: [JP]
pull_request:
branches: [JP]
schedule:
- cron: "0 8 * * 1" # Weekly on Monday at 08:00 UTC
concurrency:
group: codeql-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
permissions:
contents: read
security-events: write
jobs:
analyze:
name: CodeQL Analysis
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: [csharp]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
- name: Build
run: dotnet build Svrnty.CQRS.sln
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{ matrix.language }}"
+86
View File
@@ -0,0 +1,86 @@
name: Release
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
description: "Release tag (e.g. v1.2.0)"
required: true
type: string
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: write
jobs:
release:
name: Validate, Build, Pack & Release
runs-on: ubuntu-latest
steps:
- name: Resolve tag
id: tag
run: |
if [[ "${{ github.event_name }}" == "push" ]]; then
TAG="${GITHUB_REF_NAME}"
else
TAG="${{ inputs.tag }}"
fi
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Tag must match semver format (vX.Y.Z[-suffix]): got ${TAG}"
exit 1
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
with:
ref: ${{ steps.tag.outputs.tag }}
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Restore
run: dotnet restore Svrnty.CQRS.sln
- name: Build
run: dotnet build Svrnty.CQRS.sln --no-restore --configuration Release --warnaserror
- name: Test
run: dotnet test Svrnty.CQRS.sln --no-build --configuration Release --verbosity normal
- name: Format check
run: dotnet format Svrnty.CQRS.sln --verify-no-changes
- name: Pack NuGet packages
run: |
dotnet pack Svrnty.CQRS.sln \
--no-build \
--configuration Release \
--output ./artifacts \
-p:Version=${{ steps.tag.outputs.version }}
- name: Upload NuGet artifacts
uses: actions/upload-artifact@v4
with:
name: nuget-packages
path: ./artifacts/*.nupkg
retention-days: 30
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.tag.outputs.tag }}
generate_release_notes: true
files: |
artifacts/*.nupkg
artifacts/*.snupkg
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+34
View File
@@ -0,0 +1,34 @@
name: Security
on:
push:
branches: [JP]
pull_request:
branches: [JP]
schedule:
- cron: "0 6 * * 1" # Weekly on Monday at 06:00 UTC
concurrency:
group: security-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
permissions:
contents: read
jobs:
vulnerability-scan:
name: .NET vulnerability scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Restore dependencies
run: dotnet restore
- name: Check for vulnerable packages
run: dotnet list package --vulnerable --include-transitive
+11 -1
View File
@@ -4,6 +4,7 @@
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
.research/
.DS_Store
# User-specific files
*.rsuser
@@ -339,4 +340,13 @@ ASALocalRun/
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
healthchecksdb
# Secrets and credentials
.env
.env.local
.env.*
*.key
secrets/
.aws/
credentials.json
+14
View File
@@ -0,0 +1,14 @@
name: dotnet-cqrs
description: Modern CQRS framework for .NET with gRPC source generation and HTTP Minimal API support
owner: mathias@svrnty.io
layer: L3
stack: C# 14/.NET 10
status: stable
dependencies: []
dependents:
- flutter_cqrs_datasource
- a-gent-app
entry_points:
readme: README.md
registry: null
schemas: Svrnty.CQRS.sln
+22
View File
@@ -0,0 +1,22 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- `.library-manifest.yaml` for cross-repo discovery and dependency tracking
- Initial project setup
- `docs/ARCHITECTURE.md` -- package dependency graph, CQRS data flows, saga flow, separation of concerns
- `docs/PACKAGE_INDEX.md` -- per-package reference for all 18 NuGet packages with key types and dependencies
- `docs/GETTING_STARTED.md` -- step-by-step guide covering handler registration, gRPC setup, MinimalApi, DynamicQuery, domain events, sagas, and notifications
### Changed
- Updated README.md to reflect correct package count (18), added links to new docs, added Related Libraries section linking to flutter_cqrs_datasource
### Fixed
### Removed
+36 -135
View File
@@ -1,146 +1,47 @@
# CLAUDE.md
# Development Guidelines
This file provides guidance to AI agents when working with code in this repository.
> **Source of truth**: All engineering principles, commit rules, documentation standards, and governance policies are defined in the [root CLAUDE.md](../CLAUDE.md). This file contains repo-specific notes only.
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`).
## Quick Reference
## Project Overview
- **Branch**: `JP` for active development
- **Commit format**: `type(scope): message`
- **Co-Author**: `Co-Authored-By: Svrnty Inc. <jp@svrnty.io>`
- **Hooks**: `lefthook install` — enforces author, secrets, doc hygiene
- **Docs required**: README.md, CHANGELOG.md, LICENSE, CONTRIBUTING.md, SECURITY.md
This is Svrnty.CQRS, a modern implementation of Command Query Responsibility Segregation (CQRS) for .NET 10. It was forked from PoweredSoft.CQRS and provides:
## Tech Stack
- CQRS pattern implementation with command/query handlers exposed via HTTP or gRPC
- Automatic HTTP endpoint generation via Minimal API
- 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)
| Tool | Version |
|------|---------|
| C# | 14 |
| .NET | 10.0 |
| AOT | enabled (IsAotCompatible=true) |
| Nullable | enabled |
## Solution Structure
## Commands
The solution contains projects organized by responsibility:
| Command | Description |
|---------|-------------|
| `dotnet build` | Build all 18 projects |
| `dotnet test` | Run tests |
| `dotnet format` | Format code |
**Abstractions (interfaces and contracts only):**
- `Svrnty.CQRS.Abstractions` - Core interfaces (ICommandHandler, IQueryHandler, discovery contracts)
- `Svrnty.CQRS.DynamicQuery.Abstractions` - Dynamic query interfaces (multi-targets netstandard2.1 and net10.0)
- `Svrnty.CQRS.Grpc.Abstractions` - gRPC-specific interfaces and contracts
## Key Dependencies
**Implementation:**
- `Svrnty.CQRS` - Core discovery and registration logic
- `Svrnty.CQRS.MinimalApi` - Minimal API endpoint mapping for commands/queries
- `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
| Package | Description |
|---------|-------------|
| Svrnty.CQRS.Core | Core CQRS abstractions |
| Svrnty.CQRS.DynamicQuery | Dynamic query support |
| Svrnty.CQRS.gRPC | gRPC transport |
| Svrnty.CQRS.Events | Event sourcing |
| Svrnty.CQRS.Sagas | Saga orchestration |
| Svrnty.CQRS.Notifications | Notification handlers |
| Svrnty.CQRS.MinimalApi | Minimal API bindings |
**Sample:**
- `Svrnty.Sample` - Demo project showcasing both HTTP and gRPC endpoints
## Repo-Specific Notes
**Key Design Principle:** Abstractions projects contain ONLY interfaces/attributes with minimal dependencies. Implementation projects depend on abstractions.
## 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
```
## Testing
No test projects currently exist. When adding tests:
- Place them in a `tests/` directory
- Name them with `.Tests` suffix (e.g., `Svrnty.CQRS.Tests`)
## Architecture
### 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
**Key Files:**
- `Svrnty.CQRS.Abstractions/Discovery/` - Metadata interfaces
- `Svrnty.CQRS/Discovery/` - Discovery implementations
- `Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs` - HTTP endpoint generation
- `Svrnty.CQRS.DynamicQuery.MinimalApi/EndpointRouteBuilderExtensions.cs` - Dynamic query endpoint generation
- `Svrnty.CQRS.Grpc.Generators/` - gRPC service generation via source generators
### Integration
Commands and queries can be exposed via HTTP (Minimal API), gRPC, or both simultaneously. The fluent configuration API handles all wiring:
```csharp
builder.Services.AddSvrntyCqrs(cqrs =>
{
cqrs.AddGrpc(grpc => grpc.EnableReflection());
cqrs.AddMinimalApi();
});
app.UseSvrntyCqrs(); // Maps all endpoints
```
See `Svrnty.Sample/Program.cs` for a complete working example.
## Package Configuration
- **Target Framework**: `net10.0` (except DynamicQuery.Abstractions: `netstandard2.1;net10.0`)
- **Language Version**: C# 14
- **Authors**: David Lebee, Mathias Beaulieu-Duncan
- **Repository**: https://git.openharbor.io/svrnty/dotnet-cqrs
### Key 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)
## Publishing
NuGet packages publish automatically via GitHub Actions (`.github/workflows/publish-nugets.yml`) when a release is created. Tag becomes the version.
```bash
# Manual publish
dotnet pack -c Release -o ./artifacts -p:Version=1.2.3
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:**
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)
5. Update package version and release notes
## 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
## 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.
## Common Code Locations
- Handler interfaces: `Svrnty.CQRS.Abstractions/ICommandHandler.cs`, `IQueryHandler.cs`
- Discovery: `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/`
- Solution file: `Svrnty.CQRS.sln` with 18 projects.
- Lint is handled by .NET analyzers — AOT compatibility and nullable reference types are enforced.
- No Docker or proto files in this repo.
- Published under the `svrnty` org (git.openharbor.io/svrnty), not `a-gent`.
+52
View File
@@ -0,0 +1,52 @@
# Contributing
Thank you for your interest in contributing to this project.
## Development Guidelines
See [CLAUDE.md](./CLAUDE.md) for development practices, engineering principles, and coding standards.
## How to Contribute
1. **Fork & Clone**
```bash
git clone <your-fork-url>
cd <project>
git checkout JP
```
2. **Create a Branch**
```bash
git checkout -b feature/your-feature-name
```
3. **Make Changes**
- Follow the guidelines in CLAUDE.md
- Keep changes focused and minimal
- Write tests if applicable
4. **Validate**
- Run format checks
- Run lint checks
- Run test suite
5. **Commit**
```bash
git commit -m "feat: your change description"
```
AI-authored commits must include:
```
Co-Authored-By: Svrnty Inc. <jp@svrnty.io>
```
6. **Push & Create PR**
```bash
git push origin feature/your-feature-name
```
- Open a PR against the `JP` branch
- Provide clear description of changes
## Questions?
Open an issue for questions or discussions.
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2021 Powered Softwares Inc.
Copyright (c) 2026 svrnty
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+48 -249
View File
@@ -1,282 +1,81 @@
> This project was originally initiated by [Powered Software Inc.](https://poweredsoft.com/) and was forked from the [PoweredSoft.CQRS](https://github.com/PoweredSoft/CQRS) Repository
# Svrnty.CQRS
# CQRS
Our implementation of query and command responsibility segregation (CQRS).
> Modern CQRS framework for .NET with gRPC source generation and HTTP Minimal API support.
## Where This Fits
This is a backend framework of the [Svrnty Agent System](../README.md).
**Layer**: Framework
**Layer**: libs
**Depends on**: Nothing (standalone .NET framework)
**Depended on by**: a-gent-app (backend services), flutter_cqrs_datasource (client)
**Git**: [git.openharbor.io/svrnty/dotnet-cqrs](https://git.openharbor.io/svrnty/dotnet-cqrs)
**Depended on by**: a-gent-app (backend services), flutter-cqrs-datasource (client)
**Git**: git.openharbor.io/svrnty/dotnet-cqrs.git
## Getting Started
## Tech Stack
> Install nuget package to your awesome project.
- **Language**: C# 14 / .NET 10
- **Framework**: ASP.NET Core Minimal API, gRPC
- **Key Dependencies**: FluentValidation 11.x, Grpc.AspNetCore, PoweredSoft.DynamicQuery
| Package Name | NuGet | NuGet Install |
|-----------------------------------------| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |-----------------------------------------------------------------------:|
| Svrnty.CQRS | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS/) | ```dotnet add package Svrnty.CQRS ``` |
| Svrnty.CQRS.MinimalApi | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.MinimalApi.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS.MinimalApi/) | ```dotnet add package Svrnty.CQRS.MinimalApi ``` |
| Svrnty.CQRS.FluentValidation | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.FluentValidation.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS.FluentValidation/) | ```dotnet add package Svrnty.CQRS.FluentValidation ``` |
| Svrnty.CQRS.DynamicQuery | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.DynamicQuery.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS.DynamicQuery/) | ```dotnet add package Svrnty.CQRS.DynamicQuery ``` |
| Svrnty.CQRS.DynamicQuery.MinimalApi | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.DynamicQuery.MinimalApi.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS.DynamicQuery.MinimalApi/) | ```dotnet add package Svrnty.CQRS.DynamicQuery.MinimalApi ``` |
| Svrnty.CQRS.Grpc | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.Grpc.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS.Grpc/) | ```dotnet add package Svrnty.CQRS.Grpc ``` |
| Svrnty.CQRS.Grpc.Generators | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.Grpc.Generators.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS.Grpc.Generators/) | ```dotnet add package Svrnty.CQRS.Grpc.Generators ``` |
> Abstractions Packages.
| Package Name | NuGet | NuGet Install |
| ---------------------------- |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -----------------------------------------------------: |
| Svrnty.CQRS.Abstractions | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.Abstractions.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS.Abstractions/) | ```dotnet add package Svrnty.CQRS.Abstractions ``` |
| Svrnty.CQRS.DynamicQuery.Abstractions | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.DynamicQuery.Abstractions.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS.DynamicQuery.Abstractions/) | ```dotnet add package Svrnty.CQRS.DynamicQuery.Abstractions ``` |
| Svrnty.CQRS.Grpc.Abstractions | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.Grpc.Abstractions.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS.Grpc.Abstractions/) | ```dotnet add package Svrnty.CQRS.Grpc.Abstractions ``` |
## Sample of startup code for gRPC (Recommended)
```csharp
using Svrnty.CQRS;
using Svrnty.CQRS.FluentValidation;
using Svrnty.CQRS.Grpc;
var builder = WebApplication.CreateBuilder(args);
// Register your commands with validators
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler, AddUserCommandValidator>();
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
// Register your queries
builder.Services.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
// Configure CQRS with gRPC support
builder.Services.AddSvrntyCqrs(cqrs =>
{
// Enable gRPC endpoints with reflection
cqrs.AddGrpc(grpc =>
{
grpc.EnableReflection();
});
});
var app = builder.Build();
// Map all configured CQRS endpoints
app.UseSvrntyCqrs();
app.Run();
```
### Important: gRPC Requirements
The gRPC implementation uses **Grpc.Tools** with `.proto` files and **source generators** for automatic service implementation:
#### 1. Install required packages:
## Quick Start
```bash
dotnet add package Grpc.AspNetCore
dotnet add package Grpc.AspNetCore.Server.Reflection
dotnet add package Grpc.StatusProto # For Rich Error Model validation
# Build
dotnet build
# Run
dotnet run --project Svrnty.Sample
# Test
dotnet test
```
#### 2. Add the source generator as an analyzer:
## Architecture
```bash
dotnet add package Svrnty.CQRS.Grpc.Generators
```
18 NuGet packages organized by concern:
The source generator is automatically configured as an analyzer when installed via NuGet and will generate both the `.proto` files and gRPC service implementations at compile time.
- **Abstractions**: Core interfaces (ICommandHandler, IQueryHandler, IDomainEvent, ISaga, INotificationPublisher)
- **Core**: Discovery, registration, handler execution, CqrsBuilder fluent API
- **MinimalApi**: HTTP endpoint mapping with RFC 7807 validation
- **Grpc**: gRPC service support with Google Rich Error Model
- **Grpc.Generators**: Roslyn source generator for .proto files and service implementations
- **DynamicQuery**: PoweredSoft integration for filtering, sorting, paging (with EF Core support)
- **FluentValidation**: Validator registration helpers
- **Events**: Domain event publishing (with RabbitMQ transport)
- **Sagas**: Saga orchestration pattern with compensation and distributed execution (with RabbitMQ transport)
- **Notifications**: Real-time notification streaming (with gRPC transport)
#### 3. Define your C# commands and queries:
See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for a full dependency diagram and data flow.
## Configuration
```csharp
public record AddUserCommand
{
public required string Name { get; init; }
public required string Email { get; init; }
public int Age { get; init; }
}
// Register handlers
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
builder.Services.AddQuery<GetUserQuery, User, GetUserQueryHandler>();
public record RemoveUserCommand
{
public int UserId { get; init; }
}
```
**Notes:**
- The source generator automatically creates:
- `.proto` files in the `Protos/` directory from your C# commands and queries
- `CommandServiceImpl` and `QueryServiceImpl` implementations
- FluentValidation is automatically integrated with **Google Rich Error Model** for structured validation errors
- Validation errors return `google.rpc.Status` with `BadRequest` containing `FieldViolations`
- Use `record` types for commands/queries (immutable, value-based equality, more concise)
- No need for protobuf-net attributes - just define your C# types
## Sample of startup code for Minimal API (HTTP)
For HTTP scenarios (web browsers, public APIs), you can use the Minimal API approach:
```csharp
using Svrnty.CQRS;
using Svrnty.CQRS.FluentValidation;
using Svrnty.CQRS.MinimalApi;
var builder = WebApplication.CreateBuilder(args);
// Register your commands with validators
builder.Services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler, CreatePersonCommandValidator>();
builder.Services.AddCommand<EchoCommand, string, EchoCommandHandler, EchoCommandValidator>();
// Register your queries
builder.Services.AddQuery<PersonQuery, IQueryable<Person>, PersonQueryHandler>();
// Configure CQRS with Minimal API support
// Configure CQRS with gRPC + HTTP
builder.Services.AddSvrntyCqrs(cqrs =>
{
// Enable Minimal API endpoints
cqrs.AddGrpc(grpc => grpc.EnableReflection());
cqrs.AddMinimalApi();
});
// Add Swagger (optional)
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
// Map all configured CQRS endpoints (automatically creates POST /api/command/* and POST/GET /api/query/*)
app.UseSvrntyCqrs();
app.Run();
```
**Notes:**
- FluentValidation is automatically integrated with **RFC 7807 Problem Details** for structured validation errors
- Use `record` types for commands/queries (immutable, value-based equality, more concise)
- Supports both POST and GET (for queries) endpoints
- Automatically generates Swagger/OpenAPI documentation
## Documentation
## Sample enabling both gRPC and HTTP
- [Architecture](./docs/ARCHITECTURE.md) -- Package dependency graph, CQRS data flows, separation of concerns
- [Package Index](./docs/PACKAGE_INDEX.md) -- Per-package reference with key types and dependencies
- [Getting Started](./docs/GETTING_STARTED.md) -- Step-by-step guide covering commands, queries, gRPC, DynamicQuery, events, sagas, and notifications
You can enable both gRPC and traditional HTTP endpoints simultaneously, allowing clients to choose their preferred protocol:
## Related Libraries
```csharp
using Svrnty.CQRS;
using Svrnty.CQRS.FluentValidation;
using Svrnty.CQRS.Grpc;
using Svrnty.CQRS.MinimalApi;
- **[flutter_cqrs_datasource](https://git.openharbor.io/svrnty/flutter_cqrs_datasource)** -- Flutter/Dart counterpart for consuming Svrnty.CQRS services from mobile and desktop apps
var builder = WebApplication.CreateBuilder(args);
## Contributing
// Register your commands with validators
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler, AddUserCommandValidator>();
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
See [CLAUDE.md](./CLAUDE.md) for development guidelines.
// Register your queries
builder.Services.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
## License
// Configure CQRS with both gRPC and Minimal API support
builder.Services.AddSvrntyCqrs(cqrs =>
{
// Enable gRPC endpoints with reflection
cqrs.AddGrpc(grpc =>
{
grpc.EnableReflection();
});
// Enable Minimal API endpoints
cqrs.AddMinimalApi();
});
// Add HTTP support with Swagger
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
// Map all configured CQRS endpoints (both gRPC and HTTP)
app.UseSvrntyCqrs();
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
# Fluent Validation
FluentValidation is optional but recommended for command and query validation. The `Svrnty.CQRS.FluentValidation` package provides extension methods to simplify validator registration.
## With Svrnty.CQRS.FluentValidation (Recommended)
The package exposes extension method overloads that accept the validator as a generic parameter:
```bash
dotnet add package Svrnty.CQRS.FluentValidation
```
```csharp
using Svrnty.CQRS.FluentValidation; // Extension methods for validator registration
// Command with result - validator as last generic parameter
builder.Services.AddCommand<EchoCommand, string, EchoCommandHandler, EchoCommandValidator>();
// Command without result - validator included in generics
builder.Services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler, CreatePersonCommandValidator>();
```
**Benefits:**
- **Single line registration** - Handler and validator registered together
- **Type safety** - Compiler ensures validator matches command type
- **Less boilerplate** - No need for separate `AddTransient<IValidator<T>>()` calls
- **Cleaner code** - Clear intent that validation is part of command pipeline
## Without Svrnty.CQRS.FluentValidation
If you prefer not to use the FluentValidation package, you need to register commands and validators separately:
```csharp
using FluentValidation;
using Svrnty.CQRS;
// Register command handler
builder.Services.AddCommand<EchoCommand, string, EchoCommandHandler>();
// Manually register validator
builder.Services.AddTransient<IValidator<EchoCommand>, EchoCommandValidator>();
```
# 2024-2025 Roadmap
| Task | Description | Status |
|----------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------|--------|
| Support .NET 10 | .NET 10 with C# 14 language support. | ✅ |
| Update FluentValidation | Upgrade FluentValidation to version 11.x for .NET 10 compatibility. | ✅ |
| Add gRPC Support with source generators | Implement gRPC endpoints with source generators and Google Rich Error Model for validation. | ✅ |
| Create a demo project (Svrnty.CQRS.Grpc.Sample) | Develop a comprehensive demo project showcasing gRPC and HTTP endpoints. | ✅ |
| Create a website for the Framework | Develop a website to host comprehensive documentation for the framework. | ⬜️ |
# 2026 Roadmap
| Task | Description | Status |
|----------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------|--------|
| gRPC Compression Support | Smart message compression with automatic threshold detection and per-handler control. | ⬜️ |
| gRPC Metadata & Authorization Support | Expose ServerCallContext to handlers and integrate authorization services for gRPC endpoints. | ⬜️ |
MIT OR Apache-2.0
-122
View File
@@ -1,122 +0,0 @@
# Saga Orchestration Roadmap
## Completed (Phase 1)
- [x] `Svrnty.CQRS.Sagas.Abstractions` - Core interfaces and contracts
- [x] `Svrnty.CQRS.Sagas` - Orchestration engine with fluent builder API
- [x] `Svrnty.CQRS.Sagas.RabbitMQ` - RabbitMQ message transport
---
## Phase 1d: Testing & Sample
### Unit Tests
- [ ] `SagaBuilder` step configuration tests
- [ ] `SagaOrchestrator` execution flow tests
- [ ] `SagaOrchestrator` compensation flow tests
- [ ] `InMemorySagaStateStore` persistence tests
- [ ] `RabbitMqSagaMessageBus` serialization tests
### Integration Tests
- [ ] End-to-end saga execution with RabbitMQ
- [ ] Multi-step saga with compensation scenario
- [ ] Concurrent saga execution tests
- [ ] Connection recovery tests
### Sample Implementation
- [ ] `OrderProcessingSaga` example in WarehouseManagement
- ReserveInventory step
- ProcessPayment step
- CreateShipment step
- Full compensation flow
---
## Phase 2: Persistence
### Svrnty.CQRS.Sagas.EntityFramework
- [ ] `EfCoreSagaStateStore` implementation
- [ ] `SagaState` entity configuration
- [ ] Migration support
- [ ] PostgreSQL/SQL Server compatibility
- [ ] Optimistic concurrency handling
### Configuration
```csharp
cqrs.AddSagas()
.UseEntityFramework<AppDbContext>();
```
---
## Phase 3: Reliability
### Saga Timeout Service
- [ ] `SagaTimeoutHostedService` - background service for stalled sagas
- [ ] Configurable timeout per saga type
- [ ] Automatic compensation trigger on timeout
- [ ] Dead letter handling for failed compensations
### Retry Policies
- [ ] Exponential backoff support
- [ ] Circuit breaker integration
- [ ] Polly integration option
### Idempotency
- [ ] Message deduplication
- [ ] Idempotent step execution
- [ ] Inbox/Outbox pattern support
---
## Phase 4: Observability
### OpenTelemetry Integration
- [ ] Distributed tracing for saga execution
- [ ] Span per saga step
- [ ] Correlation ID propagation
- [ ] Metrics (saga duration, success/failure rates)
### Saga Dashboard (Optional)
- [ ] Web UI for saga monitoring
- [ ] Real-time saga status
- [ ] Manual compensation trigger
- [ ] Saga history and audit log
---
## Phase 5: Flutter Integration
### gRPC Streaming for Saga Status
- [ ] `ISagaStatusStream` service
- [ ] Real-time saga progress updates
- [ ] Step completion notifications
- [ ] Error/compensation notifications
### Flutter Client
- [ ] Dart client for saga status streaming
- [ ] Saga progress widget components
---
## Phase 6: Alternative Transports
### Svrnty.CQRS.Sagas.AzureServiceBus
- [ ] Azure Service Bus message transport
- [ ] Topic/Subscription topology
- [ ] Dead letter queue handling
### Svrnty.CQRS.Sagas.Kafka
- [ ] Kafka message transport
- [ ] Consumer group management
- [ ] Partition key strategies
---
## Future Considerations
- **Event Sourcing**: Saga state as event stream
- **Saga Versioning**: Handle saga definition changes gracefully
- **Saga Composition**: Nested/child sagas
- **Saga Scheduling**: Delayed saga start
- **Multi-tenancy**: Tenant-aware saga execution
+52
View File
@@ -0,0 +1,52 @@
# Security Policy
## Reporting a Vulnerability
If you discover a security vulnerability, please report it responsibly.
**Do NOT open a public issue.**
### How to Report
Email: **security@svrnty.com**
Include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Any suggested fixes (optional)
### Response Timeline
- **Acknowledgment**: Within 48 hours
- **Initial Assessment**: Within 7 days
- **Resolution Target**: Within 30 days (depending on severity)
### What to Expect
1. We will acknowledge receipt of your report
2. We will investigate and validate the issue
3. We will work on a fix and coordinate disclosure
4. We will credit you (if desired) when the fix is released
### Scope
This policy applies to:
- Code in this repository
- Dependencies we control
- Infrastructure we operate
### Out of Scope
- Third-party services or dependencies
- Social engineering attacks
- Physical security
## Supported Versions
Security updates are provided for the latest release only.
| Version | Supported |
|---------|-----------|
| Latest | Yes |
| Older | No |
@@ -1,4 +1,4 @@
using System;
using System;
namespace Svrnty.CQRS.Abstractions.Attributes;
@@ -1,4 +1,4 @@
using System;
using System;
namespace Svrnty.CQRS.Abstractions.Attributes;
@@ -1,4 +1,4 @@
using System;
using System;
using System.Reflection;
using Svrnty.CQRS.Abstractions.Attributes;
@@ -19,7 +19,7 @@ public sealed class CommandMeta : ICommandMeta
ServiceType = serviceType;
}
private CommandNameAttribute NameAttribute => CommandType.GetCustomAttribute<CommandNameAttribute>();
private CommandNameAttribute? NameAttribute => CommandType.GetCustomAttribute<CommandNameAttribute>();
public string Name
{
@@ -32,7 +32,7 @@ public sealed class CommandMeta : ICommandMeta
public Type CommandType { get; }
public Type ServiceType { get; }
public Type CommandResultType { get; }
public Type? CommandResultType { get; }
public string LowerCamelCaseName
{
@@ -1,4 +1,4 @@
using System;
using System;
namespace Svrnty.CQRS.Abstractions.Discovery;
@@ -7,7 +7,7 @@ public interface ICommandMeta
string Name { get; }
Type CommandType { get; }
Type ServiceType { get; }
Type CommandResultType { get; }
Type? CommandResultType { get; }
string LowerCamelCaseName { get; }
}
@@ -1,12 +1,12 @@
using System;
using System;
using System.Collections.Generic;
namespace Svrnty.CQRS.Abstractions.Discovery;
public interface IQueryDiscovery
{
IQueryMeta FindQuery(string name);
IQueryMeta FindQuery(Type queryType);
IQueryMeta? FindQuery(string name);
IQueryMeta? FindQuery(Type queryType);
IEnumerable<IQueryMeta> GetQueries();
bool QueryExists(string name);
bool QueryExists(Type queryType);
@@ -16,8 +16,8 @@ public interface ICommandDiscovery
{
bool CommandExists(string name);
bool CommandExists(Type commandType);
ICommandMeta FindCommand(string name);
ICommandMeta FindCommand(Type commandType);
ICommandMeta? FindCommand(string name);
ICommandMeta? FindCommand(Type commandType);
IEnumerable<ICommandMeta> GetCommands();
}
@@ -1,4 +1,4 @@
using System;
using System;
namespace Svrnty.CQRS.Abstractions.Discovery;
@@ -10,4 +10,4 @@ public interface IQueryMeta
Type QueryResultType { get; }
string Category { get; }
string LowerCamelCaseName { get; }
}
}
@@ -1,4 +1,4 @@
using System;
using System;
using System.Reflection;
using Svrnty.CQRS.Abstractions.Attributes;
@@ -13,7 +13,7 @@ public class QueryMeta : IQueryMeta
QueryResultType = queryResultType;
}
protected virtual QueryNameAttribute NameAttribute => QueryType.GetCustomAttribute<QueryNameAttribute>();
protected virtual QueryNameAttribute? NameAttribute => QueryType.GetCustomAttribute<QueryNameAttribute>();
public virtual string Name
{
+2 -2
View File
@@ -1,4 +1,4 @@
using System.Threading;
using System.Threading;
using System.Threading.Tasks;
namespace Svrnty.CQRS.Abstractions;
@@ -13,4 +13,4 @@ public interface ICommandHandler<in TCommand, TCommandResult>
where TCommand : class
{
Task<TCommandResult> HandleAsync(TCommand command, CancellationToken cancellationToken = default);
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
using System.Threading;
using System.Threading;
using System.Threading.Tasks;
namespace Svrnty.CQRS.Abstractions;
@@ -7,4 +7,4 @@ public interface IQueryHandler<in TQuery, TQueryResult>
where TQuery : class
{
Task<TQueryResult> HandleAsync(TQuery query, CancellationToken cancellationToken = default);
}
}
@@ -1,8 +1,8 @@
namespace Svrnty.CQRS.Abstractions.Security;
namespace Svrnty.CQRS.Abstractions.Security;
public enum AuthorizationResult
{
Unauthorized,
Forbidden,
Allowed
}
}
@@ -1,4 +1,4 @@
using System;
using System;
using System.Threading;
using System.Threading.Tasks;
@@ -7,4 +7,4 @@ namespace Svrnty.CQRS.Abstractions.Security;
public interface ICommandAuthorizationService
{
Task<AuthorizationResult> IsAllowedAsync(Type commandType, CancellationToken cancellationToken = default);
}
}
@@ -1,4 +1,4 @@
using System;
using System;
using System.Threading;
using System.Threading.Tasks;
@@ -7,4 +7,4 @@ namespace Svrnty.CQRS.Abstractions.Security;
public interface IQueryAuthorizationService
{
Task<AuthorizationResult> IsAllowedAsync(Type queryType, CancellationToken cancellationToken = default);
}
}
@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Abstractions.Discovery;
@@ -47,4 +47,4 @@ public static class ServiceCollectionExtensions
return services;
}
}
}
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
@@ -1,4 +1,4 @@
using System.Linq;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -13,4 +13,4 @@ public interface IAlterQueryableService<TSource, TDestination, in TParams>
where TParams : class
{
Task<IQueryable<TSource>> AlterQueryableAsync(IQueryable<TSource> query, IDynamicQueryParams<TParams> dynamicQuery, CancellationToken cancellationToken = default);
}
}
@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using PoweredSoft.DynamicQuery.Core;
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
@@ -15,15 +15,15 @@ public interface IDynamicQuery<TSource, TDestination, out TParams> : IDynamicQue
where TDestination : class
where TParams : class
{
}
public interface IDynamicQuery
{
List<IFilter> GetFilters();
List<IGroup> GetGroups();
List<ISort> GetSorts();
List<IAggregate> GetAggregates();
List<IFilter>? GetFilters();
List<IGroup>? GetGroups();
List<ISort>? GetSorts();
List<IAggregate>? GetAggregates();
int? GetPage();
int? GetPageSize();
}
}
@@ -1,9 +1,9 @@
using System;
using System;
using System.Collections.Generic;
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
public interface IDynamicQueryInterceptorProvider<TSource, TDestination>
{
IEnumerable<Type> GetInterceptorsTypes();
}
}
@@ -1,7 +1,7 @@
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
public interface IDynamicQueryParams<out TParams>
where TParams : class
{
TParams GetParams();
TParams? GetParams();
}
@@ -1,4 +1,4 @@
using System.Linq;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -7,4 +7,4 @@ namespace Svrnty.CQRS.DynamicQuery.Abstractions;
public interface IQueryableProvider<TSource>
{
Task<IQueryable<TSource>> GetQueryableAsync(object query, CancellationToken cancellationToken = default);
}
}
@@ -7,7 +7,6 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using PoweredSoft.DynamicQuery.Core;
using Svrnty.CQRS.Abstractions;
using Svrnty.CQRS.Abstractions.Attributes;
using Svrnty.CQRS.Abstractions.Discovery;
@@ -15,6 +14,7 @@ using Svrnty.CQRS.Abstractions.Security;
using Svrnty.CQRS.DynamicQuery;
using Svrnty.CQRS.DynamicQuery.Abstractions;
using Svrnty.CQRS.DynamicQuery.Discover;
using PoweredSoft.DynamicQuery.Core;
namespace Svrnty.CQRS.DynamicQuery.MinimalApi;
@@ -1,4 +1,4 @@
using System;
using System;
using Pluralize.NET;
using Svrnty.CQRS.Abstractions.Discovery;
@@ -7,7 +7,7 @@ namespace Svrnty.CQRS.DynamicQuery.Discover;
public class DynamicQueryMeta(Type queryType, Type serviceType, Type queryResultType)
: QueryMeta(queryType, serviceType, queryResultType)
{
public Type SourceType => QueryType.GetGenericArguments()[0];
public Type SourceType => QueryType.GetGenericArguments()[0];
public Type DestinationType => QueryType.GetGenericArguments()[1];
public override string Category => "DynamicQuery";
public override string Name
@@ -22,7 +22,7 @@ public class DynamicQueryMeta(Type queryType, Type serviceType, Type queryResult
}
}
public Type ParamsType { get; internal set; }
public string OverridableName { get; internal set; }
public Type? ParamsType { get; internal set; }
public string? OverridableName { get; internal set; }
}
+12 -12
View File
@@ -1,8 +1,8 @@
using System.Collections.Generic;
using System.Linq;
using Svrnty.CQRS.DynamicQuery.Abstractions;
using PoweredSoft.DynamicQuery;
using PoweredSoft.DynamicQuery.Core;
using Svrnty.CQRS.DynamicQuery.Abstractions;
namespace Svrnty.CQRS.DynamicQuery;
@@ -18,9 +18,9 @@ public class DynamicQuery<TSource, TDestination, TParams> : DynamicQuery, IDynam
where TDestination : class
where TParams : class
{
public TParams Params { get; set; }
public TParams? Params { get; set; }
public TParams GetParams()
public TParams? GetParams()
{
return Params;
}
@@ -30,23 +30,23 @@ public class DynamicQuery : IDynamicQuery
{
public int? Page { get; set; }
public int? PageSize { get; set; }
public List<Sort> Sorts { get; set; }
public List<DynamicQueryAggregate> Aggregates { get; set; }
public List<Group> Groups { get; set; }
public List<DynamicQueryFilter> Filters { get; set; }
public List<Sort>? Sorts { get; set; }
public List<DynamicQueryAggregate>? Aggregates { get; set; }
public List<Group>? Groups { get; set; }
public List<DynamicQueryFilter>? Filters { get; set; }
public List<IAggregate> GetAggregates()
public List<IAggregate>? GetAggregates()
{
return Aggregates?.Select(t => t.ToAggregate())?.ToList();//.AsEnumerable<IAggregate>()?.ToList();
return Aggregates?.Select(t => t.ToAggregate())?.ToList();
}
public List<IFilter> GetFilters()
public List<IFilter>? GetFilters()
{
return Filters?.Select(t => t.ToFilter())?.ToList();
}
public List<IGroup> GetGroups()
public List<IGroup>? GetGroups()
{
return this.Groups?.AsEnumerable<IGroup>()?.ToList();
}
@@ -61,7 +61,7 @@ public class DynamicQuery : IDynamicQuery
return this.PageSize;
}
public List<ISort> GetSorts()
public List<ISort>? GetSorts()
{
return this.Sorts?.AsEnumerable<ISort>()?.ToList();
}
@@ -1,13 +1,13 @@
using System;
using PoweredSoft.DynamicQuery;
using PoweredSoft.DynamicQuery.Core;
using System;
namespace Svrnty.CQRS.DynamicQuery;
public class DynamicQueryAggregate
{
public string Path { get; set; }
public string Type { get; set; }
public required string Path { get; set; }
public required string Type { get; set; }
public IAggregate ToAggregate()
{
@@ -9,14 +9,14 @@ namespace Svrnty.CQRS.DynamicQuery;
public class DynamicQueryFilter
{
public List<DynamicQueryFilter> Filters { get; set; }
public List<DynamicQueryFilter>? Filters { get; set; }
public bool? And { get; set; }
public string Type { get; set; }
public string? Type { get; set; }
public bool? Not { get; set; }
public string Path { get; set; }
public object Value { get; set; }
public string? Path { get; set; }
public object? Value { get; set; }
public string QueryValue
public string? QueryValue
{
get
{
@@ -32,7 +32,7 @@ public class DynamicQueryFilter
public IFilter ToFilter()
{
var type = Enum.Parse<FilterType>(Type);
var type = Enum.Parse<FilterType>(Type!);
if (type == FilterType.Composite)
{
var compositeFilter = new CompositeFilter
@@ -44,7 +44,7 @@ public class DynamicQueryFilter
return compositeFilter;
}
object value = Value;
object? value = Value;
if (Value is JsonElement jsonElement)
{
switch (jsonElement.ValueKind)
+10 -10
View File
@@ -1,23 +1,23 @@
using Svrnty.CQRS.DynamicQuery.Abstractions;
using PoweredSoft.DynamicQuery.Core;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using PoweredSoft.DynamicQuery.Core;
using Svrnty.CQRS.DynamicQuery.Abstractions;
namespace Svrnty.CQRS.DynamicQuery;
public class DynamicQueryHandler<TSource, TDestination>
: DynamicQueryHandlerBase<TSource, TDestination>,
: DynamicQueryHandlerBase<TSource, TDestination>,
Svrnty.CQRS.Abstractions.IQueryHandler<IDynamicQuery<TSource, TDestination>, IQueryExecutionResult<TDestination>>
where TSource : class
where TDestination : class
{
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
IEnumerable<IQueryableProvider<TSource>> queryableProviders,
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
IServiceProvider serviceProvider) : base(queryHandlerAsync, queryableProviders, alterQueryableServices, dynamicQueryInterceptorProviders, serviceProvider)
{
}
@@ -29,7 +29,7 @@ public class DynamicQueryHandler<TSource, TDestination>
}
public class DynamicQueryHandler<TSource, TDestination, TParams>
: DynamicQueryHandlerBase<TSource, TDestination>,
: DynamicQueryHandlerBase<TSource, TDestination>,
Svrnty.CQRS.Abstractions.IQueryHandler<IDynamicQuery<TSource, TDestination, TParams>, IQueryExecutionResult<TDestination>>
where TSource : class
where TDestination : class
@@ -37,10 +37,10 @@ public class DynamicQueryHandler<TSource, TDestination, TParams>
{
private readonly IEnumerable<IAlterQueryableService<TSource, TDestination, TParams>> alterQueryableServicesWithParams;
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
IEnumerable<IQueryableProvider<TSource>> queryableProviders,
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
IEnumerable<IAlterQueryableService<TSource, TDestination, TParams>> alterQueryableServicesWithParams,
IEnumerable<IAlterQueryableService<TSource, TDestination, TParams>> alterQueryableServicesWithParams,
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
IServiceProvider serviceProvider) : base(queryHandlerAsync, queryableProviders, alterQueryableServices, dynamicQueryInterceptorProviders, serviceProvider)
{
@@ -49,7 +49,7 @@ public class DynamicQueryHandler<TSource, TDestination, TParams>
protected override async Task<IQueryable<TSource>> AlterSourceAsync(IQueryable<TSource> source, IDynamicQuery query, CancellationToken cancellationToken)
{
source = await base.AlterSourceAsync(source, query, cancellationToken);
source = await base.AlterSourceAsync(source, query, cancellationToken);
if (query is IDynamicQueryParams<TParams> withParams)
{
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
@@ -6,9 +6,9 @@ using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Svrnty.CQRS.DynamicQuery.Abstractions;
using PoweredSoft.DynamicQuery;
using PoweredSoft.DynamicQuery.Core;
using Svrnty.CQRS.DynamicQuery.Abstractions;
namespace Svrnty.CQRS.DynamicQuery;
@@ -60,7 +60,10 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
{
var types = _dynamicQueryInterceptorProviders.SelectMany(t => t.GetInterceptorsTypes()).Distinct();
foreach (var type in types)
yield return _serviceProvider.GetService(type) as IQueryInterceptor;
{
if (_serviceProvider.GetService(type) is IQueryInterceptor interceptor)
yield return interceptor;
}
}
protected async Task<IQueryExecutionResult<TDestination>> ProcessQueryAsync(IDynamicQuery query,
@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using PoweredSoft.Data.Core;
@@ -26,11 +26,11 @@ public static class ServiceCollectionExtensions
return new DynamicQueryServicesBuilder(services);
}
public static IServiceCollection AddDynamicQuery<TSourceAndDestination>(this IServiceCollection services, string name = null)
public static IServiceCollection AddDynamicQuery<TSourceAndDestination>(this IServiceCollection services, string? name = null)
where TSourceAndDestination : class
=> AddDynamicQuery<TSourceAndDestination, TSourceAndDestination>(services, name: name);
public static IServiceCollection AddDynamicQuery<TSource, TDestination>(this IServiceCollection services, string name = null)
public static IServiceCollection AddDynamicQuery<TSource, TDestination>(this IServiceCollection services, string? name = null)
where TSource : class
where TDestination : class
{
@@ -51,7 +51,7 @@ public static class ServiceCollectionExtensions
return services;
}
public static IServiceCollection AddDynamicQueryWithProvider<TSource, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TQueryableProvider>(this IServiceCollection services, string name = null)
public static IServiceCollection AddDynamicQueryWithProvider<TSource, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TQueryableProvider>(this IServiceCollection services, string? name = null)
where TQueryableProvider : class, IQueryableProvider<TSource>
where TSource : class
{
@@ -60,7 +60,7 @@ public static class ServiceCollectionExtensions
return services;
}
public static IServiceCollection AddDynamicQueryWithParamsAndProvider<TSource, TParams, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TQueryableProvider>(this IServiceCollection services, string name = null)
public static IServiceCollection AddDynamicQueryWithParamsAndProvider<TSource, TParams, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TQueryableProvider>(this IServiceCollection services, string? name = null)
where TQueryableProvider : class, IQueryableProvider<TSource>
where TParams : class
where TSource : class
@@ -86,15 +86,15 @@ public static class ServiceCollectionExtensions
return services;
}
public static IServiceCollection AddDynamicQueryWithParams<TSourceAndDestination, TParams>(this IServiceCollection services, string name = null)
public static IServiceCollection AddDynamicQueryWithParams<TSourceAndDestination, TParams>(this IServiceCollection services, string? name = null)
where TSourceAndDestination : class
where TParams : class
=> AddDynamicQueryWithParams<TSourceAndDestination, TSourceAndDestination, TParams>(services, name: name);
public static IServiceCollection AddDynamicQueryWithParams<TSource, TDestination, TParams>(this IServiceCollection services, string name = null)
where TSource : class
where TDestination : class
where TParams : class
public static IServiceCollection AddDynamicQueryWithParams<TSource, TDestination, TParams>(this IServiceCollection services, string? name = null)
where TSource : class
where TDestination : class
where TParams : class
{
// add query handler.
services.AddTransient<IQueryHandler<IDynamicQuery<TSource, TDestination, TParams>, IQueryExecutionResult<TDestination>>, DynamicQueryHandler<TSource, TDestination, TParams>>();
@@ -133,7 +133,7 @@ public static class ServiceCollectionExtensions
where TParams : class
where TService : class, IAlterQueryableService<TSourceAndTDestination, TSourceAndTDestination, TParams>
{
return services.AddTransient<IAlterQueryableService<TSourceAndTDestination, TSourceAndTDestination, TParams>, TService>();
return services.AddTransient<IAlterQueryableService< TSourceAndTDestination, TSourceAndTDestination, TParams>, TService>();
}
public static IServiceCollection AddAlterQueryableWithParams<TSource, TDestination, TParams, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TService>
@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Abstractions;
@@ -39,7 +39,7 @@ public static class ServiceCollectionExtensions
{
services.AddQuery<TQuery, TQueryResult, TQueryHandler>()
.AddFluentValidator<TQuery, TValidator>();
return services;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,101 +1,102 @@
using System.Collections.Generic;
namespace Svrnty.CQRS.Grpc.Generators.Helpers;
internal static class ProtoTypeMapper
namespace Svrnty.CQRS.Grpc.Generators.Helpers
{
private static readonly Dictionary<string, string> TypeMap = new Dictionary<string, string>
internal static class ProtoTypeMapper
{
// Primitives
{ "System.String", "string" },
{ "System.Boolean", "bool" },
{ "System.Int32", "int32" },
{ "System.Int64", "int64" },
{ "System.UInt32", "uint32" },
{ "System.UInt64", "uint64" },
{ "System.Single", "float" },
{ "System.Double", "double" },
{ "System.Byte", "uint32" },
{ "System.SByte", "int32" },
{ "System.Int16", "int32" },
{ "System.UInt16", "uint32" },
{ "System.Decimal", "string" }, // Decimal as string to preserve precision
{ "System.DateTime", "int64" }, // Unix timestamp
{ "System.DateTimeOffset", "int64" }, // Unix timestamp
{ "System.Guid", "string" },
{ "System.TimeSpan", "int64" }, // Ticks
// Nullable variants
{ "System.Boolean?", "bool" },
{ "System.Int32?", "int32" },
{ "System.Int64?", "int64" },
{ "System.UInt32?", "uint32" },
{ "System.UInt64?", "uint64" },
{ "System.Single?", "float" },
{ "System.Double?", "double" },
{ "System.Byte?", "uint32" },
{ "System.SByte?", "int32" },
{ "System.Int16?", "int32" },
{ "System.UInt16?", "uint32" },
{ "System.Decimal?", "string" },
{ "System.DateTime?", "int64" },
{ "System.DateTimeOffset?", "int64" },
{ "System.Guid?", "string" },
{ "System.TimeSpan?", "int64" },
};
public static string MapToProtoType(string csharpType, out bool isRepeated, out bool isOptional)
{
isRepeated = false;
isOptional = false;
// Handle byte[] as bytes proto type (NOT repeated uint32)
if (csharpType == "System.Byte[]" || csharpType == "byte[]" || csharpType == "Byte[]")
private static readonly Dictionary<string, string> TypeMap = new Dictionary<string, string>
{
return "bytes";
}
// Primitives
{ "System.String", "string" },
{ "System.Boolean", "bool" },
{ "System.Int32", "int32" },
{ "System.Int64", "int64" },
{ "System.UInt32", "uint32" },
{ "System.UInt64", "uint64" },
{ "System.Single", "float" },
{ "System.Double", "double" },
{ "System.Byte", "uint32" },
{ "System.SByte", "int32" },
{ "System.Int16", "int32" },
{ "System.UInt16", "uint32" },
{ "System.Decimal", "string" }, // Decimal as string to preserve precision
{ "System.DateTime", "int64" }, // Unix timestamp
{ "System.DateTimeOffset", "int64" }, // Unix timestamp
{ "System.Guid", "string" },
{ "System.TimeSpan", "int64" }, // Ticks
// Handle arrays
if (csharpType.EndsWith("[]"))
// Nullable variants
{ "System.Boolean?", "bool" },
{ "System.Int32?", "int32" },
{ "System.Int64?", "int64" },
{ "System.UInt32?", "uint32" },
{ "System.UInt64?", "uint64" },
{ "System.Single?", "float" },
{ "System.Double?", "double" },
{ "System.Byte?", "uint32" },
{ "System.SByte?", "int32" },
{ "System.Int16?", "int32" },
{ "System.UInt16?", "uint32" },
{ "System.Decimal?", "string" },
{ "System.DateTime?", "int64" },
{ "System.DateTimeOffset?", "int64" },
{ "System.Guid?", "string" },
{ "System.TimeSpan?", "int64" },
};
public static string MapToProtoType(string csharpType, out bool isRepeated, out bool isOptional)
{
isRepeated = true;
var elementType = csharpType.Substring(0, csharpType.Length - 2);
return MapToProtoType(elementType, out _, out _);
}
isRepeated = false;
isOptional = false;
// Handle generic collections
if (csharpType.StartsWith("System.Collections.Generic.List<") ||
csharpType.StartsWith("System.Collections.Generic.IList<") ||
csharpType.StartsWith("System.Collections.Generic.IEnumerable<") ||
csharpType.StartsWith("System.Collections.Generic.ICollection<"))
{
isRepeated = true;
var startIndex = csharpType.IndexOf('<') + 1;
var endIndex = csharpType.LastIndexOf('>');
var elementType = csharpType.Substring(startIndex, endIndex - startIndex);
return MapToProtoType(elementType, out _, out _);
}
// Handle byte[] as bytes proto type (NOT repeated uint32)
if (csharpType == "System.Byte[]" || csharpType == "byte[]" || csharpType == "Byte[]")
{
return "bytes";
}
// Handle nullable value types
if (csharpType.EndsWith("?"))
{
isOptional = true;
}
// Handle arrays
if (csharpType.EndsWith("[]"))
{
isRepeated = true;
var elementType = csharpType.Substring(0, csharpType.Length - 2);
return MapToProtoType(elementType, out _, out _);
}
// Check if it's a known primitive type
if (TypeMap.TryGetValue(csharpType, out var protoType))
{
return protoType;
}
// Handle generic collections
if (csharpType.StartsWith("System.Collections.Generic.List<") ||
csharpType.StartsWith("System.Collections.Generic.IList<") ||
csharpType.StartsWith("System.Collections.Generic.IEnumerable<") ||
csharpType.StartsWith("System.Collections.Generic.ICollection<"))
{
isRepeated = true;
var startIndex = csharpType.IndexOf('<') + 1;
var endIndex = csharpType.LastIndexOf('>');
var elementType = csharpType.Substring(startIndex, endIndex - startIndex);
return MapToProtoType(elementType, out _, out _);
}
// For unknown types, assume it's a custom message type
// Extract just the type name without namespace
var lastDot = csharpType.LastIndexOf('.');
if (lastDot >= 0)
{
return csharpType.Substring(lastDot + 1).Replace("?", "");
}
// Handle nullable value types
if (csharpType.EndsWith("?"))
{
isOptional = true;
}
return csharpType.Replace("?", "");
// Check if it's a known primitive type
if (TypeMap.TryGetValue(csharpType, out var protoType))
{
return protoType;
}
// For unknown types, assume it's a custom message type
// Extract just the type name without namespace
var lastDot = csharpType.LastIndexOf('.');
if (lastDot >= 0)
{
return csharpType.Substring(lastDot + 1).Replace("?", "");
}
return csharpType.Replace("?", "");
}
}
}
@@ -1,82 +1,83 @@
using System.Collections.Generic;
using Microsoft.CodeAnalysis;
namespace Svrnty.CQRS.Grpc.Generators.Models;
public class CommandInfo
namespace Svrnty.CQRS.Grpc.Generators.Models
{
public string Name { get; set; }
public string FullyQualifiedName { get; set; }
public string Namespace { get; set; }
public List<PropertyInfo> Properties { get; set; }
public string? ResultType { get; set; }
public string? ResultFullyQualifiedName { get; set; }
public bool HasResult => ResultType != null;
public string HandlerInterfaceName { get; set; }
public List<PropertyInfo> ResultProperties { get; set; }
public bool IsResultPrimitiveType { get; set; }
public CommandInfo()
public class CommandInfo
{
Name = string.Empty;
FullyQualifiedName = string.Empty;
Namespace = string.Empty;
Properties = new List<PropertyInfo>();
HandlerInterfaceName = string.Empty;
ResultProperties = new List<PropertyInfo>();
IsResultPrimitiveType = false;
}
}
public class PropertyInfo
{
public string Name { get; set; }
public string Type { get; set; }
public string FullyQualifiedType { get; set; }
public string ProtoType { get; set; }
public int FieldNumber { get; set; }
public bool IsComplexType { get; set; }
public List<PropertyInfo> NestedProperties { get; set; }
// Type conversion metadata
public bool IsEnum { get; set; }
public bool IsList { get; set; }
public bool IsNullable { get; set; }
public bool IsDecimal { get; set; }
public bool IsDateTime { get; set; }
public bool IsDateTimeOffset { get; set; }
public bool IsGuid { get; set; }
public bool IsJsonElement { get; set; }
public bool IsBinaryType { get; set; } // Stream, byte[], MemoryStream
public bool IsStream { get; set; } // Specifically Stream types (not byte[])
public bool IsReadOnly { get; set; } // Read-only/computed properties should be skipped
public bool IsValueTypeCollection { get; set; } // Value types that implement IList<T> (like NpgsqlPolygon)
public string? ElementType { get; set; }
public bool IsElementComplexType { get; set; }
public bool IsElementGuid { get; set; }
public List<PropertyInfo>? ElementNestedProperties { get; set; }
public PropertyInfo()
{
Name = string.Empty;
Type = string.Empty;
FullyQualifiedType = string.Empty;
ProtoType = string.Empty;
IsComplexType = false;
NestedProperties = new List<PropertyInfo>();
IsEnum = false;
IsList = false;
IsNullable = false;
IsDecimal = false;
IsDateTime = false;
IsDateTimeOffset = false;
IsGuid = false;
IsJsonElement = false;
IsBinaryType = false;
IsStream = false;
IsReadOnly = false;
IsValueTypeCollection = false;
IsElementComplexType = false;
IsElementGuid = false;
public string Name { get; set; }
public string FullyQualifiedName { get; set; }
public string Namespace { get; set; }
public List<PropertyInfo> Properties { get; set; }
public string? ResultType { get; set; }
public string? ResultFullyQualifiedName { get; set; }
public bool HasResult => ResultType != null;
public string HandlerInterfaceName { get; set; }
public List<PropertyInfo> ResultProperties { get; set; }
public bool IsResultPrimitiveType { get; set; }
public CommandInfo()
{
Name = string.Empty;
FullyQualifiedName = string.Empty;
Namespace = string.Empty;
Properties = new List<PropertyInfo>();
HandlerInterfaceName = string.Empty;
ResultProperties = new List<PropertyInfo>();
IsResultPrimitiveType = false;
}
}
public class PropertyInfo
{
public string Name { get; set; }
public string Type { get; set; }
public string FullyQualifiedType { get; set; }
public string ProtoType { get; set; }
public int FieldNumber { get; set; }
public bool IsComplexType { get; set; }
public List<PropertyInfo> NestedProperties { get; set; }
// Type conversion metadata
public bool IsEnum { get; set; }
public bool IsList { get; set; }
public bool IsNullable { get; set; }
public bool IsDecimal { get; set; }
public bool IsDateTime { get; set; }
public bool IsDateTimeOffset { get; set; }
public bool IsGuid { get; set; }
public bool IsJsonElement { get; set; }
public bool IsBinaryType { get; set; } // Stream, byte[], MemoryStream
public bool IsStream { get; set; } // Specifically Stream types (not byte[])
public bool IsReadOnly { get; set; } // Read-only/computed properties should be skipped
public bool IsValueTypeCollection { get; set; } // Value types that implement IList<T> (like NpgsqlPolygon)
public string? ElementType { get; set; }
public bool IsElementComplexType { get; set; }
public bool IsElementGuid { get; set; }
public List<PropertyInfo>? ElementNestedProperties { get; set; }
public PropertyInfo()
{
Name = string.Empty;
Type = string.Empty;
FullyQualifiedType = string.Empty;
ProtoType = string.Empty;
IsComplexType = false;
NestedProperties = new List<PropertyInfo>();
IsEnum = false;
IsList = false;
IsNullable = false;
IsDecimal = false;
IsDateTime = false;
IsDateTimeOffset = false;
IsGuid = false;
IsJsonElement = false;
IsBinaryType = false;
IsStream = false;
IsReadOnly = false;
IsValueTypeCollection = false;
IsElementComplexType = false;
IsElementGuid = false;
}
}
}
@@ -1,27 +1,28 @@
namespace Svrnty.CQRS.Grpc.Generators.Models;
public class DynamicQueryInfo
namespace Svrnty.CQRS.Grpc.Generators.Models
{
public string Name { get; set; }
public string SourceType { get; set; }
public string SourceTypeFullyQualified { get; set; }
public string DestinationType { get; set; }
public string DestinationTypeFullyQualified { get; set; }
public string? ParamsType { get; set; }
public string? ParamsTypeFullyQualified { get; set; }
public string HandlerInterfaceName { get; set; }
public string QueryInterfaceName { get; set; }
public bool HasParams { get; set; }
public DynamicQueryInfo()
public class DynamicQueryInfo
{
Name = string.Empty;
SourceType = string.Empty;
SourceTypeFullyQualified = string.Empty;
DestinationType = string.Empty;
DestinationTypeFullyQualified = string.Empty;
HandlerInterfaceName = string.Empty;
QueryInterfaceName = string.Empty;
HasParams = false;
public string Name { get; set; }
public string SourceType { get; set; }
public string SourceTypeFullyQualified { get; set; }
public string DestinationType { get; set; }
public string DestinationTypeFullyQualified { get; set; }
public string? ParamsType { get; set; }
public string? ParamsTypeFullyQualified { get; set; }
public string HandlerInterfaceName { get; set; }
public string QueryInterfaceName { get; set; }
public bool HasParams { get; set; }
public DynamicQueryInfo()
{
Name = string.Empty;
SourceType = string.Empty;
SourceTypeFullyQualified = string.Empty;
DestinationType = string.Empty;
DestinationTypeFullyQualified = string.Empty;
HandlerInterfaceName = string.Empty;
QueryInterfaceName = string.Empty;
HasParams = false;
}
}
}
@@ -1,49 +1,50 @@
using System.Collections.Generic;
namespace Svrnty.CQRS.Grpc.Generators.Models;
/// <summary>
/// Represents a discovered streaming notification type for proto/gRPC generation.
/// </summary>
public class NotificationInfo
namespace Svrnty.CQRS.Grpc.Generators.Models
{
/// <summary>
/// The notification type name (e.g., "InventoryChangeNotification").
/// Represents a discovered streaming notification type for proto/gRPC generation.
/// </summary>
public string Name { get; set; }
/// <summary>
/// The fully qualified type name including namespace.
/// </summary>
public string FullyQualifiedName { get; set; }
/// <summary>
/// The namespace of the notification type.
/// </summary>
public string Namespace { get; set; }
/// <summary>
/// The property name used as the subscription key (from [StreamingNotification] attribute).
/// </summary>
public string SubscriptionKeyProperty { get; set; }
/// <summary>
/// The subscription key property info.
/// </summary>
public PropertyInfo SubscriptionKeyInfo { get; set; }
/// <summary>
/// All properties of the notification type.
/// </summary>
public List<PropertyInfo> Properties { get; set; }
public NotificationInfo()
public class NotificationInfo
{
Name = string.Empty;
FullyQualifiedName = string.Empty;
Namespace = string.Empty;
SubscriptionKeyProperty = string.Empty;
SubscriptionKeyInfo = new PropertyInfo();
Properties = new List<PropertyInfo>();
/// <summary>
/// The notification type name (e.g., "InventoryChangeNotification").
/// </summary>
public string Name { get; set; }
/// <summary>
/// The fully qualified type name including namespace.
/// </summary>
public string FullyQualifiedName { get; set; }
/// <summary>
/// The namespace of the notification type.
/// </summary>
public string Namespace { get; set; }
/// <summary>
/// The property name used as the subscription key (from [StreamingNotification] attribute).
/// </summary>
public string SubscriptionKeyProperty { get; set; }
/// <summary>
/// The subscription key property info.
/// </summary>
public PropertyInfo SubscriptionKeyInfo { get; set; }
/// <summary>
/// All properties of the notification type.
/// </summary>
public List<PropertyInfo> Properties { get; set; }
public NotificationInfo()
{
Name = string.Empty;
FullyQualifiedName = string.Empty;
Namespace = string.Empty;
SubscriptionKeyProperty = string.Empty;
SubscriptionKeyInfo = new PropertyInfo();
Properties = new List<PropertyInfo>();
}
}
}
+24 -23
View File
@@ -1,29 +1,30 @@
using System.Collections.Generic;
namespace Svrnty.CQRS.Grpc.Generators.Models;
public class QueryInfo
namespace Svrnty.CQRS.Grpc.Generators.Models
{
public string Name { get; set; }
public string FullyQualifiedName { get; set; }
public string Namespace { get; set; }
public List<PropertyInfo> Properties { get; set; }
public string ResultType { get; set; }
public string ResultFullyQualifiedName { get; set; }
public string HandlerInterfaceName { get; set; }
public List<PropertyInfo> ResultProperties { get; set; }
public bool IsResultPrimitiveType { get; set; }
public QueryInfo()
public class QueryInfo
{
Name = string.Empty;
FullyQualifiedName = string.Empty;
Namespace = string.Empty;
Properties = new List<PropertyInfo>();
ResultType = string.Empty;
ResultFullyQualifiedName = string.Empty;
HandlerInterfaceName = string.Empty;
ResultProperties = new List<PropertyInfo>();
IsResultPrimitiveType = false;
public string Name { get; set; }
public string FullyQualifiedName { get; set; }
public string Namespace { get; set; }
public List<PropertyInfo> Properties { get; set; }
public string ResultType { get; set; }
public string ResultFullyQualifiedName { get; set; }
public string HandlerInterfaceName { get; set; }
public List<PropertyInfo> ResultProperties { get; set; }
public bool IsResultPrimitiveType { get; set; }
public QueryInfo()
{
Name = string.Empty;
FullyQualifiedName = string.Empty;
Namespace = string.Empty;
Properties = new List<PropertyInfo>();
ResultType = string.Empty;
ResultFullyQualifiedName = string.Empty;
HandlerInterfaceName = string.Empty;
ResultProperties = new List<PropertyInfo>();
IsResultPrimitiveType = false;
}
}
}
BIN
View File
Binary file not shown.
+19
View File
@@ -43,6 +43,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Events.Abstract
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Events.RabbitMQ", "Svrnty.CQRS.Events.RabbitMQ\Svrnty.CQRS.Events.RabbitMQ.csproj", "{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Tests", "tests\Svrnty.CQRS.Tests\Svrnty.CQRS.Tests.csproj", "{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -257,10 +261,25 @@ Global
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x64.Build.0 = Release|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x86.ActiveCfg = Release|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x86.Build.0 = Release|Any CPU
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Debug|x64.ActiveCfg = Debug|Any CPU
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Debug|x64.Build.0 = Debug|Any CPU
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Debug|x86.ActiveCfg = Debug|Any CPU
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Debug|x86.Build.0 = Debug|Any CPU
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Release|Any CPU.Build.0 = Release|Any CPU
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Release|x64.ActiveCfg = Release|Any CPU
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Release|x64.Build.0 = Release|Any CPU
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Release|x86.ActiveCfg = Release|Any CPU
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D6D431EA-C04F-462B-8033-60F510FEB49E}
EndGlobalSection
BIN
View File
Binary file not shown.
+2 -3
View File
@@ -1,4 +1,3 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Abstractions;
using Svrnty.CQRS.Discovery;
@@ -44,7 +43,7 @@ public class CqrsBuilder
/// <summary>
/// Adds a command handler to the CQRS pipeline
/// </summary>
public CqrsBuilder AddCommand<TCommand, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TCommandHandler>()
public CqrsBuilder AddCommand<TCommand, TCommandHandler>()
where TCommand : class
where TCommandHandler : class, ICommandHandler<TCommand>
{
@@ -55,7 +54,7 @@ public class CqrsBuilder
/// <summary>
/// Adds a command handler with result to the CQRS pipeline
/// </summary>
public CqrsBuilder AddCommand<TCommand, TResult, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TCommandHandler>()
public CqrsBuilder AddCommand<TCommand, TResult, TCommandHandler>()
where TCommand : class
where TCommandHandler : class, ICommandHandler<TCommand, TResult>
{
+3 -3
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using Svrnty.CQRS.Abstractions.Discovery;
@@ -15,8 +15,8 @@ public sealed class CommandDiscovery : ICommandDiscovery
}
public IEnumerable<ICommandMeta> GetCommands() => _commandMetas;
public ICommandMeta FindCommand(string name) => _commandMetas.FirstOrDefault(t => t.Name == name);
public ICommandMeta FindCommand(Type commandType) => _commandMetas.FirstOrDefault(t => t.CommandType == commandType);
public ICommandMeta? FindCommand(string name) => _commandMetas.FirstOrDefault(t => t.Name == name);
public ICommandMeta? FindCommand(Type commandType) => _commandMetas.FirstOrDefault(t => t.CommandType == commandType);
public bool CommandExists(string name) => _commandMetas.Any(t => t.Name == name);
public bool CommandExists(Type commandType) => _commandMetas.Any(t => t.CommandType == commandType);
}
+3 -3
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using Svrnty.CQRS.Abstractions.Discovery;
@@ -15,8 +15,8 @@ public sealed class QueryDiscovery : IQueryDiscovery
}
public IEnumerable<IQueryMeta> GetQueries() => _queryMetas;
public IQueryMeta FindQuery(string name) => _queryMetas.FirstOrDefault(t => t.Name == name);
public IQueryMeta FindQuery(Type queryType) => _queryMetas.FirstOrDefault(t => t.QueryType == queryType);
public IQueryMeta? FindQuery(string name) => _queryMetas.FirstOrDefault(t => t.Name == name);
public IQueryMeta? FindQuery(Type queryType) => _queryMetas.FirstOrDefault(t => t.QueryType == queryType);
public bool QueryExists(string name) => _queryMetas.Any(t => t.Name == name);
public bool QueryExists(Type queryType) => _queryMetas.Any(t => t.QueryType == queryType);
}
+3 -3
View File
@@ -1,11 +1,11 @@
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Svrnty.CQRS;
using Svrnty.CQRS.Abstractions;
using Svrnty.CQRS.DynamicQuery;
using Svrnty.CQRS.FluentValidation;
using Svrnty.CQRS.Grpc;
using Svrnty.CQRS.MinimalApi;
using Svrnty.Sample;
using Svrnty.CQRS.MinimalApi;
using Svrnty.CQRS.DynamicQuery;
using Svrnty.CQRS.Abstractions;
var builder = WebApplication.CreateBuilder(args);
+1 -1
View File
@@ -1,5 +1,5 @@
using System.Linq.Expressions;
using PoweredSoft.Data.Core;
using System.Linq.Expressions;
namespace Svrnty.Sample;
+178
View File
@@ -0,0 +1,178 @@
# Architecture
> Svrnty.CQRS is a modular CQRS/event-sourcing framework for .NET 10, organized as 18 NuGet packages with clear separation of concerns.
## Package Dependency Graph
```
Svrnty.CQRS.Abstractions
(ICommandHandler, IQueryHandler)
|
+-----------------+-----------------+
| |
Svrnty.CQRS Svrnty.CQRS.FluentValidation
(Discovery, Registration, (AbstractValidator<T> binding)
CqrsBuilder, DI) depends on: Abstractions, Core
|
+------------+------------+---------------------------+
| | | |
MinimalApi Grpc DynamicQuery Sagas
(HTTP REST) (gRPC) (Filtering, (Orchestrator,
| Sorting, Paging) Compensation)
| | |
Grpc.Abstractions DQ.Abstractions Sagas.Abstractions
(GrpcIgnore attr) (IQueryableProvider) (ISaga, ISagaBuilder,
| | | ISagaOrchestrator)
Grpc.Generators DQ.MinimalApi | |
(Source gen, (HTTP endpoints | Sagas.RabbitMQ
.proto gen) for DQ) | (RabbitMQ transport)
|
DQ.EntityFramework
(EF Core provider)
Events.Abstractions Notifications.Abstractions
(IDomainEvent, (INotificationPublisher,
IDomainEventPublisher) StreamingNotificationAttribute)
| |
Events.RabbitMQ Notifications.Grpc
(RabbitMQ transport) (gRPC streaming)
```
## Dependency Matrix
| Package | Depends On (internal) |
|---|---|
| `Svrnty.CQRS.Abstractions` | _(none)_ |
| `Svrnty.CQRS` | Abstractions |
| `Svrnty.CQRS.MinimalApi` | Abstractions, Core |
| `Svrnty.CQRS.Grpc` | Core |
| `Svrnty.CQRS.Grpc.Abstractions` | _(none)_ |
| `Svrnty.CQRS.Grpc.Generators` | _(none, Roslyn source gen)_ |
| `Svrnty.CQRS.FluentValidation` | Abstractions, Core |
| `Svrnty.CQRS.DynamicQuery.Abstractions` | _(none)_ |
| `Svrnty.CQRS.DynamicQuery` | DynamicQuery.Abstractions, Core |
| `Svrnty.CQRS.DynamicQuery.MinimalApi` | Abstractions, DynamicQuery.Abstractions, DynamicQuery |
| `Svrnty.CQRS.DynamicQuery.EntityFramework` | DynamicQuery |
| `Svrnty.CQRS.Events.Abstractions` | _(none)_ |
| `Svrnty.CQRS.Events.RabbitMQ` | Events.Abstractions |
| `Svrnty.CQRS.Sagas.Abstractions` | _(none)_ |
| `Svrnty.CQRS.Sagas` | Core, Sagas.Abstractions |
| `Svrnty.CQRS.Sagas.RabbitMQ` | Sagas |
| `Svrnty.CQRS.Notifications.Abstractions` | _(none)_ |
| `Svrnty.CQRS.Notifications.Grpc` | Notifications.Abstractions |
## CQRS Data Flow
### Command Flow
```
Client Request
|
v
[MinimalApi POST /api/command/{name}] or [gRPC CommandService/{name}]
|
v
FluentValidation (if validator registered)
|
|-- Validation fails --> RFC 7807 ProblemDetails (HTTP) / Google Rich Error (gRPC)
|
v
ICommandHandler<TCommand, TResult>.HandleAsync(command, ct)
|
v
Command Result (or void)
|
+--> (optional) IDomainEventPublisher.PublishAsync(event)
+--> (optional) INotificationPublisher.PublishAsync(notification)
```
### Query Flow
```
Client Request
|
v
[MinimalApi POST /api/query/{name}] or [gRPC QueryService/{name}]
|
v
IQueryHandler<TQuery, TResult>.HandleAsync(query, ct)
|
v
Query Result
```
### Dynamic Query Flow
```
Client Request (with filters, sorts, pagination)
|
v
[MinimalApi POST /api/dynamic-query/{entity}]
|
v
IQueryableProvider<TSource>.GetQueryableAsync(query, ct)
|
v
PoweredSoft.DynamicQuery engine (applies filters, sorts, groups, aggregates)
|
v
IAlterQueryableService (optional interception)
|
v
Paged/Grouped result set
```
### Saga Flow
```
ISagaOrchestrator.StartAsync<TSaga, TData>(data)
|
v
ISaga<TData>.Configure(builder) -- defines steps
|
v
Step 1: Execute --> Step 2: Execute --> Step 3: Execute --> Completed
| | |
| | +-- fails -->
| | |
| +-- compensate <-----------------+
| |
+-- compensate <-----------------+
|
v
Compensated (rolled back)
```
## Separation of Concerns
The framework follows a layered architecture:
1. **Abstractions layer** (4 packages) -- Pure interfaces and marker types with zero dependencies. Can be referenced by any project without pulling in implementation details.
- `Svrnty.CQRS.Abstractions`
- `Svrnty.CQRS.DynamicQuery.Abstractions`
- `Svrnty.CQRS.Events.Abstractions`
- `Svrnty.CQRS.Sagas.Abstractions`
- `Svrnty.CQRS.Grpc.Abstractions`
- `Svrnty.CQRS.Notifications.Abstractions`
2. **Core layer** (1 package) -- Handler discovery, DI registration, and the `CqrsBuilder` fluent API.
- `Svrnty.CQRS`
3. **Transport layer** (4 packages) -- Maps commands/queries to HTTP or gRPC endpoints.
- `Svrnty.CQRS.MinimalApi`
- `Svrnty.CQRS.Grpc`
- `Svrnty.CQRS.Grpc.Generators`
- `Svrnty.CQRS.DynamicQuery.MinimalApi`
4. **Feature layer** (4 packages) -- Optional capabilities that can be composed in.
- `Svrnty.CQRS.FluentValidation`
- `Svrnty.CQRS.DynamicQuery`
- `Svrnty.CQRS.DynamicQuery.EntityFramework`
- `Svrnty.CQRS.Sagas`
5. **Infrastructure layer** (3 packages) -- Concrete transport bindings for messaging and streaming.
- `Svrnty.CQRS.Events.RabbitMQ`
- `Svrnty.CQRS.Sagas.RabbitMQ`
- `Svrnty.CQRS.Notifications.Grpc`
This layering ensures that application code depends only on abstractions, while transport and infrastructure concerns remain pluggable.
+514
View File
@@ -0,0 +1,514 @@
# Getting Started
> Step-by-step guide to building a CQRS application with Svrnty.CQRS on .NET 10.
## Prerequisites
- .NET 10 SDK
- A text editor or IDE with C# support
## 1. Create a New Project
```bash
dotnet new web -n MyCqrsApp
cd MyCqrsApp
```
Add the required packages:
```bash
dotnet add package Svrnty.CQRS
dotnet add package Svrnty.CQRS.Abstractions
dotnet add package Svrnty.CQRS.MinimalApi
dotnet add package Svrnty.CQRS.FluentValidation
```
## 2. Define Commands and Queries
### Command with Result
A command represents an action that changes state. Implement `ICommandHandler<TCommand, TResult>` for commands that return a value.
```csharp
using Svrnty.CQRS.Abstractions;
// The command (a plain record/class)
public record CreateUserCommand
{
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public int Age { get; set; }
}
// The handler
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, int>
{
public Task<int> HandleAsync(CreateUserCommand command, CancellationToken cancellationToken = default)
{
// Your business logic here -- persist to database, etc.
return Task.FromResult(123); // Return the new user ID
}
}
```
### Command without Result
For commands that do not return a value, implement `ICommandHandler<TCommand>`:
```csharp
public record DeleteUserCommand
{
public int UserId { get; set; }
}
public class DeleteUserCommandHandler : ICommandHandler<DeleteUserCommand>
{
public Task HandleAsync(DeleteUserCommand command, CancellationToken cancellationToken = default)
{
// Delete the user
return Task.CompletedTask;
}
}
```
### Query
A query retrieves data without side effects. Implement `IQueryHandler<TQuery, TResult>`:
```csharp
public record GetUserQuery
{
public int UserId { get; set; }
}
public record UserDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
}
public class GetUserQueryHandler : IQueryHandler<GetUserQuery, UserDto>
{
public Task<UserDto> HandleAsync(GetUserQuery query, CancellationToken cancellationToken = default)
{
return Task.FromResult(new UserDto
{
Id = query.UserId,
Name = "John Doe",
Email = "john@example.com"
});
}
}
```
## 3. Register Handlers
In `Program.cs`, register your handlers with the DI container:
```csharp
using Svrnty.CQRS;
using Svrnty.CQRS.Abstractions;
using Svrnty.CQRS.MinimalApi;
var builder = WebApplication.CreateBuilder(args);
// Register command and query handlers
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
builder.Services.AddCommand<DeleteUserCommand, DeleteUserCommandHandler>();
builder.Services.AddQuery<GetUserQuery, UserDto, GetUserQueryHandler>();
// Configure CQRS with MinimalApi transport
builder.Services.AddSvrntyCqrs(cqrs =>
{
cqrs.AddMinimalApi();
});
var app = builder.Build();
// Map all CQRS endpoints
app.UseSvrntyCqrs();
app.Run();
```
This will expose:
- `POST /api/command/CreateUser` -- executes CreateUserCommand
- `POST /api/command/DeleteUser` -- executes DeleteUserCommand
- `POST /api/query/GetUser` -- executes GetUserQuery
## 4. Add FluentValidation
Add validators to enforce business rules before handler execution:
```csharp
using FluentValidation;
public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
public CreateUserCommandValidator()
{
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required")
.EmailAddress().WithMessage("Email must be valid");
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Name is required");
RuleFor(x => x.Age)
.GreaterThan(0).WithMessage("Age must be greater than 0");
}
}
```
Register the command with its validator using the 4-type-parameter overload:
```csharp
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler, CreateUserCommandValidator>();
```
Validation errors are returned as RFC 7807 Problem Details (HTTP) or Google Rich Error Model (gRPC).
## 5. gRPC Setup
Add the gRPC packages:
```bash
dotnet add package Svrnty.CQRS.Grpc
dotnet add package Svrnty.CQRS.Grpc.Generators
dotnet add package Svrnty.CQRS.Grpc.Abstractions
```
Configure Kestrel for dual-protocol support and enable gRPC:
```csharp
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Svrnty.CQRS.Grpc;
var builder = WebApplication.CreateBuilder(args);
// Configure dual ports
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenLocalhost(6000, o => o.Protocols = HttpProtocols.Http2); // gRPC
options.ListenLocalhost(6001, o => o.Protocols = HttpProtocols.Http1); // HTTP API
});
// Register handlers (same as before)
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
builder.Services.AddQuery<GetUserQuery, UserDto, GetUserQueryHandler>();
// Enable both gRPC and MinimalApi
builder.Services.AddSvrntyCqrs(cqrs =>
{
cqrs.AddGrpc(grpc =>
{
grpc.EnableReflection(); // Enable gRPC reflection for tools like grpcurl
});
cqrs.AddMinimalApi();
});
var app = builder.Build();
app.UseSvrntyCqrs();
app.Run();
```
The `Svrnty.CQRS.Grpc.Generators` package automatically generates `.proto` files and gRPC service implementations from your registered command/query types at build time.
### Excluding Commands from gRPC
Use the `[GrpcIgnore]` attribute to prevent a command or query from being exposed via gRPC:
```csharp
using Svrnty.CQRS.Grpc.Abstractions.Attributes;
[GrpcIgnore]
public record InternalCommand
{
public string Data { get; set; } = string.Empty;
}
```
## 6. DynamicQuery Usage
Dynamic queries provide automatic filtering, sorting, grouping, and pagination for entity collections.
Add the packages:
```bash
dotnet add package Svrnty.CQRS.DynamicQuery
dotnet add package Svrnty.CQRS.DynamicQuery.Abstractions
dotnet add package Svrnty.CQRS.DynamicQuery.MinimalApi
```
### Define a Queryable Provider
Implement `IQueryableProvider<T>` to supply the data source:
```csharp
using Svrnty.CQRS.DynamicQuery.Abstractions;
public class UserQueryableProvider : IQueryableProvider<UserDto>
{
private readonly MyDbContext _db;
public UserQueryableProvider(MyDbContext db)
{
_db = db;
}
public Task<IQueryable<UserDto>> GetQueryableAsync(object query, CancellationToken cancellationToken = default)
{
return Task.FromResult(_db.Users.AsQueryable());
}
}
```
### Register the Provider
```csharp
using Svrnty.CQRS.DynamicQuery;
// Register PoweredSoft dependencies
builder.Services.AddTransient<PoweredSoft.Data.Core.IAsyncQueryableService, MyAsyncQueryableService>();
builder.Services.AddTransient<PoweredSoft.DynamicQuery.Core.IQueryHandlerAsync, PoweredSoft.DynamicQuery.QueryHandlerAsync>();
// Register the dynamic query provider
builder.Services.AddDynamicQueryWithProvider<UserDto, UserQueryableProvider>();
```
This exposes a POST endpoint that accepts filter, sort, group, and pagination parameters, returning paged results automatically.
### Entity Framework Integration
For EF Core projects, add the EF integration package:
```bash
dotnet add package Svrnty.CQRS.DynamicQuery.EntityFramework
```
This provides a ready-made `IAsyncQueryableService` backed by EF Core.
## 7. Domain Events
Domain events allow you to publish side effects after a command completes.
Add the packages:
```bash
dotnet add package Svrnty.CQRS.Events.Abstractions
dotnet add package Svrnty.CQRS.Events.RabbitMQ # or implement your own IDomainEventPublisher
```
### Define an Event
```csharp
using Svrnty.CQRS.Events.Abstractions;
public record UserCreatedEvent : IDomainEvent
{
public Guid EventId { get; } = Guid.NewGuid();
public DateTime OccurredAt { get; } = DateTime.UtcNow;
public int UserId { get; init; }
public string Email { get; init; } = string.Empty;
}
```
### Publish from a Command Handler
```csharp
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, int>
{
private readonly IDomainEventPublisher _events;
public CreateUserCommandHandler(IDomainEventPublisher events)
{
_events = events;
}
public async Task<int> HandleAsync(CreateUserCommand command, CancellationToken ct = default)
{
var userId = 123; // persist user
await _events.PublishAsync(new UserCreatedEvent
{
UserId = userId,
Email = command.Email
}, ct);
return userId;
}
}
```
## 8. Saga Pattern
Sagas orchestrate multi-step workflows with automatic compensation (rollback) on failure.
Add the packages:
```bash
dotnet add package Svrnty.CQRS.Sagas
dotnet add package Svrnty.CQRS.Sagas.Abstractions
dotnet add package Svrnty.CQRS.Sagas.RabbitMQ # for distributed sagas
```
### Define Saga Data
```csharp
using Svrnty.CQRS.Sagas.Abstractions;
public class CreateOrderSagaData : ISagaData
{
public Guid CorrelationId { get; set; }
public int OrderId { get; set; }
public int PaymentId { get; set; }
public decimal Amount { get; set; }
}
```
### Define a Saga
```csharp
public class CreateOrderSaga : ISaga<CreateOrderSagaData>
{
public void Configure(ISagaBuilder<CreateOrderSagaData> builder)
{
builder
.Step("CreateOrder")
.Execute(async (data, ctx, ct) =>
{
// Create the order
data.OrderId = 42;
})
.Compensate(async (data, ctx, ct) =>
{
// Cancel the order on rollback
})
.Then()
.Step("ProcessPayment")
.Execute(async (data, ctx, ct) =>
{
// Charge payment
data.PaymentId = 99;
})
.Compensate(async (data, ctx, ct) =>
{
// Refund payment on rollback
})
.Then();
}
}
```
### Execute a Saga
```csharp
using Svrnty.CQRS.Sagas.Abstractions;
public class OrderCommandHandler : ICommandHandler<PlaceOrderCommand, int>
{
private readonly ISagaOrchestrator _orchestrator;
public OrderCommandHandler(ISagaOrchestrator orchestrator)
{
_orchestrator = orchestrator;
}
public async Task<int> HandleAsync(PlaceOrderCommand command, CancellationToken ct = default)
{
var state = await _orchestrator.StartAsync<CreateOrderSaga, CreateOrderSagaData>(
new CreateOrderSagaData { Amount = command.Amount }, ct);
// state.Status will be Completed or Compensated
return state.Status == SagaStatus.Completed ? 1 : 0;
}
}
```
Saga statuses: `NotStarted` -> `InProgress` -> `Completed` (success) or `Failed` -> `Compensating` -> `Compensated` (rolled back).
### Remote Steps (Distributed Sagas)
For steps that execute on remote services via RabbitMQ:
```csharp
builder
.SendCommand<ChargePaymentCommand, PaymentResult>("ChargePayment")
.WithCommand((data, ctx) => new ChargePaymentCommand { Amount = data.Amount })
.OnResponse(async (data, ctx, result, ct) =>
{
data.PaymentId = result.PaymentId;
})
.Compensate<RefundPaymentCommand>((data, ctx) =>
new RefundPaymentCommand { PaymentId = data.PaymentId })
.WithTimeout(TimeSpan.FromSeconds(30))
.WithRetry(maxRetries: 3, delay: TimeSpan.FromSeconds(2))
.Then();
```
## 9. Real-Time Notifications
For pushing real-time updates to clients via gRPC streaming:
```bash
dotnet add package Svrnty.CQRS.Notifications.Abstractions
dotnet add package Svrnty.CQRS.Notifications.Grpc
```
### Define a Notification
```csharp
using Svrnty.CQRS.Notifications.Abstractions;
[StreamingNotification(SubscriptionKey = "user-updates")]
public record UserUpdatedNotification
{
public int UserId { get; init; }
public string NewEmail { get; init; } = string.Empty;
}
```
### Publish a Notification
```csharp
public class UpdateUserCommandHandler : ICommandHandler<UpdateUserCommand>
{
private readonly INotificationPublisher _notifications;
public UpdateUserCommandHandler(INotificationPublisher notifications)
{
_notifications = notifications;
}
public async Task HandleAsync(UpdateUserCommand command, CancellationToken ct = default)
{
// Update user...
await _notifications.PublishAsync(new UserUpdatedNotification
{
UserId = command.UserId,
NewEmail = command.NewEmail
}, ct);
}
}
```
## Running the Sample App
The repository includes a complete sample application:
```bash
cd Svrnty.Sample
dotnet run
```
This starts:
- gRPC server on `http://localhost:6000` (HTTP/2)
- HTTP API on `http://localhost:6001` (HTTP/1.1)
- Swagger UI at `http://localhost:6001/swagger`
The sample demonstrates commands with validation, queries, gRPC reflection, MinimalApi endpoints, and dynamic queries.
+335
View File
@@ -0,0 +1,335 @@
# Package Index
> Complete reference for all 18 NuGet packages in the Svrnty.CQRS framework.
## Overview
| # | Package | Path | NuGet Package |
|---|---------|------|:---:|
| 1 | [Svrnty.CQRS.Abstractions](#1-svrntycqrsabstractions) | `Svrnty.CQRS.Abstractions/` | Yes |
| 2 | [Svrnty.CQRS](#2-svrntycqrs) | `Svrnty.CQRS/` | Yes |
| 3 | [Svrnty.CQRS.MinimalApi](#3-svrntycqrsminimalapi) | `Svrnty.CQRS.MinimalApi/` | Yes |
| 4 | [Svrnty.CQRS.Grpc](#4-svrntycqrsgrpc) | `Svrnty.CQRS.Grpc/` | Yes |
| 5 | [Svrnty.CQRS.Grpc.Abstractions](#5-svrntycqrsgrpcabstractions) | `Svrnty.CQRS.Grpc.Abstractions/` | Yes |
| 6 | [Svrnty.CQRS.Grpc.Generators](#6-svrntycqrsgrpcgenerators) | `Svrnty.CQRS.Grpc.Generators/` | Yes |
| 7 | [Svrnty.CQRS.FluentValidation](#7-svrntycqrsfluentvalidation) | `Svrnty.CQRS.FluentValidation/` | Yes |
| 8 | [Svrnty.CQRS.DynamicQuery.Abstractions](#8-svrntycqrsdynamicqueryabstractions) | `Svrnty.CQRS.DynamicQuery.Abstractions/` | Yes |
| 9 | [Svrnty.CQRS.DynamicQuery](#9-svrntycqrsdynamicquery) | `Svrnty.CQRS.DynamicQuery/` | Yes |
| 10 | [Svrnty.CQRS.DynamicQuery.MinimalApi](#10-svrntycqrsdynamicqueryminimalapi) | `Svrnty.CQRS.DynamicQuery.MinimalApi/` | Yes |
| 11 | [Svrnty.CQRS.DynamicQuery.EntityFramework](#11-svrntycqrsdynamicqueryentityframework) | `Svrnty.CQRS.DynamicQuery.EntityFramework/` | Yes |
| 12 | [Svrnty.CQRS.Events.Abstractions](#12-svrntycqrseventsabstractions) | `Svrnty.CQRS.Events.Abstractions/` | Yes |
| 13 | [Svrnty.CQRS.Events.RabbitMQ](#13-svrntycqrseventsrabbitmq) | `Svrnty.CQRS.Events.RabbitMQ/` | Yes |
| 14 | [Svrnty.CQRS.Sagas.Abstractions](#14-svrntycqrssagasabstractions) | `Svrnty.CQRS.Sagas.Abstractions/` | Yes |
| 15 | [Svrnty.CQRS.Sagas](#15-svrntycqrssagas) | `Svrnty.CQRS.Sagas/` | Yes |
| 16 | [Svrnty.CQRS.Sagas.RabbitMQ](#16-svrntycqrssagasrabbitmq) | `Svrnty.CQRS.Sagas.RabbitMQ/` | Yes |
| 17 | [Svrnty.CQRS.Notifications.Abstractions](#17-svrntycqrsnotificationsabstractions) | `Svrnty.CQRS.Notifications.Abstractions/` | Yes |
| 18 | [Svrnty.CQRS.Notifications.Grpc](#18-svrntycqrsnotificationsgrpc) | `Svrnty.CQRS.Notifications.Grpc/` | Yes |
---
## Package Details
### 1. Svrnty.CQRS.Abstractions
**Purpose**: Core interfaces that define the CQRS contract. This is the only package your domain/application layer needs to reference.
**Target**: `net10.0` | **AOT**: Yes
**Key Types**:
- `ICommandHandler<TCommand>` -- Handler for commands with no return value
- `ICommandHandler<TCommand, TResult>` -- Handler for commands returning a result
- `IQueryHandler<TQuery, TResult>` -- Handler for queries
- `ICommandMeta` / `IQueryMeta` -- Discovery metadata
- `ICommandDiscovery` / `IQueryDiscovery` -- Service discovery interfaces
- `ICommandAuthorizationService<TCommand>` -- Per-command authorization
- `IQueryAuthorizationService<TQuery>` -- Per-query authorization
- `CommandNameAttribute` / `QueryNameAttribute` -- Custom naming
- `IgnoreCommandAttribute` / `IgnoreQueryAttribute` -- Exclude from auto-discovery
**Internal Dependencies**: None
---
### 2. Svrnty.CQRS
**Purpose**: Core registration and discovery engine. Provides the `AddSvrntyCqrs()` fluent API and auto-discovers registered handlers.
**Target**: `net10.0` | **AOT**: Yes
**Key Types**:
- `CqrsBuilder` -- Fluent builder for configuring transports and features
- `CqrsConfiguration` -- Configuration state
- `ServiceCollectionExtensions.AddSvrntyCqrs()` -- Entry point
- `ServiceCollectionExtensions.AddCommand<T, TResult, THandler>()` -- Register a command handler
- `ServiceCollectionExtensions.AddQuery<T, TResult, THandler>()` -- Register a query handler
- `CommandDiscovery` / `QueryDiscovery` -- Default discovery implementations
**Internal Dependencies**: `Svrnty.CQRS.Abstractions`
---
### 3. Svrnty.CQRS.MinimalApi
**Purpose**: Maps registered commands and queries to ASP.NET Core Minimal API HTTP endpoints. Includes RFC 7807 Problem Details for validation errors.
**Target**: `net10.0` | **AOT**: No
**Key Types**:
- `CqrsBuilderExtensions.AddMinimalApi()` -- Enable HTTP endpoints
- `MinimalApiCqrsOptions` -- Configuration (route prefixes, etc.)
- `ValidationFilter` -- Endpoint filter for FluentValidation
- `WebApplicationExtensions.UseSvrntyCqrs()` -- Map endpoints at startup
- `EndpointRouteBuilderExtensions` -- Route mapping helpers
**Internal Dependencies**: `Svrnty.CQRS.Abstractions`, `Svrnty.CQRS`
**External Dependencies**: `FluentValidation 11.x`, `Microsoft.AspNetCore.App`
---
### 4. Svrnty.CQRS.Grpc
**Purpose**: Maps registered commands and queries to gRPC services. Uses Google Rich Error Model for structured validation errors.
**Target**: `net10.0` | **AOT**: No
**Key Types**:
- `CqrsBuilderExtensions.AddGrpc()` -- Enable gRPC endpoints
- `GrpcCqrsOptions` -- Configuration (reflection, etc.)
**Internal Dependencies**: `Svrnty.CQRS`
**External Dependencies**: `Grpc.AspNetCore 2.71.0`
---
### 5. Svrnty.CQRS.Grpc.Abstractions
**Purpose**: Attributes for controlling gRPC code generation behavior.
**Target**: `net10.0` | **AOT**: Yes
**Key Types**:
- `GrpcIgnoreAttribute` -- Marks a command/query to be excluded from gRPC service generation
**Internal Dependencies**: None
---
### 6. Svrnty.CQRS.Grpc.Generators
**Purpose**: Roslyn source generator that auto-generates `.proto` files and gRPC service implementations from registered command/query types.
**Target**: `netstandard2.0` (Roslyn component) | **AOT**: N/A
**Key Types**:
- Source generator (analyzer DLL)
- MSBuild `WriteProtoFileTask` -- Writes generated `.proto` files to disk
- Build targets and props for NuGet consumers
**Internal Dependencies**: None (ships as analyzer)
**External Dependencies**: `Microsoft.CodeAnalysis.CSharp 5.0.0`, `Microsoft.Build.Utilities.Core 17.0.0`
---
### 7. Svrnty.CQRS.FluentValidation
**Purpose**: Integrates FluentValidation with command/query registration. Validators are automatically invoked before handler execution.
**Target**: `net10.0` | **AOT**: Yes
**Key Types**:
- `ServiceCollectionExtensions.AddCommand<TCmd, TResult, THandler, TValidator>()` -- Register command with validator
- Automatic `AbstractValidator<T>` binding
**Internal Dependencies**: `Svrnty.CQRS`, `Svrnty.CQRS.Abstractions`
**External Dependencies**: `FluentValidation 11.11.0`
---
### 8. Svrnty.CQRS.DynamicQuery.Abstractions
**Purpose**: Interfaces for the dynamic query subsystem. Defines how data sources are provided and queries are intercepted.
**Target**: `netstandard2.1`, `net10.0` (multi-target) | **AOT**: Conditional
**Key Types**:
- `IQueryableProvider<TSource>` -- Provides an `IQueryable<T>` data source
- `IQueryableProviderOverride<TSource>` -- Override default provider
- `IAlterQueryableService<TSource>` -- Intercept/modify queryables
- `IDynamicQuery` / `IDynamicQueryParams` -- Query parameter contracts
- `IDynamicQueryInterceptorProvider` -- Interceptor registration
**Internal Dependencies**: None
**External Dependencies**: `PoweredSoft.DynamicQuery.Core 3.0.1`
---
### 9. Svrnty.CQRS.DynamicQuery
**Purpose**: Implementation of dynamic query execution with filtering, sorting, grouping, pagination, and aggregation.
**Target**: `net10.0` | **AOT**: Yes
**Key Types**:
- `ServiceCollectionExtensions.AddDynamicQueryWithProvider<TSource, TProvider>()` -- Register a queryable provider
- Dynamic query handler pipeline
**Internal Dependencies**: `Svrnty.CQRS.DynamicQuery.Abstractions`, `Svrnty.CQRS`
**External Dependencies**: `PoweredSoft.DynamicQuery 3.0.1`, `Pluralize.NET 1.0.2`
---
### 10. Svrnty.CQRS.DynamicQuery.MinimalApi
**Purpose**: HTTP Minimal API endpoints for dynamic queries. Exposes each registered entity as a POST endpoint with filter/sort/page parameters.
**Target**: `net10.0` | **AOT**: No
**Key Types**:
- Endpoint mapping for dynamic query routes (`/api/dynamic-query/{entity}`)
**Internal Dependencies**: `Svrnty.CQRS.Abstractions`, `Svrnty.CQRS.DynamicQuery.Abstractions`, `Svrnty.CQRS.DynamicQuery`
**External Dependencies**: `Microsoft.AspNetCore.App`
---
### 11. Svrnty.CQRS.DynamicQuery.EntityFramework
**Purpose**: Entity Framework Core integration for dynamic queries. Provides an EF-backed `IAsyncQueryableService`.
**Target**: `net10.0` | **AOT**: No
**Key Types**:
- EF Core queryable service adapter
**Internal Dependencies**: `Svrnty.CQRS.DynamicQuery`
**External Dependencies**: `PoweredSoft.Data.EntityFrameworkCore 3.0.0`
---
### 12. Svrnty.CQRS.Events.Abstractions
**Purpose**: Interfaces for domain event publishing.
**Target**: `net10.0` | **AOT**: Yes
**Key Types**:
- `IDomainEvent` -- Marker interface (EventId, OccurredAt)
- `IDomainEventPublisher` -- Publish events to external systems
**Internal Dependencies**: None
---
### 13. Svrnty.CQRS.Events.RabbitMQ
**Purpose**: RabbitMQ-backed implementation of domain event publishing.
**Target**: `net10.0` | **AOT**: No
**Key Types**:
- RabbitMQ event publisher implementation
**Internal Dependencies**: `Svrnty.CQRS.Events.Abstractions`
**External Dependencies**: `RabbitMQ.Client 7.0.0`, `Microsoft.Extensions.DependencyInjection.Abstractions`, `Microsoft.Extensions.Logging.Abstractions`, `Microsoft.Extensions.Options`
---
### 14. Svrnty.CQRS.Sagas.Abstractions
**Purpose**: Interfaces and types for the saga orchestration pattern with compensation (rollback) support.
**Target**: `net10.0` | **AOT**: Yes
**Key Types**:
- `ISaga<TData>` -- Define a saga with steps
- `ISagaBuilder<TData>` -- Fluent builder for local and remote steps
- `ISagaStepBuilder<TData>` -- Configure Execute/Compensate actions
- `ISagaRemoteStepBuilder<TData, TCommand>` -- Remote command steps with timeout/retry
- `ISagaOrchestrator` -- Start sagas, query state
- `ISagaData` -- Marker interface (CorrelationId)
- `SagaState` -- Persistent saga state (status, completed steps, errors)
- `SagaStatus` -- Enum: NotStarted, InProgress, Completed, Failed, Compensating, Compensated
- `ISagaStateStore` -- Persistence abstraction
- `ISagaMessageBus` -- Messaging abstraction
- `SagaMessage` / `SagaStepResponse` -- Message types
- `ISagaContext` -- Step execution context
**Internal Dependencies**: None
---
### 15. Svrnty.CQRS.Sagas
**Purpose**: Default saga orchestrator implementation with step execution, compensation, and state management.
**Target**: `net10.0` | **AOT**: Yes
**Key Types**:
- Saga orchestrator engine
- In-memory state store (default)
**Internal Dependencies**: `Svrnty.CQRS`, `Svrnty.CQRS.Sagas.Abstractions`
**External Dependencies**: `Microsoft.Extensions.Logging.Abstractions`, `Microsoft.Extensions.Options`
---
### 16. Svrnty.CQRS.Sagas.RabbitMQ
**Purpose**: RabbitMQ-backed message bus for distributed saga step execution across microservices.
**Target**: `net10.0` | **AOT**: No
**Key Types**:
- RabbitMQ saga message bus implementation
**Internal Dependencies**: `Svrnty.CQRS.Sagas`
**External Dependencies**: `RabbitMQ.Client 7.0.0`, `Microsoft.Extensions.Hosting.Abstractions`, `Microsoft.Extensions.Options`
---
### 17. Svrnty.CQRS.Notifications.Abstractions
**Purpose**: Interfaces for real-time notification streaming to clients.
**Target**: `net10.0` | **AOT**: Yes
**Key Types**:
- `INotificationPublisher` -- Publish notifications to subscribed clients
- `StreamingNotificationAttribute` -- Marks a type as a streamable notification with a subscription key
**Internal Dependencies**: None
---
### 18. Svrnty.CQRS.Notifications.Grpc
**Purpose**: gRPC server-streaming implementation for real-time notifications.
**Target**: `net10.0` | **AOT**: No
**Key Types**:
- gRPC notification streaming service
**Internal Dependencies**: `Svrnty.CQRS.Notifications.Abstractions`
**External Dependencies**: `Grpc.AspNetCore 2.71.0`, `Microsoft.Extensions.DependencyInjection.Abstractions`, `Microsoft.Extensions.Logging.Abstractions`
---
## Additional Projects (not NuGet packages)
| Project | Path | Purpose |
|---------|------|---------|
| `Svrnty.Sample` | `Svrnty.Sample/` | Sample web application demonstrating commands, queries, gRPC, MinimalApi, DynamicQuery, and validation |
| `Svrnty.CQRS.Tests` | `tests/Svrnty.CQRS.Tests/` | Unit and integration test suite |
+129
View File
@@ -0,0 +1,129 @@
pre-commit:
parallel: true
commands:
check-author:
run: |
EMAIL=$(git config user.email)
ALLOWED="jp@svrnty.io mathias@svrnty.io"
for a in $ALLOWED; do
[ "$EMAIL" = "$a" ] && exit 0
done
echo "BLOCKED: author email '$EMAIL' not in allowed list: $ALLOWED"
exit 1
no-secrets:
run: |
BLOCKED=$(git diff --cached --name-only | grep -E '\.(env|pem|key)$|credentials\.json|id_rsa|id_ed25519' || true)
if [ -n "$BLOCKED" ]; then
echo "BLOCKED: refusing to commit sensitive files:"
echo "$BLOCKED"
exit 1
fi
no-large-files:
run: |
LARGE=$(git diff --cached --name-only -z | xargs -0 -I{} sh -c 'if [ -f "{}" ]; then size=$(wc -c < "{}"); if [ "$size" -gt 5242880 ]; then echo "{} ($(( size / 1048576 ))MB)"; fi; fi' || true)
if [ -n "$LARGE" ]; then
echo "WARNING: large files staged (>5MB):"
echo "$LARGE"
fi
doc-hygiene:
run: |
STAGED=$(git diff --cached --name-only)
# Check if code files are staged (not just docs)
CODE_CHANGED=$(echo "$STAGED" | grep -vE '\.(md|txt|yml|yaml|json|toml|lock)$|^LICENSE$|^\.gitignore$' || true)
if [ -z "$CODE_CHANGED" ]; then
exit 0
fi
# Warn if CHANGELOG.md is not being updated with code changes
if ! echo "$STAGED" | grep -q '^CHANGELOG.md$'; then
echo "WARNING: code changes staged without CHANGELOG.md update"
echo " → Update CHANGELOG.md under [Unreleased] before committing"
echo " → See root CLAUDE.md § Documentation Standards for format"
fi
# Warn if README.md is missing
if [ ! -f "README.md" ]; then
echo "WARNING: README.md is missing — every repo must have one"
echo " → See root CLAUDE.md § README Requirements for structure"
fi
commit-msg:
commands:
validate-message:
run: |
MSG=$(cat "{1}")
if echo "$MSG" | head -1 | grep -qE '^Merge '; then
exit 0
fi
if ! echo "$MSG" | head -1 | grep -qE '^[a-z]+(\([a-zA-Z0-9_-]+\))?: .+'; then
echo "WARNING: commit message does not follow conventional format: type(scope): message"
echo " → Types: feat, fix, refactor, docs, test, chore, ci, perf"
fi
append-coauthor:
run: |
MSG=$(cat "{1}")
if ! echo "$MSG" | grep -qF 'Co-Authored-By: Svrnty Inc. <jp@svrnty.io>'; then
printf '\n\nCo-Authored-By: Svrnty Inc. <jp@svrnty.io>\n' >> "{1}"
fi
post-commit:
commands:
register-repo:
run: |
REPO_NAME=$(basename "$(git rev-parse --show-toplevel)")
ROOT_CLAUDE="$(git rev-parse --show-toplevel)/../CLAUDE.md"
[ -f "$ROOT_CLAUDE" ] || exit 0
if grep -qF "| \`$REPO_NAME\`" "$ROOT_CLAUDE"; then
exit 0
fi
COMMIT=$(git rev-parse --short HEAD)
DATE=$(date +%Y-%m-%d)
TOTAL_LINE=$(grep -n '^\*\*Total:' "$ROOT_CLAUDE" | head -1 | cut -d: -f1)
if [ -z "$TOTAL_LINE" ]; then
exit 0
fi
OLD_COUNT=$(sed -n "${TOTAL_LINE}p" "$ROOT_CLAUDE" | grep -oP '\d+')
NEW_COUNT=$((OLD_COUNT + 1))
sed -i "${TOTAL_LINE}i| \`${REPO_NAME}\` | — | NEW REPO — registered ${DATE} (${COMMIT}). Update stack and purpose. |" "$ROOT_CLAUDE"
NEW_TOTAL_LINE=$((TOTAL_LINE + 1))
sed -i "${NEW_TOTAL_LINE}s/Total: ${OLD_COUNT}/Total: ${NEW_COUNT}/" "$ROOT_CLAUDE"
echo "REGISTRY: added '$REPO_NAME' to root CLAUDE.md (${DATE}, ${COMMIT})"
bootstrap-siblings:
run: |
REPO_ROOT=$(git rev-parse --show-toplevel)
HOOKS_DIR="$REPO_ROOT/../.svrnty-hooks"
[ -d "$HOOKS_DIR" ] || exit 0
[ -f "$HOOKS_DIR/lefthook.yml" ] || exit 0
for sibling in "$REPO_ROOT"/../*/; do
[ -d "$sibling/.git" ] || continue
[ -f "$sibling/lefthook.yml" ] && continue
SNAME=$(basename "$sibling")
# Deploy lefthook
cp "$HOOKS_DIR/lefthook.yml" "$sibling/lefthook.yml"
# Deploy CLAUDE.md
[ -f "$HOOKS_DIR/CLAUDE.md.template" ] && cp "$HOOKS_DIR/CLAUDE.md.template" "$sibling/CLAUDE.md"
# Deploy governance docs
[ -f "$HOOKS_DIR/LICENSE" ] && [ ! -f "$sibling/LICENSE" ] && cp "$HOOKS_DIR/LICENSE" "$sibling/LICENSE"
[ -f "$HOOKS_DIR/CONTRIBUTING.md" ] && [ ! -f "$sibling/CONTRIBUTING.md" ] && cp "$HOOKS_DIR/CONTRIBUTING.md" "$sibling/CONTRIBUTING.md"
[ -f "$HOOKS_DIR/SECURITY.md" ] && [ ! -f "$sibling/SECURITY.md" ] && cp "$HOOKS_DIR/SECURITY.md" "$sibling/SECURITY.md"
[ -f "$HOOKS_DIR/CHANGELOG.md.template" ] && [ ! -f "$sibling/CHANGELOG.md" ] && cp "$HOOKS_DIR/CHANGELOG.md.template" "$sibling/CHANGELOG.md"
# Install lefthook
(cd "$sibling" && lefthook install 2>/dev/null)
echo "BOOTSTRAP: installed lefthook + governance docs in '$SNAME'"
done
pre-push:
commands:
protect-main:
run: |
BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
echo "BLOCKED: direct push to $BRANCH is not allowed"
exit 1
fi
check-behind-remote:
run: |
git fetch origin 2>/dev/null || true
BRANCH=$(git rev-parse --abbrev-ref HEAD)
BEHIND=$(git rev-list --count HEAD..origin/"$BRANCH" 2>/dev/null || echo 0)
if [ "$BEHIND" -gt 0 ]; then
echo "WARNING: local branch is $BEHIND commit(s) behind origin/$BRANCH"
fi
@@ -0,0 +1,124 @@
using Svrnty.CQRS.Abstractions.Discovery;
using Svrnty.CQRS.Discovery;
namespace Svrnty.CQRS.Tests;
public class CommandDiscoveryTests
{
private static CommandDiscovery CreateDiscovery(params ICommandMeta[] metas)
{
return new CommandDiscovery(metas);
}
[Fact]
public void GetCommands_ReturnsAllRegistered()
{
var meta1 = new CommandMeta(typeof(CreatePersonCommand), typeof(object), typeof(CreatePersonResult));
var meta2 = new CommandMeta(typeof(DeletePersonCommand), typeof(object));
var discovery = CreateDiscovery(meta1, meta2);
var commands = discovery.GetCommands().ToList();
Assert.Equal(2, commands.Count);
}
[Fact]
public void GetCommands_ReturnsEmpty_WhenNoneRegistered()
{
var discovery = CreateDiscovery();
var commands = discovery.GetCommands().ToList();
Assert.Empty(commands);
}
[Fact]
public void FindCommand_ByName_ReturnsCorrectMeta()
{
var meta = new CommandMeta(typeof(CreatePersonCommand), typeof(object), typeof(CreatePersonResult));
var discovery = CreateDiscovery(meta);
var found = discovery.FindCommand("CreatePerson");
Assert.NotNull(found);
Assert.Equal(typeof(CreatePersonCommand), found.CommandType);
}
[Fact]
public void FindCommand_ByName_ReturnsNull_WhenNotFound()
{
var discovery = CreateDiscovery();
var found = discovery.FindCommand("NonExistent");
Assert.Null(found);
}
[Fact]
public void FindCommand_ByType_ReturnsCorrectMeta()
{
var meta = new CommandMeta(typeof(CreatePersonCommand), typeof(object), typeof(CreatePersonResult));
var discovery = CreateDiscovery(meta);
var found = discovery.FindCommand(typeof(CreatePersonCommand));
Assert.NotNull(found);
Assert.Equal("CreatePerson", found.Name);
}
[Fact]
public void FindCommand_ByType_ReturnsNull_WhenNotFound()
{
var discovery = CreateDiscovery();
var found = discovery.FindCommand(typeof(CreatePersonCommand));
Assert.Null(found);
}
[Fact]
public void CommandExists_ByName_ReturnsTrue_WhenFound()
{
var meta = new CommandMeta(typeof(CreatePersonCommand), typeof(object));
var discovery = CreateDiscovery(meta);
Assert.True(discovery.CommandExists("CreatePerson"));
}
[Fact]
public void CommandExists_ByName_ReturnsFalse_WhenNotFound()
{
var discovery = CreateDiscovery();
Assert.False(discovery.CommandExists("CreatePerson"));
}
[Fact]
public void CommandExists_ByType_ReturnsTrue_WhenFound()
{
var meta = new CommandMeta(typeof(CreatePersonCommand), typeof(object));
var discovery = CreateDiscovery(meta);
Assert.True(discovery.CommandExists(typeof(CreatePersonCommand)));
}
[Fact]
public void CommandExists_ByType_ReturnsFalse_WhenNotFound()
{
var discovery = CreateDiscovery();
Assert.False(discovery.CommandExists(typeof(CreatePersonCommand)));
}
[Fact]
public void FindCommand_WithCustomName_FindsByAttributeName()
{
var meta = new CommandMeta(typeof(CreateWidgetCommand), typeof(object));
var discovery = CreateDiscovery(meta);
var found = discovery.FindCommand("customCreate");
Assert.NotNull(found);
Assert.Equal(typeof(CreateWidgetCommand), found.CommandType);
}
}
@@ -0,0 +1,64 @@
using Svrnty.CQRS.Abstractions.Discovery;
namespace Svrnty.CQRS.Tests;
public class CommandMetaTests
{
[Fact]
public void Name_StripsCommandSuffix()
{
var meta = new CommandMeta(typeof(CreatePersonCommand), typeof(object));
Assert.Equal("CreatePerson", meta.Name);
}
[Fact]
public void Name_UsesCommandNameAttribute_WhenPresent()
{
var meta = new CommandMeta(typeof(CreateWidgetCommand), typeof(object));
Assert.Equal("customCreate", meta.Name);
}
[Fact]
public void LowerCamelCaseName_ConvertsFirstCharToLower()
{
var meta = new CommandMeta(typeof(CreatePersonCommand), typeof(object));
Assert.Equal("createPerson", meta.LowerCamelCaseName);
}
[Fact]
public void LowerCamelCaseName_PreservesAlreadyLowerCase()
{
var meta = new CommandMeta(typeof(CreateWidgetCommand), typeof(object));
// customCreate -> already lower first char
Assert.Equal("customCreate", meta.LowerCamelCaseName);
}
[Fact]
public void CommandType_IsSetCorrectly()
{
var meta = new CommandMeta(typeof(CreatePersonCommand), typeof(object));
Assert.Equal(typeof(CreatePersonCommand), meta.CommandType);
}
[Fact]
public void ServiceType_IsSetCorrectly()
{
var serviceType = typeof(object);
var meta = new CommandMeta(typeof(CreatePersonCommand), serviceType);
Assert.Equal(serviceType, meta.ServiceType);
}
[Fact]
public void CommandResultType_IsSetCorrectly_WithThreeArgConstructor()
{
var meta = new CommandMeta(typeof(CreatePersonCommand), typeof(object), typeof(CreatePersonResult));
Assert.Equal(typeof(CreatePersonResult), meta.CommandResultType);
}
[Fact]
public void CommandResultType_IsNull_WithTwoArgConstructor()
{
var meta = new CommandMeta(typeof(DeletePersonCommand), typeof(object));
Assert.Null(meta.CommandResultType);
}
}
@@ -0,0 +1,106 @@
using Svrnty.CQRS.Configuration;
namespace Svrnty.CQRS.Tests;
public class CqrsConfigurationTests
{
private class TestConfig
{
public string Value { get; set; } = string.Empty;
}
private class OtherConfig
{
public int Number { get; set; }
}
[Fact]
public void SetConfiguration_CanBeRetrieved()
{
var config = new CqrsConfiguration();
var testConfig = new TestConfig { Value = "hello" };
config.SetConfiguration(testConfig);
var retrieved = config.GetConfiguration<TestConfig>();
Assert.NotNull(retrieved);
Assert.Equal("hello", retrieved.Value);
}
[Fact]
public void GetConfiguration_ReturnsNull_WhenNotSet()
{
var config = new CqrsConfiguration();
var retrieved = config.GetConfiguration<TestConfig>();
Assert.Null(retrieved);
}
[Fact]
public void HasConfiguration_ReturnsTrue_WhenSet()
{
var config = new CqrsConfiguration();
config.SetConfiguration(new TestConfig());
Assert.True(config.HasConfiguration<TestConfig>());
}
[Fact]
public void HasConfiguration_ReturnsFalse_WhenNotSet()
{
var config = new CqrsConfiguration();
Assert.False(config.HasConfiguration<TestConfig>());
}
[Fact]
public void SetConfiguration_OverwritesPrevious()
{
var config = new CqrsConfiguration();
config.SetConfiguration(new TestConfig { Value = "first" });
config.SetConfiguration(new TestConfig { Value = "second" });
var retrieved = config.GetConfiguration<TestConfig>();
Assert.Equal("second", retrieved!.Value);
}
[Fact]
public void MultipleConfigTypes_AreIndependent()
{
var config = new CqrsConfiguration();
config.SetConfiguration(new TestConfig { Value = "test" });
config.SetConfiguration(new OtherConfig { Number = 42 });
Assert.Equal("test", config.GetConfiguration<TestConfig>()!.Value);
Assert.Equal(42, config.GetConfiguration<OtherConfig>()!.Number);
}
[Fact]
public void ExecuteMappingCallbacks_InvokesAllCallbacks()
{
var config = new CqrsConfiguration();
var callCount = 0;
config.AddMappingCallback(_ => callCount++);
config.AddMappingCallback(_ => callCount++);
config.ExecuteMappingCallbacks(new object());
Assert.Equal(2, callCount);
}
[Fact]
public void ExecuteMappingCallbacks_PassesAppObject()
{
var config = new CqrsConfiguration();
object? receivedApp = null;
config.AddMappingCallback(app => receivedApp = app);
var expected = new object();
config.ExecuteMappingCallbacks(expected);
Assert.Same(expected, receivedApp);
}
}
+81
View File
@@ -0,0 +1,81 @@
using Svrnty.CQRS.Abstractions;
using Svrnty.CQRS.Abstractions.Attributes;
namespace Svrnty.CQRS.Tests;
// Commands
public class CreatePersonCommand
{
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
}
public class DeletePersonCommand
{
public int Id { get; set; }
}
[CommandName("customCreate")]
public class CreateWidgetCommand
{
public string Name { get; set; } = string.Empty;
}
// Command results
public class CreatePersonResult
{
public int Id { get; set; }
}
// Queries
public class PersonQuery
{
public string? NameFilter { get; set; }
}
[QueryName("customPersonLookup")]
public class PersonLookupQuery
{
public int Id { get; set; }
}
// Handlers
public class CreatePersonCommandHandler : ICommandHandler<CreatePersonCommand, CreatePersonResult>
{
public Task<CreatePersonResult> HandleAsync(CreatePersonCommand command, CancellationToken cancellationToken = default)
{
return Task.FromResult(new CreatePersonResult { Id = 1 });
}
}
public class DeletePersonCommandHandler : ICommandHandler<DeletePersonCommand>
{
public Task HandleAsync(DeletePersonCommand command, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
}
public class CreateWidgetCommandHandler : ICommandHandler<CreateWidgetCommand>
{
public Task HandleAsync(CreateWidgetCommand command, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
}
public class PersonQueryHandler : IQueryHandler<PersonQuery, IEnumerable<string>>
{
public Task<IEnumerable<string>> HandleAsync(PersonQuery query, CancellationToken cancellationToken = default)
{
return Task.FromResult<IEnumerable<string>>(["Alice", "Bob"]);
}
}
public class PersonLookupQueryHandler : IQueryHandler<PersonLookupQuery, string>
{
public Task<string> HandleAsync(PersonLookupQuery query, CancellationToken cancellationToken = default)
{
return Task.FromResult("Alice");
}
}
@@ -0,0 +1,184 @@
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Abstractions;
using Svrnty.CQRS.Abstractions.Discovery;
namespace Svrnty.CQRS.Tests;
// Validator for CreatePersonCommand
public class CreatePersonCommandValidator : AbstractValidator<CreatePersonCommand>
{
public CreatePersonCommandValidator()
{
RuleFor(x => x.FirstName).NotEmpty().WithMessage("FirstName is required");
RuleFor(x => x.LastName).NotEmpty().WithMessage("LastName is required");
}
}
// Validator for PersonQuery
public class PersonQueryValidator : AbstractValidator<PersonQuery>
{
public PersonQueryValidator()
{
RuleFor(x => x.NameFilter).MaximumLength(100).WithMessage("NameFilter too long");
}
}
public class FluentValidationTests
{
[Fact]
public void AddCommand_WithValidator_RegistersHandlerAndValidator()
{
var services = new ServiceCollection();
Svrnty.CQRS.FluentValidation.ServiceCollectionExtensions
.AddCommand<CreatePersonCommand, CreatePersonCommandHandler, CreatePersonCommandValidator>(services);
var provider = services.BuildServiceProvider();
var handler = provider.GetService<ICommandHandler<CreatePersonCommand>>();
var validator = provider.GetService<IValidator<CreatePersonCommand>>();
Assert.NotNull(handler);
Assert.NotNull(validator);
Assert.IsType<CreatePersonCommandHandler>(handler);
Assert.IsType<CreatePersonCommandValidator>(validator);
}
[Fact]
public void AddCommand_WithResultAndValidator_RegistersHandlerAndValidator()
{
var services = new ServiceCollection();
Svrnty.CQRS.FluentValidation.ServiceCollectionExtensions
.AddCommand<CreatePersonCommand, CreatePersonResult, CreatePersonCommandHandler, CreatePersonCommandValidator>(services);
var provider = services.BuildServiceProvider();
var handler = provider.GetService<ICommandHandler<CreatePersonCommand, CreatePersonResult>>();
var validator = provider.GetService<IValidator<CreatePersonCommand>>();
Assert.NotNull(handler);
Assert.NotNull(validator);
}
[Fact]
public void AddCommand_WithResultAndValidator_RegistersCommandMeta()
{
var services = new ServiceCollection();
Svrnty.CQRS.FluentValidation.ServiceCollectionExtensions
.AddCommand<CreatePersonCommand, CreatePersonResult, CreatePersonCommandHandler, CreatePersonCommandValidator>(services);
var provider = services.BuildServiceProvider();
var metas = provider.GetServices<ICommandMeta>().ToList();
Assert.Single(metas);
Assert.Equal(typeof(CreatePersonCommand), metas[0].CommandType);
Assert.Equal(typeof(CreatePersonResult), metas[0].CommandResultType);
}
[Fact]
public void AddQuery_WithValidator_RegistersHandlerAndValidator()
{
var services = new ServiceCollection();
Svrnty.CQRS.FluentValidation.ServiceCollectionExtensions
.AddQuery<PersonQuery, IEnumerable<string>, PersonQueryHandler, PersonQueryValidator>(services);
var provider = services.BuildServiceProvider();
var handler = provider.GetService<IQueryHandler<PersonQuery, IEnumerable<string>>>();
var validator = provider.GetService<IValidator<PersonQuery>>();
Assert.NotNull(handler);
Assert.NotNull(validator);
Assert.IsType<PersonQueryHandler>(handler);
Assert.IsType<PersonQueryValidator>(validator);
}
[Fact]
public async Task Validator_RejectsInvalidCommand()
{
var validator = new CreatePersonCommandValidator();
var command = new CreatePersonCommand { FirstName = "", LastName = "" };
var result = await validator.ValidateAsync(command);
Assert.False(result.IsValid);
Assert.Equal(2, result.Errors.Count);
}
[Fact]
public async Task Validator_AcceptsValidCommand()
{
var validator = new CreatePersonCommandValidator();
var command = new CreatePersonCommand { FirstName = "John", LastName = "Doe" };
var result = await validator.ValidateAsync(command);
Assert.True(result.IsValid);
Assert.Empty(result.Errors);
}
[Fact]
public async Task Validator_ResolvedFromDI_WorksCorrectly()
{
var services = new ServiceCollection();
Svrnty.CQRS.FluentValidation.ServiceCollectionExtensions
.AddCommand<CreatePersonCommand, CreatePersonCommandHandler, CreatePersonCommandValidator>(services);
var provider = services.BuildServiceProvider();
var validator = provider.GetRequiredService<IValidator<CreatePersonCommand>>();
var invalidResult = await validator.ValidateAsync(new CreatePersonCommand { FirstName = "", LastName = "" });
Assert.False(invalidResult.IsValid);
var validResult = await validator.ValidateAsync(new CreatePersonCommand { FirstName = "Jane", LastName = "Doe" });
Assert.True(validResult.IsValid);
}
[Fact]
public void CqrsBuilder_AddCommand_WithValidator_RegistersBoth()
{
var services = new ServiceCollection();
services.AddSvrntyCqrs(builder =>
{
Svrnty.CQRS.FluentValidation.CqrsBuilderExtensions
.AddCommand<CreatePersonCommand, CreatePersonCommandHandler, CreatePersonCommandValidator>(builder);
});
var provider = services.BuildServiceProvider();
Assert.NotNull(provider.GetService<ICommandHandler<CreatePersonCommand>>());
Assert.NotNull(provider.GetService<IValidator<CreatePersonCommand>>());
}
[Fact]
public void CqrsBuilder_AddCommand_WithResultAndValidator_RegistersBoth()
{
var services = new ServiceCollection();
services.AddSvrntyCqrs(builder =>
{
Svrnty.CQRS.FluentValidation.CqrsBuilderExtensions
.AddCommand<CreatePersonCommand, CreatePersonResult, CreatePersonCommandHandler, CreatePersonCommandValidator>(builder);
});
var provider = services.BuildServiceProvider();
Assert.NotNull(provider.GetService<ICommandHandler<CreatePersonCommand, CreatePersonResult>>());
Assert.NotNull(provider.GetService<IValidator<CreatePersonCommand>>());
}
[Fact]
public void CqrsBuilder_AddQuery_WithValidator_RegistersBoth()
{
var services = new ServiceCollection();
services.AddSvrntyCqrs(builder =>
{
Svrnty.CQRS.FluentValidation.CqrsBuilderExtensions
.AddQuery<PersonQuery, IEnumerable<string>, PersonQueryHandler, PersonQueryValidator>(builder);
});
var provider = services.BuildServiceProvider();
Assert.NotNull(provider.GetService<IQueryHandler<PersonQuery, IEnumerable<string>>>());
Assert.NotNull(provider.GetService<IValidator<PersonQuery>>());
}
}
+1
View File
@@ -0,0 +1 @@
global using Xunit;
+64
View File
@@ -0,0 +1,64 @@
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Abstractions;
namespace Svrnty.CQRS.Tests;
public class HandlerTests
{
[Fact]
public async Task CommandHandler_WithResult_ExecutesCorrectly()
{
var services = new ServiceCollection();
services.AddCommand<CreatePersonCommand, CreatePersonResult, CreatePersonCommandHandler>();
var provider = services.BuildServiceProvider();
var handler = provider.GetRequiredService<ICommandHandler<CreatePersonCommand, CreatePersonResult>>();
var result = await handler.HandleAsync(new CreatePersonCommand
{
FirstName = "John",
LastName = "Doe"
});
Assert.Equal(1, result.Id);
}
[Fact]
public async Task CommandHandler_WithoutResult_ExecutesWithoutException()
{
var services = new ServiceCollection();
services.AddCommand<DeletePersonCommand, DeletePersonCommandHandler>();
var provider = services.BuildServiceProvider();
var handler = provider.GetRequiredService<ICommandHandler<DeletePersonCommand>>();
await handler.HandleAsync(new DeletePersonCommand { Id = 1 });
}
[Fact]
public async Task QueryHandler_ExecutesCorrectly()
{
var services = new ServiceCollection();
services.AddQuery<PersonQuery, IEnumerable<string>, PersonQueryHandler>();
var provider = services.BuildServiceProvider();
var handler = provider.GetRequiredService<IQueryHandler<PersonQuery, IEnumerable<string>>>();
var result = await handler.HandleAsync(new PersonQuery());
Assert.Equal(["Alice", "Bob"], result);
}
[Fact]
public async Task CommandHandler_SupportsCancellationToken()
{
var services = new ServiceCollection();
services.AddCommand<DeletePersonCommand, DeletePersonCommandHandler>();
var provider = services.BuildServiceProvider();
var handler = provider.GetRequiredService<ICommandHandler<DeletePersonCommand>>();
using var cts = new CancellationTokenSource();
await handler.HandleAsync(new DeletePersonCommand { Id = 1 }, cts.Token);
}
}
@@ -0,0 +1,124 @@
using Svrnty.CQRS.Abstractions.Discovery;
using Svrnty.CQRS.Discovery;
namespace Svrnty.CQRS.Tests;
public class QueryDiscoveryTests
{
private static QueryDiscovery CreateDiscovery(params IQueryMeta[] metas)
{
return new QueryDiscovery(metas);
}
[Fact]
public void GetQueries_ReturnsAllRegistered()
{
var meta1 = new QueryMeta(typeof(PersonQuery), typeof(object), typeof(IEnumerable<string>));
var meta2 = new QueryMeta(typeof(PersonLookupQuery), typeof(object), typeof(string));
var discovery = CreateDiscovery(meta1, meta2);
var queries = discovery.GetQueries().ToList();
Assert.Equal(2, queries.Count);
}
[Fact]
public void GetQueries_ReturnsEmpty_WhenNoneRegistered()
{
var discovery = CreateDiscovery();
var queries = discovery.GetQueries().ToList();
Assert.Empty(queries);
}
[Fact]
public void FindQuery_ByName_ReturnsCorrectMeta()
{
var meta = new QueryMeta(typeof(PersonQuery), typeof(object), typeof(IEnumerable<string>));
var discovery = CreateDiscovery(meta);
var found = discovery.FindQuery("Person");
Assert.NotNull(found);
Assert.Equal(typeof(PersonQuery), found.QueryType);
}
[Fact]
public void FindQuery_ByName_ReturnsNull_WhenNotFound()
{
var discovery = CreateDiscovery();
var found = discovery.FindQuery("NonExistent");
Assert.Null(found);
}
[Fact]
public void FindQuery_ByType_ReturnsCorrectMeta()
{
var meta = new QueryMeta(typeof(PersonQuery), typeof(object), typeof(IEnumerable<string>));
var discovery = CreateDiscovery(meta);
var found = discovery.FindQuery(typeof(PersonQuery));
Assert.NotNull(found);
Assert.Equal("Person", found.Name);
}
[Fact]
public void FindQuery_ByType_ReturnsNull_WhenNotFound()
{
var discovery = CreateDiscovery();
var found = discovery.FindQuery(typeof(PersonQuery));
Assert.Null(found);
}
[Fact]
public void QueryExists_ByName_ReturnsTrue_WhenFound()
{
var meta = new QueryMeta(typeof(PersonQuery), typeof(object), typeof(IEnumerable<string>));
var discovery = CreateDiscovery(meta);
Assert.True(discovery.QueryExists("Person"));
}
[Fact]
public void QueryExists_ByName_ReturnsFalse_WhenNotFound()
{
var discovery = CreateDiscovery();
Assert.False(discovery.QueryExists("Person"));
}
[Fact]
public void QueryExists_ByType_ReturnsTrue_WhenFound()
{
var meta = new QueryMeta(typeof(PersonQuery), typeof(object), typeof(IEnumerable<string>));
var discovery = CreateDiscovery(meta);
Assert.True(discovery.QueryExists(typeof(PersonQuery)));
}
[Fact]
public void QueryExists_ByType_ReturnsFalse_WhenNotFound()
{
var discovery = CreateDiscovery();
Assert.False(discovery.QueryExists(typeof(PersonQuery)));
}
[Fact]
public void FindQuery_WithCustomName_FindsByAttributeName()
{
var meta = new QueryMeta(typeof(PersonLookupQuery), typeof(object), typeof(string));
var discovery = CreateDiscovery(meta);
var found = discovery.FindQuery("customPersonLookup");
Assert.NotNull(found);
Assert.Equal(typeof(PersonLookupQuery), found.QueryType);
}
}
+57
View File
@@ -0,0 +1,57 @@
using Svrnty.CQRS.Abstractions.Discovery;
namespace Svrnty.CQRS.Tests;
public class QueryMetaTests
{
[Fact]
public void Name_StripsQuerySuffix()
{
var meta = new QueryMeta(typeof(PersonQuery), typeof(object), typeof(IEnumerable<string>));
Assert.Equal("Person", meta.Name);
}
[Fact]
public void Name_UsesQueryNameAttribute_WhenPresent()
{
var meta = new QueryMeta(typeof(PersonLookupQuery), typeof(object), typeof(string));
Assert.Equal("customPersonLookup", meta.Name);
}
[Fact]
public void LowerCamelCaseName_ConvertsFirstCharToLower()
{
var meta = new QueryMeta(typeof(PersonQuery), typeof(object), typeof(IEnumerable<string>));
Assert.Equal("person", meta.LowerCamelCaseName);
}
[Fact]
public void Category_DefaultsToBasicQuery()
{
var meta = new QueryMeta(typeof(PersonQuery), typeof(object), typeof(IEnumerable<string>));
Assert.Equal("BasicQuery", meta.Category);
}
[Fact]
public void QueryType_IsSetCorrectly()
{
var meta = new QueryMeta(typeof(PersonQuery), typeof(object), typeof(IEnumerable<string>));
Assert.Equal(typeof(PersonQuery), meta.QueryType);
}
[Fact]
public void ServiceType_IsSetCorrectly()
{
var serviceType = typeof(object);
var meta = new QueryMeta(typeof(PersonQuery), serviceType, typeof(IEnumerable<string>));
Assert.Equal(serviceType, meta.ServiceType);
}
[Fact]
public void QueryResultType_IsSetCorrectly()
{
var resultType = typeof(IEnumerable<string>);
var meta = new QueryMeta(typeof(PersonQuery), typeof(object), resultType);
Assert.Equal(resultType, meta.QueryResultType);
}
}
@@ -0,0 +1,180 @@
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Abstractions;
using Svrnty.CQRS.Abstractions.Discovery;
using Svrnty.CQRS.Discovery;
namespace Svrnty.CQRS.Tests;
public class ServiceRegistrationTests
{
[Fact]
public void AddCommand_WithResult_RegistersHandlerInDI()
{
var services = new ServiceCollection();
services.AddCommand<CreatePersonCommand, CreatePersonResult, CreatePersonCommandHandler>();
var provider = services.BuildServiceProvider();
var handler = provider.GetService<ICommandHandler<CreatePersonCommand, CreatePersonResult>>();
Assert.NotNull(handler);
Assert.IsType<CreatePersonCommandHandler>(handler);
}
[Fact]
public void AddCommand_WithResult_RegistersCommandMeta()
{
var services = new ServiceCollection();
services.AddCommand<CreatePersonCommand, CreatePersonResult, CreatePersonCommandHandler>();
var provider = services.BuildServiceProvider();
var metas = provider.GetServices<ICommandMeta>().ToList();
Assert.Single(metas);
Assert.Equal(typeof(CreatePersonCommand), metas[0].CommandType);
Assert.Equal(typeof(CreatePersonResult), metas[0].CommandResultType);
}
[Fact]
public void AddCommand_WithoutResult_RegistersHandlerInDI()
{
var services = new ServiceCollection();
services.AddCommand<DeletePersonCommand, DeletePersonCommandHandler>();
var provider = services.BuildServiceProvider();
var handler = provider.GetService<ICommandHandler<DeletePersonCommand>>();
Assert.NotNull(handler);
Assert.IsType<DeletePersonCommandHandler>(handler);
}
[Fact]
public void AddCommand_WithoutResult_RegistersCommandMeta()
{
var services = new ServiceCollection();
services.AddCommand<DeletePersonCommand, DeletePersonCommandHandler>();
var provider = services.BuildServiceProvider();
var metas = provider.GetServices<ICommandMeta>().ToList();
Assert.Single(metas);
Assert.Equal(typeof(DeletePersonCommand), metas[0].CommandType);
Assert.Null(metas[0].CommandResultType);
}
[Fact]
public void AddQuery_RegistersHandlerInDI()
{
var services = new ServiceCollection();
services.AddQuery<PersonQuery, IEnumerable<string>, PersonQueryHandler>();
var provider = services.BuildServiceProvider();
var handler = provider.GetService<IQueryHandler<PersonQuery, IEnumerable<string>>>();
Assert.NotNull(handler);
Assert.IsType<PersonQueryHandler>(handler);
}
[Fact]
public void AddQuery_RegistersQueryMeta()
{
var services = new ServiceCollection();
services.AddQuery<PersonQuery, IEnumerable<string>, PersonQueryHandler>();
var provider = services.BuildServiceProvider();
var metas = provider.GetServices<IQueryMeta>().ToList();
Assert.Single(metas);
Assert.Equal(typeof(PersonQuery), metas[0].QueryType);
Assert.Equal(typeof(IEnumerable<string>), metas[0].QueryResultType);
}
[Fact]
public void AddDefaultCommandDiscovery_RegistersCommandDiscovery()
{
var services = new ServiceCollection();
services.AddDefaultCommandDiscovery();
var provider = services.BuildServiceProvider();
var discovery = provider.GetService<ICommandDiscovery>();
Assert.NotNull(discovery);
Assert.IsType<CommandDiscovery>(discovery);
}
[Fact]
public void AddDefaultQueryDiscovery_RegistersQueryDiscovery()
{
var services = new ServiceCollection();
services.AddDefaultQueryDiscovery();
var provider = services.BuildServiceProvider();
var discovery = provider.GetService<IQueryDiscovery>();
Assert.NotNull(discovery);
Assert.IsType<QueryDiscovery>(discovery);
}
[Fact]
public void FullPipeline_DiscoveryFindsRegisteredCommands()
{
var services = new ServiceCollection();
services.AddCommand<CreatePersonCommand, CreatePersonResult, CreatePersonCommandHandler>();
services.AddCommand<DeletePersonCommand, DeletePersonCommandHandler>();
services.AddDefaultCommandDiscovery();
var provider = services.BuildServiceProvider();
var discovery = provider.GetRequiredService<ICommandDiscovery>();
Assert.Equal(2, discovery.GetCommands().Count());
Assert.True(discovery.CommandExists("CreatePerson"));
Assert.True(discovery.CommandExists("DeletePerson"));
}
[Fact]
public void FullPipeline_DiscoveryFindsRegisteredQueries()
{
var services = new ServiceCollection();
services.AddQuery<PersonQuery, IEnumerable<string>, PersonQueryHandler>();
services.AddQuery<PersonLookupQuery, string, PersonLookupQueryHandler>();
services.AddDefaultQueryDiscovery();
var provider = services.BuildServiceProvider();
var discovery = provider.GetRequiredService<IQueryDiscovery>();
Assert.Equal(2, discovery.GetQueries().Count());
Assert.True(discovery.QueryExists("Person"));
Assert.True(discovery.QueryExists("customPersonLookup"));
}
[Fact]
public void AddSvrntyCqrs_RegistersDiscoveryServices()
{
var services = new ServiceCollection();
services.AddSvrntyCqrs();
var provider = services.BuildServiceProvider();
Assert.NotNull(provider.GetService<ICommandDiscovery>());
Assert.NotNull(provider.GetService<IQueryDiscovery>());
}
[Fact]
public void AddSvrntyCqrs_WithFluentBuilder_RegistersCommandsAndQueries()
{
var services = new ServiceCollection();
services.AddSvrntyCqrs(builder =>
{
builder
.AddCommand<CreatePersonCommand, CreatePersonResult, CreatePersonCommandHandler>()
.AddCommand<DeletePersonCommand, DeletePersonCommandHandler>()
.AddQuery<PersonQuery, IEnumerable<string>, PersonQueryHandler>();
});
var provider = services.BuildServiceProvider();
var cmdDiscovery = provider.GetRequiredService<ICommandDiscovery>();
var qryDiscovery = provider.GetRequiredService<IQueryDiscovery>();
Assert.Equal(2, cmdDiscovery.GetCommands().Count());
Assert.Single(qryDiscovery.GetQueries());
}
}
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
<PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.*">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0-*" />
<PackageReference Include="FluentValidation" Version="11.11.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Svrnty.CQRS\Svrnty.CQRS.csproj" />
<ProjectReference Include="..\..\Svrnty.CQRS.Abstractions\Svrnty.CQRS.Abstractions.csproj" />
<ProjectReference Include="..\..\Svrnty.CQRS.FluentValidation\Svrnty.CQRS.FluentValidation.csproj" />
<ProjectReference Include="..\..\Svrnty.CQRS.DynamicQuery\Svrnty.CQRS.DynamicQuery.csproj" />
<ProjectReference Include="..\..\Svrnty.CQRS.DynamicQuery.Abstractions\Svrnty.CQRS.DynamicQuery.Abstractions.csproj" />
</ItemGroup>
</Project>