added roadmap and plans

This commit is contained in:
Mathias Beaulieu-Duncan 2025-11-08 13:29:03 -05:00
parent e72cbe4319
commit dea62c2434
Signed by: mathias
GPG Key ID: 1C16CF05BAF9162D
4 changed files with 1260 additions and 2 deletions

View File

@ -41,7 +41,8 @@
"WebFetch(domain:www.kenmuse.com)",
"WebFetch(domain:blog.rsuter.com)",
"WebFetch(domain:natemcmaster.com)",
"WebFetch(domain:www.nuget.org)"
"WebFetch(domain:www.nuget.org)",
"Bash(mkdir:*)"
],
"deny": [],
"ask": []

View File

@ -264,4 +264,11 @@ builder.Services.AddTransient<IValidator<EchoCommand>, EchoCommandValidator>();
| Update FluentValidation | Upgrade FluentValidation to version 11.x for .NET 10 compatibility. | ✅ |
| Add gRPC Support with source generators | Implement gRPC endpoints with source generators and Google Rich Error Model for validation. | ✅ |
| Create a demo project (Svrnty.CQRS.Grpc.Sample) | Develop a comprehensive demo project showcasing gRPC and HTTP endpoints. | ✅ |
| Create a website for the Framework | Develop a website to host comprehensive documentation for the framework. | ⬜️ |
| 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. | ⬜️ |

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