Compare commits

...

4 Commits

Author SHA1 Message Date
dea62c2434
added roadmap and plans 2025-11-08 13:29:03 -05:00
e72cbe4319 update readme 2025-11-07 13:34:51 -05:00
467e700885 added nugets references in readme :) 2025-11-07 13:01:24 -05:00
898aca0905 fix nuget package for Generator assembly?
All checks were successful
Publish NuGets / build (release) Successful in 46s
2025-11-07 12:48:00 -05:00
7 changed files with 1391 additions and 133 deletions

View File

@ -32,7 +32,17 @@
"Bash(lsof:*)", "Bash(lsof:*)",
"Bash(xargs kill -9)", "Bash(xargs kill -9)",
"Bash(dotnet run:*)", "Bash(dotnet run:*)",
"Bash(find:*)" "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": [], "deny": [],
"ask": [] "ask": []

213
README.md
View File

@ -12,10 +12,9 @@ Our implementation of query and command responsibility segregation (CQRS).
|-----------------------------------------| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |-----------------------------------------------------------------------:| |-----------------------------------------| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |-----------------------------------------------------------------------:|
| 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 | [![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.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.AspNetCore | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.AspNetCore.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS.AspNetCore/) | ```dotnet add package Svrnty.CQRS.AspNetCore ``` |
| 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.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 | [![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.AspNetCore | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.DynamicQuery.AspNetCore.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS.DynamicQuery.AspNetCore/) | ```dotnet add package Svrnty.CQRS.DynamicQuery.AspNetCore ``` | | 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 | [![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 ``` | | 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 ``` |
@ -31,28 +30,33 @@ Our implementation of query and command responsibility segregation (CQRS).
## Sample of startup code for gRPC (Recommended) ## Sample of startup code for gRPC (Recommended)
```csharp ```csharp
using Svrnty.CQRS;
using Svrnty.CQRS.FluentValidation;
using Svrnty.CQRS.Grpc;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Register CQRS core services // Register your commands with validators
builder.Services.AddSvrntyCQRS(); builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler, AddUserCommandValidator>();
builder.Services.AddDefaultCommandDiscovery(); builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
builder.Services.AddDefaultQueryDiscovery();
// Add your commands and queries // Register your queries
AddQueries(builder.Services); builder.Services.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
AddCommands(builder.Services);
// Add gRPC support // Configure CQRS with gRPC support
builder.Services.AddGrpc(); builder.Services.AddSvrntyCqrs(cqrs =>
{
// Enable gRPC endpoints with reflection
cqrs.AddGrpc(grpc =>
{
grpc.EnableReflection();
});
});
var app = builder.Build(); var app = builder.Build();
// Map auto-generated gRPC service implementations // Map all configured CQRS endpoints
app.MapGrpcService<CommandServiceImpl>(); app.UseSvrntyCqrs();
app.MapGrpcService<QueryServiceImpl>();
// Enable gRPC reflection for tools like grpcurl
app.MapGrpcReflectionService();
app.Run(); app.Run();
``` ```
@ -75,31 +79,9 @@ dotnet add package Grpc.StatusProto # For Rich Error Model validation
dotnet add package Svrnty.CQRS.Grpc.Generators dotnet add package Svrnty.CQRS.Grpc.Generators
``` ```
The source generator is automatically configured as an analyzer when installed via NuGet and will generate the gRPC service implementations at compile time. 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.
#### 3. Define your proto files in `Protos/` directory: #### 3. Define your C# commands and queries:
```protobuf
syntax = "proto3";
import "google/protobuf/empty.proto";
service CommandService {
rpc AddUser(AddUserCommandRequest) returns (AddUserCommandResponse);
rpc RemoveUser(RemoveUserCommandRequest) returns (google.protobuf.Empty);
}
message AddUserCommandRequest {
string name = 1;
string email = 2;
int32 age = 3;
}
message AddUserCommandResponse {
int32 result = 1;
}
```
#### 4. Define your C# commands matching the proto structure:
```csharp ```csharp
public record AddUserCommand public record AddUserCommand
@ -116,28 +98,38 @@ public record RemoveUserCommand
``` ```
**Notes:** **Notes:**
- The source generator automatically creates `CommandServiceImpl` and `QueryServiceImpl` implementations - The source generator automatically creates:
- Property names in C# commands must match proto field names (case-insensitive) - `.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 - FluentValidation is automatically integrated with **Google Rich Error Model** for structured validation errors
- Validation errors return `google.rpc.Status` with `BadRequest` containing `FieldViolations` - Validation errors return `google.rpc.Status` with `BadRequest` containing `FieldViolations`
- Use `record` types for commands/queries (immutable, value-based equality, more concise) - Use `record` types for commands/queries (immutable, value-based equality, more concise)
- No need for protobuf-net attributes - No need for protobuf-net attributes - just define your C# types
## Sample of startup code for Minimal API (HTTP) ## Sample of startup code for Minimal API (HTTP)
For HTTP scenarios (web browsers, public APIs), you can use the Minimal API approach: For HTTP scenarios (web browsers, public APIs), you can use the Minimal API approach:
```csharp ```csharp
using Svrnty.CQRS;
using Svrnty.CQRS.FluentValidation;
using Svrnty.CQRS.MinimalApi;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Register CQRS core services // Register your commands with validators
builder.Services.AddSvrntyCQRS(); builder.Services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler, CreatePersonCommandValidator>();
builder.Services.AddDefaultCommandDiscovery(); builder.Services.AddCommand<EchoCommand, string, EchoCommandHandler, EchoCommandValidator>();
builder.Services.AddDefaultQueryDiscovery();
// Add your commands and queries // Register your queries
AddQueries(builder.Services); builder.Services.AddQuery<PersonQuery, IQueryable<Person>, PersonQueryHandler>();
AddCommands(builder.Services);
// Configure CQRS with Minimal API support
builder.Services.AddSvrntyCqrs(cqrs =>
{
// Enable Minimal API endpoints
cqrs.AddMinimalApi();
});
// Add Swagger (optional) // Add Swagger (optional)
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
@ -151,9 +143,8 @@ if (app.Environment.IsDevelopment())
app.UseSwaggerUI(); app.UseSwaggerUI();
} }
// Map CQRS endpoints - automatically creates routes for all commands and queries // Map all configured CQRS endpoints (automatically creates POST /api/command/* and POST/GET /api/query/*)
app.MapSvrntyCommands(); // Creates POST /api/command/{commandName} endpoints app.UseSvrntyCqrs();
app.MapSvrntyQueries(); // Creates POST/GET /api/query/{queryName} endpoints
app.Run(); app.Run();
``` ```
@ -169,19 +160,32 @@ app.Run();
You can enable both gRPC and traditional HTTP endpoints simultaneously, allowing clients to choose their preferred protocol: You can enable both gRPC and traditional HTTP endpoints simultaneously, allowing clients to choose their preferred protocol:
```csharp ```csharp
using Svrnty.CQRS;
using Svrnty.CQRS.FluentValidation;
using Svrnty.CQRS.Grpc;
using Svrnty.CQRS.MinimalApi;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Register CQRS core services // Register your commands with validators
builder.Services.AddSvrntyCQRS(); builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler, AddUserCommandValidator>();
builder.Services.AddDefaultCommandDiscovery(); builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
builder.Services.AddDefaultQueryDiscovery();
// Add your commands and queries // Register your queries
AddQueries(builder.Services); builder.Services.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
AddCommands(builder.Services);
// Add gRPC support // Configure CQRS with both gRPC and Minimal API support
builder.Services.AddGrpc(); 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 // Add HTTP support with Swagger
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
@ -195,14 +199,8 @@ if (app.Environment.IsDevelopment())
app.UseSwaggerUI(); app.UseSwaggerUI();
} }
// Map gRPC endpoints // Map all configured CQRS endpoints (both gRPC and HTTP)
app.MapGrpcService<CommandServiceImpl>(); app.UseSvrntyCqrs();
app.MapGrpcService<QueryServiceImpl>();
app.MapGrpcReflectionService();
// Map HTTP endpoints
app.MapSvrntyCommands();
app.MapSvrntyQueries();
app.Run(); app.Run();
``` ```
@ -214,47 +212,10 @@ app.Run();
- Same commands, queries, and validation logic for both protocols - Same commands, queries, and validation logic for both protocols
- Swagger UI available for HTTP endpoints, gRPC reflection for gRPC clients - Swagger UI available for HTTP endpoints, gRPC reflection for gRPC clients
> Example how to add your queries and commands.
```csharp
private void AddCommands(IServiceCollection services)
{
services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler>();
services.AddTransient<IValidator<CreatePersonCommand>, CreatePersonCommandValidator>();
services.AddCommand<EchoCommand, string, EchoCommandHandler>();
services.AddTransient<IValidator<EchoCommand>, EchoCommandValidator>();
}
private void AddQueries(IServiceCollection services)
{
services.AddQuery<PersonQuery, IQueryable<Person>, PersonQueryHandler>();
}
```
# Fluent Validation # Fluent Validation
FluentValidation is optional but recommended for command and query validation. The `Svrnty.CQRS.FluentValidation` package provides extension methods to simplify validator registration. FluentValidation is optional but recommended for command and query validation. The `Svrnty.CQRS.FluentValidation` package provides extension methods to simplify validator registration.
## Without Svrnty.CQRS.FluentValidation
You need to register commands and validators separately:
```csharp
using Microsoft.Extensions.DependencyInjection;
using FluentValidation;
using Svrnty.CQRS;
private void AddCommands(IServiceCollection services)
{
// Register command handler
services.AddCommand<EchoCommand, string, EchoCommandHandler>();
// Manually register validator
services.AddTransient<IValidator<EchoCommand>, EchoCommandValidator>();
}
```
## With Svrnty.CQRS.FluentValidation (Recommended) ## With Svrnty.CQRS.FluentValidation (Recommended)
The package exposes extension method overloads that accept the validator as a generic parameter: The package exposes extension method overloads that accept the validator as a generic parameter:
@ -264,17 +225,13 @@ dotnet add package Svrnty.CQRS.FluentValidation
``` ```
```csharp ```csharp
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.FluentValidation; // Extension methods for validator registration using Svrnty.CQRS.FluentValidation; // Extension methods for validator registration
private void AddCommands(IServiceCollection services) // Command with result - validator as last generic parameter
{ builder.Services.AddCommand<EchoCommand, string, EchoCommandHandler, EchoCommandValidator>();
// Command without result - validator included in generics
services.AddCommand<EchoCommand, string, EchoCommandHandler, EchoCommandValidator>();
// Command with result - validator as last generic parameter // Command without result - validator included in generics
services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler, CreatePersonCommandValidator>(); builder.Services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler, CreatePersonCommandValidator>();
}
``` ```
**Benefits:** **Benefits:**
@ -283,6 +240,21 @@ private void AddCommands(IServiceCollection services)
- **Less boilerplate** - No need for separate `AddTransient<IValidator<T>>()` calls - **Less boilerplate** - No need for separate `AddTransient<IValidator<T>>()` calls
- **Cleaner code** - Clear intent that validation is part of command pipeline - **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 # 2024-2025 Roadmap
| Task | Description | Status | | Task | Description | Status |
@ -292,4 +264,11 @@ private void AddCommands(IServiceCollection services)
| Update FluentValidation | Upgrade FluentValidation to version 11.x for .NET 10 compatibility. | ✅ | | 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. | ✅ | | 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 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. | ⬜️ | | 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. | ⬜️ |

