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