View File

@ -7,7 +7,10 @@
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules> <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<IsPackable>true</IsPackable> <IsPackable>true</IsPackable>
<DevelopmentDependency>true</DevelopmentDependency> <DevelopmentDependency>true</DevelopmentDependency>
<!-- Don't include build output in lib/ - this is an analyzer/generator package -->
<IncludeBuildOutput>false</IncludeBuildOutput> <IncludeBuildOutput>false</IncludeBuildOutput>
<NoPackageAnalysis>true</NoPackageAnalysis>
<NoWarn>$(NoWarn);NU5128</NoWarn>
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking> <SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
<Company>Svrnty</Company> <Company>Svrnty</Company>
@ -20,11 +23,8 @@
<PackageLicenseExpression>MIT</PackageLicenseExpression> <PackageLicenseExpression>MIT</PackageLicenseExpression>
<Description>Source Generator for Svrnty.CQRS.Grpc - generates .proto files and gRPC service implementations from commands and queries</Description> <Description>Source Generator for Svrnty.CQRS.Grpc - generates .proto files and gRPC service implementations from commands and queries</Description>
<DebugType>portable</DebugType> <!-- Disable symbol packages for analyzer/generator packages (prevents NU5017 error) -->
<DebugSymbols>true</DebugSymbols> <IncludeSymbols>false</IncludeSymbols>
<IncludeSymbols>true</IncludeSymbols>
<IncludeSource>true</IncludeSource>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@ -39,11 +39,24 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<!-- Package as analyzer --> <!-- Include targets and props files in both build and buildTransitive for proper dependency flow -->
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
<!-- Also package as build task -->
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="build" Visible="false" />
<None Include="build\Svrnty.CQRS.Grpc.Generators.targets" Pack="true" PackagePath="build" /> <None Include="build\Svrnty.CQRS.Grpc.Generators.targets" Pack="true" PackagePath="build" />
<None Include="build\Svrnty.CQRS.Grpc.Generators.targets" Pack="true" PackagePath="buildTransitive" />
<None Include="build\Svrnty.CQRS.Grpc.Generators.props" Pack="true" PackagePath="build" />
<None Include="build\Svrnty.CQRS.Grpc.Generators.props" Pack="true" PackagePath="buildTransitive" />
</ItemGroup> </ItemGroup>
<!-- Use the recommended pattern to include the generator DLL in the package -->
<PropertyGroup>
<TargetsForTfmSpecificContentInPackage>$(TargetsForTfmSpecificContentInPackage);IncludeGeneratorAssemblyInPackage</TargetsForTfmSpecificContentInPackage>
</PropertyGroup>
<Target Name="IncludeGeneratorAssemblyInPackage">
<ItemGroup>
<!-- Include in analyzers folder for Roslyn source generator -->
<TfmSpecificPackageFile Include="$(OutputPath)$(AssemblyName).dll" PackagePath="analyzers/dotnet/cs" />
<!-- Include in build folder for MSBuild task (WriteProtoFileTask) -->
<TfmSpecificPackageFile Include="$(OutputPath)$(AssemblyName).dll" PackagePath="build" />
</ItemGroup>
</Target>
</Project> </Project>

View File

@ -0,0 +1,6 @@
<Project>
<PropertyGroup>
<!-- Marker to indicate Svrnty.CQRS.Grpc.Generators is referenced -->
<SvrntyCqrsGrpcGeneratorsVersion>$(SvrntyCqrsGrpcGeneratorsVersion)</SvrntyCqrsGrpcGeneratorsVersion>
</PropertyGroup>
</Project>

View File

@ -24,14 +24,14 @@ builder.Services.AddTransient<PoweredSoft.Data.Core.IAsyncQueryableService, Simp
builder.Services.AddTransient<PoweredSoft.DynamicQuery.Core.IQueryHandlerAsync, PoweredSoft.DynamicQuery.QueryHandlerAsync>(); builder.Services.AddTransient<PoweredSoft.DynamicQuery.Core.IQueryHandlerAsync, PoweredSoft.DynamicQuery.QueryHandlerAsync>();
builder.Services.AddDynamicQueryWithProvider<User, UserQueryableProvider>(); builder.Services.AddDynamicQueryWithProvider<User, UserQueryableProvider>();
// Register commands and queries with validators
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler, AddUserCommandValidator>();
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
builder.Services.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
// Configure CQRS with fluent API // Configure CQRS with fluent API
builder.Services.AddSvrntyCqrs(cqrs => builder.Services.AddSvrntyCqrs(cqrs =>
{ {
// Register commands and queries with validators
cqrs.AddCommand<AddUserCommand, int, AddUserCommandHandler, AddUserCommandValidator>();
cqrs.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
cqrs.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
// Enable gRPC endpoints with reflection // Enable gRPC endpoints with reflection
cqrs.AddGrpc(grpc => cqrs.AddGrpc(grpc =>
{ {

640
roadmap-2026/compression.md Normal file
View File

@ -0,0 +1,640 @@
# gRPC Compression Support - Implementation Plan
**Status:** Planned
**Target:** Q1 2026
**Priority:** High
**Complexity:** Medium
## Overview
Implement intelligent message compression for gRPC services with automatic threshold detection, per-handler control via attributes, and configurable compression levels. This will reduce bandwidth costs, improve performance over slow networks, and provide fine-grained control for developers.
## Goals
1. **Automatic Compression**: Compress messages larger than a configurable threshold (default: 1KB)
2. **Attribute-Based Control**: Allow developers to opt-out (`[NoCompression]`) or opt-in (`[CompressAlways]`) per handler
3. **Configurable Levels**: Support Fastest, Optimal, and SmallestSize compression levels
4. **Smart Defaults**: Don't compress small messages or already-compressed data
5. **Backward Compatible**: No breaking changes to existing APIs
6. **Zero Configuration**: Works out-of-the-box with sensible defaults
## Technical Design
### Architecture Components
```
┌─────────────────────────────────────────────────────────┐
│ gRPC Service Layer │
│ (Generated CommandServiceImpl/QueryServiceImpl) │
└─────────────────┬───────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Compression Interceptor │
│ - Automatically registered via UseCompression() │
│ - Checks handler metadata for compression attributes │
│ - Measures message size │
│ - Applies compression based on policy │
└─────────────────┬───────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Handler Execution │
│ ICommandHandler / IQueryHandler │
└─────────────────────────────────────────────────────────┘
```
### New Abstractions
#### 1. Compression Attributes
**File:** `Svrnty.CQRS.Grpc.Abstractions/Attributes/CompressionAttribute.cs`
```csharp
namespace Svrnty.CQRS.Grpc.Abstractions;
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
public sealed class NoCompressionAttribute : Attribute
{
}
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
public sealed class CompressAlwaysAttribute : Attribute
{
}
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
public sealed class CompressionLevelAttribute : Attribute
{
public CompressionLevel Level { get; }
public CompressionLevelAttribute(CompressionLevel level)
{
Level = level;
}
}
public enum CompressionLevel
{
Fastest = 0,
Optimal = 1,
SmallestSize = 2
}
```
#### 2. Compression Configuration
**File:** `Svrnty.CQRS.Grpc/Configuration/GrpcCompressionOptions.cs`
```csharp
namespace Svrnty.CQRS.Grpc;
public class GrpcCompressionOptions
{
public bool EnableAutomaticCompression { get; set; } = true;
public int CompressionThresholdBytes { get; set; } = 1024;
public CompressionLevel DefaultCompressionLevel { get; set; } = CompressionLevel.Optimal;
public string CompressionAlgorithm { get; set; } = "gzip";
public bool EnableCompressionMetrics { get; set; } = false;
}
```
#### 3. Compression Metadata
**File:** `Svrnty.CQRS.Grpc/Metadata/ICompressionMetadata.cs`
```csharp
namespace Svrnty.CQRS.Grpc;
public interface ICompressionMetadata
{
Type HandlerType { get; }
CompressionPolicy Policy { get; }
CompressionLevel? CustomLevel { get; }
}
public enum CompressionPolicy
{
Automatic,
Never,
Always
}
internal class CompressionMetadata : ICompressionMetadata
{
public Type HandlerType { get; init; }
public CompressionPolicy Policy { get; init; }
public CompressionLevel? CustomLevel { get; init; }
}
```
### Implementation Details
#### 1. Metadata Discovery
**File:** `Svrnty.CQRS.Grpc/Discovery/CompressionMetadataProvider.cs`
```csharp
namespace Svrnty.CQRS.Grpc;
internal class CompressionMetadataProvider
{
private readonly Dictionary<Type, ICompressionMetadata> _cache = new();
private readonly object _lock = new();
public ICompressionMetadata GetMetadata(Type handlerType)
{
if (_cache.TryGetValue(handlerType, out var metadata))
return metadata;
lock (_lock)
{
if (_cache.TryGetValue(handlerType, out metadata))
return metadata;
metadata = BuildMetadata(handlerType);
_cache[handlerType] = metadata;
return metadata;
}
}
private ICompressionMetadata BuildMetadata(Type handlerType)
{
var noCompression = handlerType.GetCustomAttribute<NoCompressionAttribute>();
var compressAlways = handlerType.GetCustomAttribute<CompressAlwaysAttribute>();
var customLevel = handlerType.GetCustomAttribute<CompressionLevelAttribute>();
if (noCompression != null && compressAlways != null)
{
throw new InvalidOperationException(
$"Handler {handlerType.Name} cannot have both [NoCompression] and [CompressAlways] attributes.");
}
var policy = CompressionPolicy.Automatic;
if (noCompression != null)
policy = CompressionPolicy.Never;
else if (compressAlways != null)
policy = CompressionPolicy.Always;
return new CompressionMetadata
{
HandlerType = handlerType,
Policy = policy,
CustomLevel = customLevel?.Level
};
}
}
```
#### 2. Compression Interceptor
**File:** `Svrnty.CQRS.Grpc/Interceptors/CompressionInterceptor.cs`
```csharp
namespace Svrnty.CQRS.Grpc;
internal class CompressionInterceptor : Interceptor
{
private readonly GrpcCompressionOptions _options;
private readonly CompressionMetadataProvider _metadataProvider;
private readonly ILogger<CompressionInterceptor> _logger;
public CompressionInterceptor(
IOptions<GrpcCompressionOptions> options,
CompressionMetadataProvider metadataProvider,
ILogger<CompressionInterceptor> logger)
{
_options = options.Value;
_metadataProvider = metadataProvider;
_logger = logger;
}
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
var response = await continuation(request, context);
if (_options.EnableAutomaticCompression)
{
ApplyCompressionPolicy(context, response);
}
return response;
}
private void ApplyCompressionPolicy<TResponse>(ServerCallContext context, TResponse response)
{
if (!context.UserState.TryGetValue("HandlerType", out var handlerTypeObj)
|| handlerTypeObj is not Type handlerType)
{
ApplyAutomaticCompression(context, response);
return;
}
var metadata = _metadataProvider.GetMetadata(handlerType);
switch (metadata.Policy)
{
case CompressionPolicy.Never:
break;
case CompressionPolicy.Always:
SetCompression(context, metadata.CustomLevel ?? _options.DefaultCompressionLevel);
break;
case CompressionPolicy.Automatic:
ApplyAutomaticCompression(context, response, metadata.CustomLevel);
break;
}
}
private void ApplyAutomaticCompression<TResponse>(
ServerCallContext context,
TResponse response,
CompressionLevel? customLevel = null)
{
var messageSize = EstimateMessageSize(response);
if (messageSize >= _options.CompressionThresholdBytes)
{
SetCompression(context, customLevel ?? _options.DefaultCompressionLevel);
if (_options.EnableCompressionMetrics)
{
_logger.LogDebug(
"Compressing response of {Size} bytes (threshold: {Threshold})",
messageSize, _options.CompressionThresholdBytes);
}
}
}
private void SetCompression(ServerCallContext context, CompressionLevel level)
{
var grpcLevel = level switch
{
CompressionLevel.Fastest => System.IO.Compression.CompressionLevel.Fastest,
CompressionLevel.Optimal => System.IO.Compression.CompressionLevel.Optimal,
CompressionLevel.SmallestSize => System.IO.Compression.CompressionLevel.SmallestSize,
_ => System.IO.Compression.CompressionLevel.Optimal
};
context.WriteOptions = new WriteOptions(flags: WriteFlags.NoCompress);
context.ResponseTrailers.Add("grpc-encoding", _options.CompressionAlgorithm);
}
private int EstimateMessageSize<TResponse>(TResponse response)
{
try
{
var json = System.Text.Json.JsonSerializer.Serialize(response);
return System.Text.Encoding.UTF8.GetByteCount(json);
}
catch
{
return 0;
}
}
}
```
#### 3. gRPC Configuration Builder
**File:** `Svrnty.CQRS.Grpc/Configuration/GrpcOptionsBuilder.cs`
```csharp
namespace Svrnty.CQRS.Grpc;
public class GrpcOptionsBuilder
{
private readonly IServiceCollection _services;
internal bool CompressionEnabled { get; private set; }
internal GrpcOptionsBuilder(IServiceCollection services)
{
_services = services;
}
public GrpcOptionsBuilder UseCompression(Action<GrpcCompressionOptions>? configure = null)
{
CompressionEnabled = true;
_services.AddSingleton<CompressionMetadataProvider>();
if (configure != null)
{
_services.Configure(configure);
}
_services.AddGrpc(options =>
{
options.Interceptors.Add<CompressionInterceptor>();
});
return this;
}
public GrpcOptionsBuilder EnableReflection()
{
_services.AddGrpcReflection();
return this;
}
}
```
#### 4. CQRS Configuration Builder
**File:** `Svrnty.CQRS/Configuration/CqrsOptionsBuilder.cs`
```csharp
namespace Svrnty.CQRS;
public class CqrsOptionsBuilder
{
private readonly IServiceCollection _services;
internal CqrsOptionsBuilder(IServiceCollection services)
{
_services = services;
}
public CqrsOptionsBuilder AddGrpc(Action<GrpcOptionsBuilder> configure)
{
var grpcBuilder = new GrpcOptionsBuilder(_services);
configure(grpcBuilder);
return this;
}
public CqrsOptionsBuilder AddMinimalApi()
{
return this;
}
}
```
#### 5. Service Registration
**File:** `Svrnty.CQRS/ServiceCollectionExtensions.cs`
```csharp
namespace Svrnty.CQRS;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddSvrntyCqrs(
this IServiceCollection services,
Action<CqrsOptionsBuilder>? configure = null)
{
services.AddDefaultCommandDiscovery();
services.AddDefaultQueryDiscovery();
if (configure != null)
{
var builder = new CqrsOptionsBuilder(services);
configure(builder);
}
return services;
}
}
```
#### 6. Source Generator Updates
The source generator needs to be updated to pass handler type information to the service implementation.
**File:** `Svrnty.CQRS.Grpc.Generators/ServiceImplementationGenerator.cs`
```csharp
// In the generated service implementation, add handler type to context:
public override async Task<ExecuteCommandResponse> ExecuteCommand(
ExecuteCommandRequest request,
ServerCallContext context)
{
context.UserState["HandlerType"] = typeof(THandler);
// ... rest of implementation
}
```
## Usage Examples
### Example 1: Basic Setup with Default Compression
```csharp
using Svrnty.CQRS;
using Svrnty.CQRS.Grpc;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler>();
builder.Services.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
builder.Services.AddSvrntyCqrs(cqrs =>
{
cqrs.AddGrpc(grpc =>
{
grpc.UseCompression();
grpc.EnableReflection();
});
});
var app = builder.Build();
app.UseSvrntyCqrs();
app.Run();
```
### Example 2: Custom Compression Configuration
```csharp
builder.Services.AddSvrntyCqrs(cqrs =>
{
cqrs.AddGrpc(grpc =>
{
grpc.UseCompression(compression =>
{
compression.CompressionThresholdBytes = 5 * 1024;
compression.DefaultCompressionLevel = CompressionLevel.Fastest;
compression.EnableCompressionMetrics = true;
});
grpc.EnableReflection();
});
});
```
### Example 3: Handler with No Compression (Binary Data)
```csharp
using Svrnty.CQRS.Grpc.Abstractions;
[NoCompression]
public class GetProfileImageQueryHandler : IQueryHandler<GetProfileImageQuery, byte[]>
{
public async Task<byte[]> HandleAsync(GetProfileImageQuery query, CancellationToken ct)
{
return await LoadImage(query.UserId);
}
}
```
### Example 4: Handler with Always Compress (Large Text)
```csharp
[CompressAlways]
[CompressionLevel(CompressionLevel.SmallestSize)]
public class ExportDataQueryHandler : IQueryHandler<ExportDataQuery, string>
{
public async Task<string> HandleAsync(ExportDataQuery query, CancellationToken ct)
{
return await GenerateCsvExport();
}
}
```
### Example 5: Default Automatic Compression
```csharp
public class GetUsersQueryHandler : IQueryHandler<GetUsersQuery, List<User>>
{
public async Task<List<User>> HandleAsync(GetUsersQuery query, CancellationToken ct)
{
return await GetUsers();
}
}
```
### Example 6: Both gRPC and HTTP (Compression Only for gRPC)
```csharp
builder.Services.AddSvrntyCqrs(cqrs =>
{
cqrs.AddGrpc(grpc =>
{
grpc.UseCompression(compression =>
{
compression.CompressionThresholdBytes = 2 * 1024;
});
grpc.EnableReflection();
});
cqrs.AddMinimalApi();
});
```
## Migration & Backward Compatibility
### Breaking Changes
**None.** This feature is fully backward compatible.
### Default Behavior
- Compression is NOT enabled by default
- Must call `.UseCompression()` to enable
- Once enabled, uses automatic compression with 1KB threshold
- Existing handlers work without modification
### Migration Path
1. Update to new version
2. Add `.UseCompression()` to gRPC configuration
3. Optionally add `[NoCompression]` to handlers with binary data
4. Optionally add `[CompressAlways]` to handlers with large text
5. Optionally configure custom threshold/levels
## Success Criteria
### Functional Requirements
- ✅ Automatic compression for messages > 1KB (configurable)
- ✅ `[NoCompression]` attribute prevents compression
- ✅ `[CompressAlways]` attribute forces compression
- ✅ `[CompressionLevel]` attribute sets custom level
- ✅ Configuration via fluent API: `AddSvrntyCqrs().AddGrpc().UseCompression()`
- ✅ No breaking changes to existing APIs
- ✅ Interceptor automatically registered when calling `UseCompression()`
### Performance Requirements
- ✅ Compression overhead < 5ms for 100KB messages
- ✅ 50-80% size reduction for text-heavy payloads
- ✅ No measurable impact for small messages (< 1KB)
## Documentation Requirements
### User Documentation
- Update README.md with compression feature in 2026 roadmap
- Update CLAUDE.md with compression configuration details
- Add compression examples to sample project
## Implementation Checklist
### Phase 1: Core Implementation
- [ ] Create compression attributes in `Svrnty.CQRS.Grpc.Abstractions`
- [ ] Create `GrpcCompressionOptions` configuration class
- [ ] Create `GrpcOptionsBuilder` with `UseCompression()` method
- [ ] Update `CqrsOptionsBuilder` to support `AddGrpc()`
- [ ] Implement `CompressionMetadataProvider` for discovering attributes
- [ ] Implement `CompressionInterceptor` for applying compression
- [ ] Ensure interceptor is automatically registered via `UseCompression()`
### Phase 2: Source Generator
- [ ] Update source generator to pass handler type to context
- [ ] Set `HandlerType` in `ServerCallContext.UserState`
### Phase 3: Documentation
- [ ] Update README.md with 2026 roadmap item
- [ ] Update CLAUDE.md with compression details
- [ ] Update sample project with compression examples
- [ ] Create migration guide
### Phase 4: Release
- [ ] Code review and feedback
- [ ] Merge to main branch
- [ ] Release as part of next minor version
- [ ] Publish NuGet packages
## Dependencies
### NuGet Packages
- `Grpc.AspNetCore` >= 2.68.0 (already referenced)
- `System.IO.Compression` (part of .NET runtime)
### Internal Dependencies
- `Svrnty.CQRS` (for configuration builders)
- `Svrnty.CQRS.Grpc.Abstractions` (for attributes)
- `Svrnty.CQRS.Grpc` (for implementation)
- `Svrnty.CQRS.Grpc.Generators` (source generator updates)
## Open Questions
1. **Message Size Estimation**: Should we use protobuf serialization size or JSON estimation?
- **Proposed**: Use protobuf for accuracy, fallback to JSON estimate if not available
2. **HTTP Compression**: Should we also support compression for Minimal API endpoints?
- **Proposed**: Phase 2 - HTTP already has built-in compression via ASP.NET Core
3. **Compression Metrics**: What metrics should we expose?
- **Proposed**: Compressed vs uncompressed size, compression ratio, compression time
4. **Per-Message Override**: Should clients be able to request no compression via metadata?
- **Proposed**: Phase 2 - respect `grpc-accept-encoding` header
## Future Enhancements (Beyond 2026)
1. **Multiple Compression Algorithms**: Support Brotli, LZ4, Snappy
2. **Adaptive Compression**: Machine learning to predict best compression strategy
3. **Client Hints**: Honor client compression preferences
4. **Compression Metrics Dashboard**: Visual metrics for compression effectiveness
5. **HTTP Compression**: Extend to Minimal API endpoints with same attribute model
6. **Streaming Compression**: Apply compression to streaming RPCs
---
**Document Version:** 1.0
**Last Updated:** 2025-01-08
**Author:** Svrnty Team

View File

@ -0,0 +1,610 @@
# gRPC Metadata & Authorization Support - Implementation Plan (DRAFT)
**Status:** Draft (Option 4 Preferred)
**Target:** Q1 2026
**Priority:** High
**Complexity:** Medium
## Overview
Expose gRPC metadata and `ServerCallContext` to handlers, and integrate the existing `ICommandAuthorizationService` and `IQueryAuthorizationService` with gRPC endpoints. This will enable authentication, authorization, distributed tracing, multi-tenancy, and other context-aware features in gRPC services.
## Problem Statement
### Current Limitations
1. **No Authorization in gRPC:**
- `ICommandAuthorizationService` and `IQueryAuthorizationService` exist but are only used by Minimal API
- gRPC services skip authorization checks entirely
- Security gap for gRPC endpoints
2. **No Context Access:**
- Handlers cannot access `ServerCallContext`
- Cannot read request metadata (headers, auth tokens, correlation IDs)
- Cannot set response metadata
- Cannot access user identity/claims
- Cannot determine caller information
3. **Authorization Interface Limitation:**
```csharp
Task<AuthorizationResult> IsAllowedAsync(Type commandType, CancellationToken ct);
```
- No access to user context
- No access to request metadata
- Cannot implement context-aware authorization
## Goals
1. **Expose Context to Handlers:** Allow handlers to access `ServerCallContext` via accessor service
2. **Integrate Authorization:** Call authorization services before executing handlers in gRPC
3. **Maintain Backward Compatibility:** Existing handlers continue to work without changes
4. **Enable Advanced Scenarios:** Multi-tenancy, distributed tracing, custom authentication
5. **Familiar Pattern:** Use accessor pattern similar to ASP.NET Core's `IHttpContextAccessor`
## Recommended Approach: Context Accessor Pattern (Option 4)
**PREFERRED APPROACH** - Use ambient context accessor similar to `IHttpContextAccessor` in ASP.NET Core.
### Why Option 4?
- ✅ **Zero breaking changes** - No handler interface modifications
- ✅ **Opt-in** - Only inject accessor when context is needed
- ✅ **Familiar pattern** - Mirrors `IHttpContextAccessor` developers already know
- ✅ **Full protocol access** - Can use raw `ServerCallContext` or `HttpContext` features
- ✅ **Flexible** - Handlers that don't need context remain simple
### Implementation Overview
```csharp
// New accessor interface
public interface ICqrsContextAccessor
{
ServerCallContext? GrpcContext { get; }
HttpContext? HttpContext { get; }
}
// Handlers opt-in by injecting the accessor
public class AddUserCommandHandler : ICommandHandler<AddUserCommand, int>
{
private readonly ICqrsContextAccessor _context;
public AddUserCommandHandler(ICqrsContextAccessor context)
{
_context = context;
}
public async Task<int> HandleAsync(AddUserCommand command, CancellationToken ct)
{
// Access gRPC metadata when available
if (_context.GrpcContext != null)
{
var token = _context.GrpcContext.RequestHeaders.GetValue("authorization");
var userId = ExtractUserIdFromToken(token);
}
// Or access HTTP context when available
if (_context.HttpContext != null)
{
var userId = _context.HttpContext.User?.Identity?.Name;
}
// Handler logic...
}
}
```
See full implementation details in [Option 4 section](#option-4-context-accessor-pattern-ambient-context) below.
---
## Alternative Design Options (For Reference)
### Option 1: Optional ServerCallContext Parameter
**Approach:** Add optional `ServerCallContext` parameter to handler interface methods.
**Handler Interface Changes:**
```csharp
namespace Svrnty.CQRS.Abstractions;
public interface ICommandHandler<TCommand>
{
Task HandleAsync(TCommand command, CancellationToken cancellationToken = default, ServerCallContext? context = null);
}
public interface ICommandHandler<TCommand, TResult>
{
Task<TResult> HandleAsync(TCommand command, CancellationToken cancellationToken = default, ServerCallContext? context = null);
}
public interface IQueryHandler<TQuery, TResult>
{
Task<TResult> HandleAsync(TQuery query, CancellationToken cancellationToken = default, ServerCallContext? context = null);
}
```
**Pros:**
- ✅ Simple and straightforward
- ✅ Backward compatible (optional parameter with default null)
- ✅ Handlers can explicitly access context when needed
- ✅ No dependency on HTTP types in core abstractions
**Cons:**
- ❌ Couples abstractions to gRPC (requires `Grpc.Core` reference)
- ❌ Minimal API handlers won't have access to `HttpContext` through same parameter
- ❌ Different context types for HTTP vs gRPC
**Authorization Service Changes:**
```csharp
namespace Svrnty.CQRS.Abstractions.Security;
public interface ICommandAuthorizationService
{
Task<AuthorizationResult> IsAllowedAsync(Type commandType, object? context, CancellationToken ct = default);
}
```
Where `context` is:
- `ServerCallContext` for gRPC
- `HttpContext` for Minimal API
- `null` if no context available
---
### Option 2: Unified Context Abstraction
**Approach:** Create abstraction over both `ServerCallContext` and `HttpContext`.
**New Abstraction:**
```csharp
namespace Svrnty.CQRS.Abstractions;
public interface IRequestContext
{
string? GetHeader(string name);
void SetHeader(string name, string value);
IDictionary<string, object> Items { get; }
CancellationToken CancellationToken { get; }
IUserContext? User { get; }
}
public interface IUserContext
{
bool IsAuthenticated { get; }
string? UserId { get; }
IEnumerable<string> Roles { get; }
IEnumerable<Claim> Claims { get; }
}
public interface ICommandHandler<TCommand>
{
Task HandleAsync(TCommand command, IRequestContext? context = null, CancellationToken ct = default);
}
```
**Implementation Wrappers:**
```csharp
internal class GrpcRequestContext : IRequestContext
{
private readonly ServerCallContext _grpcContext;
public GrpcRequestContext(ServerCallContext context)
{
_grpcContext = context;
}
public string? GetHeader(string name) => _grpcContext.RequestHeaders.GetValue(name);
public CancellationToken CancellationToken => _grpcContext.CancellationToken;
// ... map other properties
}
internal class HttpRequestContext : IRequestContext
{
private readonly HttpContext _httpContext;
// ... similar mapping
}
```
**Pros:**
- ✅ Protocol-agnostic handlers
- ✅ Unified authorization interface
- ✅ No coupling to gRPC or HTTP in abstractions
- ✅ Can support future protocols (WebSockets, etc.)
**Cons:**
- ❌ Additional abstraction layer (more complexity)
- ❌ Cannot access protocol-specific features (e.g., gRPC deadlines, HTTP response codes)
- ❌ Mapping overhead between contexts
- ❌ Leaky abstraction if protocol-specific needs arise
---
### Option 3: Separate gRPC-Aware Handler Interfaces
**Approach:** Keep existing handlers unchanged, add new gRPC-specific interfaces.
**New Interfaces:**
```csharp
namespace Svrnty.CQRS.Grpc.Abstractions;
public interface IGrpcCommandHandler<TCommand>
{
Task HandleAsync(TCommand command, ServerCallContext context, CancellationToken ct = default);
}
public interface IGrpcCommandHandler<TCommand, TResult>
{
Task<TResult> HandleAsync(TCommand command, ServerCallContext context, CancellationToken ct = default);
}
public interface IGrpcQueryHandler<TQuery, TResult>
{
Task<TResult> HandleAsync(TQuery query, ServerCallContext context, CancellationToken ct = default);
}
```
**Pros:**
- ✅ No changes to core abstractions
- ✅ Explicit opt-in for context access
- ✅ Can access full `ServerCallContext` API
- ✅ Clear separation between gRPC and HTTP handlers
**Cons:**
- ❌ Handler duplication (need to implement both interfaces if supporting HTTP + gRPC)
- ❌ Discovery system needs to handle multiple handler types
- ❌ More complex registration (which handler for which protocol?)
---
### Option 4: Context Accessor Pattern (Ambient Context)
**Approach:** Use service locator/accessor pattern for context access.
**Implementation:**
```csharp
namespace Svrnty.CQRS.Abstractions;
public interface ICommandContextAccessor
{
ServerCallContext? GrpcContext { get; }
HttpContext? HttpContext { get; }
}
public class MyCommandHandler : ICommandHandler<MyCommand>
{
private readonly ICommandContextAccessor _contextAccessor;
public MyCommandHandler(ICommandContextAccessor contextAccessor)
{
_contextAccessor = contextAccessor;
}
public async Task HandleAsync(MyCommand command, CancellationToken ct)
{
var grpcContext = _contextAccessor.GrpcContext;
if (grpcContext != null)
{
// Access gRPC-specific features
}
}
}
```
**Pros:**
- ✅ No changes to handler interfaces
- ✅ Fully backward compatible
- ✅ Handlers can access context only when needed
**Cons:**
- ❌ Ambient context is an anti-pattern (hidden dependency)
- ❌ Harder to test (need to mock accessor)
- ❌ AsyncLocal overhead for context flow
- ❌ Not explicit in handler signature
---
## Authorization Integration
Regardless of which option is chosen, authorization needs to be integrated into the gRPC source generator.
### Source Generator Changes
**File:** `Svrnty.CQRS.Grpc.Generators/ServiceImplementationGenerator.cs`
```csharp
public override async Task<AddUserResponse> AddUser(
AddUserRequest request,
ServerCallContext context)
{
var command = new AddUserCommand
{
Name = request.Name,
Email = request.Email
};
// AUTHORIZATION CHECK
var authService = _serviceProvider.GetService<ICommandAuthorizationService>();
if (authService != null)
{
var authResult = await authService.IsAllowedAsync(typeof(AddUserCommand), context, context.CancellationToken);
if (authResult == AuthorizationResult.Unauthorized)
{
throw new RpcException(new Status(StatusCode.Unauthenticated, "Authentication required"));
}
if (authResult == AuthorizationResult.Forbidden)
{
throw new RpcException(new Status(StatusCode.PermissionDenied, "Insufficient permissions"));
}
}
// Validation (existing)
var validator = _serviceProvider.GetService<IValidator<AddUserCommand>>();
if (validator != null)
{
var validationResult = await validator.ValidateAsync(command, context.CancellationToken);
if (!validationResult.IsValid)
{
// ... existing validation error handling
}
}
// Execute handler
var handler = _serviceProvider.GetRequiredService<ICommandHandler<AddUserCommand, int>>();
var result = await handler.HandleAsync(command, context.CancellationToken, context); // Option 1
return new AddUserResponse { Result = result };
}
```
### Authorization Service Update
**Current Interface:**
```csharp
public interface ICommandAuthorizationService
{
Task<AuthorizationResult> IsAllowedAsync(Type commandType, CancellationToken ct = default);
}
```
**Proposed Interface:**
```csharp
public interface ICommandAuthorizationService
{
Task<AuthorizationResult> IsAllowedAsync(
Type commandType,
object? requestContext = null,
CancellationToken ct = default);
}
```
Where `requestContext` can be:
- `ServerCallContext` for gRPC
- `HttpContext` for HTTP
- Custom context for other scenarios
- `null` if no context
**Breaking Change Mitigation:**
Add new method with default parameter to maintain backward compatibility:
```csharp
public interface ICommandAuthorizationService
{
// Old method (deprecated but still supported)
Task<AuthorizationResult> IsAllowedAsync(Type commandType, CancellationToken ct = default);
// New method with context
Task<AuthorizationResult> IsAllowedAsync(Type commandType, object? requestContext, CancellationToken ct = default);
}
```
Default implementation:
```csharp
public abstract class CommandAuthorizationServiceBase : ICommandAuthorizationService
{
public virtual Task<AuthorizationResult> IsAllowedAsync(Type commandType, CancellationToken ct = default)
{
return IsAllowedAsync(commandType, null, ct);
}
public abstract Task<AuthorizationResult> IsAllowedAsync(Type commandType, object? requestContext, CancellationToken ct);
}
```
---
## Implementation Phases
### Phase 1: Context Access (Choose Design Option)
- [ ] Decide on design option (1, 2, 3, or 4)
- [ ] Update handler interfaces (if needed)
- [ ] Update source generator to pass context to handlers
- [ ] Create context wrappers/abstractions (if needed)
- [ ] Update Minimal API to work with new approach
### Phase 2: Authorization Service Update
- [ ] Update `ICommandAuthorizationService` and `IQueryAuthorizationService` interfaces
- [ ] Provide backward-compatible base class
- [ ] Update Minimal API to pass context to authorization services
### Phase 3: gRPC Authorization Integration
- [ ] Update source generator to call authorization services
- [ ] Map authorization results to gRPC status codes
- [ ] Handle `Unauthenticated` (401) → `StatusCode.Unauthenticated`
- [ ] Handle `Forbidden` (403) → `StatusCode.PermissionDenied`
### Phase 4: Sample Implementation
- [ ] Create sample authorization service implementation
- [ ] Demonstrate JWT token validation in gRPC
- [ ] Demonstrate role-based authorization
- [ ] Update sample project with authorization examples
### Phase 5: Documentation
- [ ] Update CLAUDE.md with authorization details
- [ ] Document how to implement custom authorization services
- [ ] Document how to access context in handlers
- [ ] Create migration guide
---
## Open Questions
### 1. Which design option should we use?
**Question:** Option 1 (optional parameter), Option 2 (unified abstraction), Option 3 (separate interfaces), or Option 4 (accessor pattern)?
**Decision:** ✅ **Option 4 (Context Accessor Pattern)** - Chosen for backward compatibility and familiar ASP.NET Core pattern.
### 2. Should we support accessing HttpContext in handlers?
**Question:** If a handler wants to access HTTP-specific features, should we support that through the same mechanism?
**Decision:** ✅ **Yes** - `ICqrsContextAccessor` will expose both `GrpcContext` and `HttpContext` properties. Handlers can check which is non-null to determine the protocol.
### 3. Breaking changes to authorization interface?
**Question:** Is it acceptable to add a parameter to the authorization interface?
**Options:**
- Add new method, keep old one (backward compatible but verbose)
- Add optional parameter with default `null` (semi-breaking for implementations)
- Create v2 interfaces (`ICommandAuthorizationServiceV2`)
### 4. How should we handle multi-protocol handlers?
**Question:** If a handler is used by both HTTP and gRPC, how does it differentiate?
**Decision:** ✅ **Check which context is non-null** - Simple null check: `if (_context.GrpcContext != null)` vs `if (_context.HttpContext != null)`
### 5. Should metadata access be strongly typed?
**Question:** Should we provide typed accessors for common metadata (JWT, correlation ID, etc.)?
**Options:**
- Raw string access only: `context.GetHeader("authorization")`
- Typed extensions: `context.GetAuthorizationToken()`, `context.GetCorrelationId()`
- Dedicated metadata services
---
## Success Criteria
### Functional Requirements
- ✅ Handlers can access `ServerCallContext` when needed
- ✅ Authorization services are called before gRPC handler execution
- ✅ Authorization failures return appropriate gRPC status codes
- ✅ Backward compatibility maintained for existing handlers
- ✅ Works with both HTTP and gRPC (if unified approach)
### Security Requirements
- ✅ Unauthorized requests return `StatusCode.Unauthenticated`
- ✅ Forbidden requests return `StatusCode.PermissionDenied`
- ✅ Authorization decisions can access user context
- ✅ JWT tokens can be validated from gRPC metadata
### Performance Requirements
- ✅ Minimal overhead for context passing
- ✅ No performance regression for handlers that don't use context
---
## Migration & Backward Compatibility
### Breaking Changes
**With Option 4:** ✅ **NO BREAKING CHANGES**
- Handler interfaces remain unchanged
- Existing handlers work without modification
- Authorization interface adds optional parameter (backward compatible)
### Migration Path
**For Option 4 (Chosen):**
1. Update to new version
2. All existing handlers continue to work unchanged
3. Handlers that need context inject `ICqrsContextAccessor` via constructor
4. Authorization service implementations can optionally use the new context parameter
5. No code changes required for basic upgrade
---
## Dependencies
### NuGet Packages
- `Grpc.AspNetCore` >= 2.68.0 (already referenced)
- `Microsoft.AspNetCore.Http.Abstractions` (for `HttpContext`, if using unified abstraction)
### Internal Dependencies
- `Svrnty.CQRS.Abstractions` (handler interfaces, authorization interfaces)
- `Svrnty.CQRS.Grpc` (context wrappers, if needed)
- `Svrnty.CQRS.Grpc.Generators` (source generator updates)
- `Svrnty.CQRS.MinimalApi` (authorization integration updates)
---
## Final Recommended Approach
Based on preference for **Option 4 (Context Accessor Pattern)**:
### Implementation Summary
1. **Create `ICqrsContextAccessor` interface:**
```csharp
public interface ICqrsContextAccessor
{
ServerCallContext? GrpcContext { get; }
HttpContext? HttpContext { get; }
}
```
2. **Implement accessor with AsyncLocal:**
- Use `AsyncLocal<T>` to flow context through async calls
- Similar to ASP.NET Core's `IHttpContextAccessor` implementation
- Set context in gRPC interceptor and HTTP middleware
3. **Update authorization interface** with context parameter:
```csharp
Task<AuthorizationResult> IsAllowedAsync(
Type commandType,
object? requestContext = null,
CancellationToken ct = default);
```
4. **Update source generator** to:
- Set context in accessor before calling handler
- Call authorization service with `ServerCallContext`
- Map authorization results to gRPC status codes
5. **Provide sample implementations** for:
- JWT token validation from gRPC metadata
- Role-based authorization
- Multi-tenancy with tenant ID from headers
- Correlation ID extraction and propagation
### Advantages
- ✅ **Zero breaking changes** - No handler signature modifications required
- ✅ **Familiar to ASP.NET Core developers** - Same pattern as `IHttpContextAccessor`
- ✅ **Opt-in complexity** - Simple handlers remain simple
- ✅ **Full protocol support** - Access raw gRPC and HTTP contexts
- ✅ **Testable** - Can mock `ICqrsContextAccessor` in tests
### Next Steps
1. Finalize accessor interface design
2. Implement AsyncLocal-based accessor
3. Update source generator for context setting
4. Integrate authorization checks
5. Create comprehensive samples
---
**Document Version:** 0.2 (DRAFT - Option 4 Selected)
**Last Updated:** 2025-01-08
**Author:** Svrnty Team
**Status:** Ready for Implementation Planning