Compare commits

..

16 Commits

Author SHA1 Message Date
david.nguyen 20147bfec7 Add diagnostic logging when gRPC generated code not found
Publish NuGets / build (release) Successful in 37s
When AddGrpcFromConfiguration method is not found via reflection,
logs detailed diagnostics to help identify the root cause:
- Entry assembly name and total type count
- All Grpc-related types with their IsClass/IsSealed/IsPublic flags
- Whether the target method exists on each type
- ReflectionTypeLoadException details if type loading fails

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 16:10:18 -05:00
david.nguyen 18f81a28e8 Add authorization checks to gRPC service implementations
Publish NuGets / build (release) Successful in 44s
- Add ICommandAuthorizationService check to CommandServiceImpl
- Add IQueryAuthorizationService check to QueryServiceImpl
- Add IQueryAuthorizationService check to DynamicQueryServiceImpl
- Return Unauthenticated/PermissionDenied gRPC status codes
- Use global:: prefix for Grpc.Core namespace to avoid conflicts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 14:18:07 -05:00
david.nguyen 201768e716 Revert AllowAnonymous endpoint propagation
Publish NuGets / build (release) Successful in 35s
Remove the WithAllowAnonymousIfAttributePresent helper method.
Authorization should be handled by IQueryAuthorizationService and
ICommandAuthorizationService implementations, not by ASP.NET Core
middleware.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 13:07:03 -05:00
david.nguyen 932ee6e632 add AllowAnonymous support for MinimalApi endpoints
Publish NuGets / build (release) Successful in 37s
Endpoints with [AllowAnonymous] attribute on query/command class
now bypass ASP.NET Core authorization middleware.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 12:29:26 -05:00
jp 4bf03446c0 docs: update .NET 8 references to .NET 10
Publish NuGets / build (release) Successful in 50s
Consolidated roadmap to show .NET 10 with C# 14 as current target.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 09:22:27 -05:00
david.nguyen 227be70f95 Fix MinimalApi to resolve authorization services per-request
- Resolve ICommandAuthorizationService and IQueryAuthorizationService from request-scoped serviceProvider
- Allows Scoped authorization services that depend on DbContext
- Updated both MinimalApi and DynamicQuery.MinimalApi

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 15:56:31 -05:00
david.nguyen bd43bc9bde Fix gRPC source generator for complex nested types
- Add DateTime/Timestamp conversion in nested property mapping
- Add IsReadOnly property detection to skip computed properties
- Extract ElementNestedProperties for complex list element types
- Skip read-only properties in GenerateComplexObjectMapping and GenerateComplexListMapping

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 23:25:01 -05:00
david.nguyen 661f5b4b1c Fix GrpcGenerator type mapping for commands and nullable primitives
- Add proper complex type mapping for command results (same as queries already had)
- Handle nullable primitives (long?, int?, etc.) with default value fallback
- Fixes CS0029 and CS0266 compilation errors in generated gRPC service implementations

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 11:29:32 -05:00
david.nguyen 99aebcf314 Fix proto generation for collection types (NpgsqlPolygon, etc.)
- Add IsCollectionTypeByInterface() to detect types implementing IList<T>, ICollection<T>, IEnumerable<T>
- Add GetCollectionElementTypeByInterface() to extract element type from collection interfaces
- Add IsCollectionInternalProperty() to filter out Count, Capacity, IsReadOnly, etc.
- Update GenerateComplexTypeMessage to generate `repeated T items` for collection types
- Filter out indexers (!p.IsIndexer) and collection-internal properties from all property extraction

This fixes the invalid proto syntax where C# indexers (this[]) were being generated
as proto fields, causing proto compilation errors.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 10:33:55 -05:00
Mathias Beaulieu-Duncan f76dbb1a97 fix: add Guid to string conversion in gRPC source generator
The MapToProtoModel function was silently failing when mapping Guid
properties to proto string fields, causing IDs to be empty in gRPC
responses. Added explicit Guid → string conversion handling.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:06:18 -05:00
Mathias Beaulieu-Duncan 9b9e2cbdbe added domain events, fix IQueryalbeProvider abstraction, added support for sagas and RabbitMQ 2025-12-20 15:13:05 -05:00
mathias 4051800934 merge
Publish NuGets / build (release) Successful in 35s
2025-12-02 15:44:21 -05:00
mathias a312428093 update to .net 10 lts 2025-12-02 15:40:31 -05:00
mathias dea62c2434 added roadmap and plans 2025-11-08 13:29:03 -05:00
mathias e72cbe4319 update readme 2025-11-07 13:34:51 -05:00
mathias 467e700885 added nugets references in readme :) 2025-11-07 13:01:24 -05:00
69 changed files with 7655 additions and 359 deletions
+3 -1
View File
@@ -40,7 +40,9 @@
"WebFetch(domain:stackoverflow.com)",
"WebFetch(domain:www.kenmuse.com)",
"WebFetch(domain:blog.rsuter.com)",
"WebFetch(domain:natemcmaster.com)"
"WebFetch(domain:natemcmaster.com)",
"WebFetch(domain:www.nuget.org)",
"Bash(mkdir:*)"
],
"deny": [],
"ask": []
+1 -1
View File
@@ -301,7 +301,7 @@ All projects target .NET 10.0 and use C# 14, sharing common configuration:
### Package Dependencies
**Core Dependencies:**
- **Microsoft.Extensions.DependencyInjection.Abstractions**: 10.0.0-rc.2.25502.107 (will update to stable when .NET 10 is released)
- **Microsoft.Extensions.DependencyInjection.Abstractions**: 10.0.0
- **FluentValidation**: 11.11.0
- **PoweredSoft.DynamicQuery**: 3.0.1
- **Pluralize.NET**: 1.0.2
+97 -119
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.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.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.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)
```csharp
using Svrnty.CQRS;
using Svrnty.CQRS.FluentValidation;
using Svrnty.CQRS.Grpc;
var builder = WebApplication.CreateBuilder(args);
// Register CQRS core services
builder.Services.AddSvrntyCQRS();
builder.Services.AddDefaultCommandDiscovery();
builder.Services.AddDefaultQueryDiscovery();
// Register your commands with validators
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler, AddUserCommandValidator>();
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
// Add your commands and queries
AddQueries(builder.Services);
AddCommands(builder.Services);
// Register your queries
builder.Services.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
// Add gRPC support
builder.Services.AddGrpc();
// Configure CQRS with gRPC support
builder.Services.AddSvrntyCqrs(cqrs =>
{
// Enable gRPC endpoints with reflection
cqrs.AddGrpc(grpc =>
{
grpc.EnableReflection();
});
});
var app = builder.Build();
// Map auto-generated gRPC service implementations
app.MapGrpcService<CommandServiceImpl>();
app.MapGrpcService<QueryServiceImpl>();
// Enable gRPC reflection for tools like grpcurl
app.MapGrpcReflectionService();
// Map all configured CQRS endpoints
app.UseSvrntyCqrs();
app.Run();
```
@@ -75,31 +79,9 @@ dotnet add package Grpc.StatusProto # For Rich Error Model validation
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:
```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:
#### 3. Define your C# commands and queries:
```csharp
public record AddUserCommand
@@ -116,28 +98,38 @@ public record RemoveUserCommand
```
**Notes:**
- The source generator automatically creates `CommandServiceImpl` and `QueryServiceImpl` implementations
- Property names in C# commands must match proto field names (case-insensitive)
- The source generator automatically creates:
- `.proto` files in the `Protos/` directory from your C# commands and queries
- `CommandServiceImpl` and `QueryServiceImpl` implementations
- FluentValidation is automatically integrated with **Google Rich Error Model** for structured validation errors
- Validation errors return `google.rpc.Status` with `BadRequest` containing `FieldViolations`
- Use `record` types for commands/queries (immutable, value-based equality, more concise)
- No need for protobuf-net attributes
- No need for protobuf-net attributes - just define your C# types
## Sample of startup code for Minimal API (HTTP)
For HTTP scenarios (web browsers, public APIs), you can use the Minimal API approach:
```csharp
using Svrnty.CQRS;
using Svrnty.CQRS.FluentValidation;
using Svrnty.CQRS.MinimalApi;
var builder = WebApplication.CreateBuilder(args);
// Register CQRS core services
builder.Services.AddSvrntyCQRS();
builder.Services.AddDefaultCommandDiscovery();
builder.Services.AddDefaultQueryDiscovery();
// Register your commands with validators
builder.Services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler, CreatePersonCommandValidator>();
builder.Services.AddCommand<EchoCommand, string, EchoCommandHandler, EchoCommandValidator>();
// Add your commands and queries
AddQueries(builder.Services);
AddCommands(builder.Services);
// Register your queries
builder.Services.AddQuery<PersonQuery, IQueryable<Person>, PersonQueryHandler>();
// Configure CQRS with Minimal API support
builder.Services.AddSvrntyCqrs(cqrs =>
{
// Enable Minimal API endpoints
cqrs.AddMinimalApi();
});
// Add Swagger (optional)
builder.Services.AddEndpointsApiExplorer();
@@ -151,9 +143,8 @@ if (app.Environment.IsDevelopment())
app.UseSwaggerUI();
}
// Map CQRS endpoints - automatically creates routes for all commands and queries
app.MapSvrntyCommands(); // Creates POST /api/command/{commandName} endpoints
app.MapSvrntyQueries(); // Creates POST/GET /api/query/{queryName} endpoints
// Map all configured CQRS endpoints (automatically creates POST /api/command/* and POST/GET /api/query/*)
app.UseSvrntyCqrs();
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:
```csharp
using Svrnty.CQRS;
using Svrnty.CQRS.FluentValidation;
using Svrnty.CQRS.Grpc;
using Svrnty.CQRS.MinimalApi;
var builder = WebApplication.CreateBuilder(args);
// Register CQRS core services
builder.Services.AddSvrntyCQRS();
builder.Services.AddDefaultCommandDiscovery();
builder.Services.AddDefaultQueryDiscovery();
// Register your commands with validators
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler, AddUserCommandValidator>();
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
// Add your commands and queries
AddQueries(builder.Services);
AddCommands(builder.Services);
// Register your queries
builder.Services.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
// Add gRPC support
builder.Services.AddGrpc();
// Configure CQRS with both gRPC and Minimal API support
builder.Services.AddSvrntyCqrs(cqrs =>
{
// Enable gRPC endpoints with reflection
cqrs.AddGrpc(grpc =>
{
grpc.EnableReflection();
});
// Enable Minimal API endpoints
cqrs.AddMinimalApi();
});
// Add HTTP support with Swagger
builder.Services.AddEndpointsApiExplorer();
@@ -195,14 +199,8 @@ if (app.Environment.IsDevelopment())
app.UseSwaggerUI();
}
// Map gRPC endpoints
app.MapGrpcService<CommandServiceImpl>();
app.MapGrpcService<QueryServiceImpl>();
app.MapGrpcReflectionService();
// Map HTTP endpoints
app.MapSvrntyCommands();
app.MapSvrntyQueries();
// Map all configured CQRS endpoints (both gRPC and HTTP)
app.UseSvrntyCqrs();
app.Run();
```
@@ -214,47 +212,10 @@ app.Run();
- Same commands, queries, and validation logic for both protocols
- 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
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)
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
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.FluentValidation; // Extension methods for validator registration
private void AddCommands(IServiceCollection services)
{
// Command without result - validator included in generics
services.AddCommand<EchoCommand, string, EchoCommandHandler, EchoCommandValidator>();
// Command with result - validator as last generic parameter
builder.Services.AddCommand<EchoCommand, string, EchoCommandHandler, EchoCommandValidator>();
// Command with result - validator as last generic parameter
services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler, CreatePersonCommandValidator>();
}
// Command without result - validator included in generics
builder.Services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler, CreatePersonCommandValidator>();
```
**Benefits:**
@@ -283,13 +240,34 @@ private void AddCommands(IServiceCollection services)
- **Less boilerplate** - No need for separate `AddTransient<IValidator<T>>()` calls
- **Cleaner code** - Clear intent that validation is part of command pipeline
## Without Svrnty.CQRS.FluentValidation
If you prefer not to use the FluentValidation package, you need to register commands and validators separately:
```csharp
using FluentValidation;
using Svrnty.CQRS;
// Register command handler
builder.Services.AddCommand<EchoCommand, string, EchoCommandHandler>();
// Manually register validator
builder.Services.AddTransient<IValidator<EchoCommand>, EchoCommandValidator>();
```
# 2024-2025 Roadmap
| Task | Description | Status |
|----------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------|--------|
| Support .NET 8 | Ensure compatibility with .NET 8. | ✅ |
| Support .NET 10 | Upgrade to .NET 10 with C# 14 language support. | ✅ |
| Support .NET 10 | .NET 10 with C# 14 language support. | ✅ |
| Update FluentValidation | Upgrade FluentValidation to version 11.x for .NET 10 compatibility. | ✅ |
| Add gRPC Support with source generators | Implement gRPC endpoints with source generators and Google Rich Error Model for validation. | ✅ |
| Create a demo project (Svrnty.CQRS.Grpc.Sample) | Develop a comprehensive demo project showcasing gRPC and HTTP endpoints. | ✅ |
| Create a website for the Framework | Develop a website to host comprehensive documentation for the framework. | ⬜️ |
| 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. | ⬜️ |
+122
View File
@@ -0,0 +1,122 @@
# Saga Orchestration Roadmap
## Completed (Phase 1)
- [x] `Svrnty.CQRS.Sagas.Abstractions` - Core interfaces and contracts
- [x] `Svrnty.CQRS.Sagas` - Orchestration engine with fluent builder API
- [x] `Svrnty.CQRS.Sagas.RabbitMQ` - RabbitMQ message transport
---
## Phase 1d: Testing & Sample
### Unit Tests
- [ ] `SagaBuilder` step configuration tests
- [ ] `SagaOrchestrator` execution flow tests
- [ ] `SagaOrchestrator` compensation flow tests
- [ ] `InMemorySagaStateStore` persistence tests
- [ ] `RabbitMqSagaMessageBus` serialization tests
### Integration Tests
- [ ] End-to-end saga execution with RabbitMQ
- [ ] Multi-step saga with compensation scenario
- [ ] Concurrent saga execution tests
- [ ] Connection recovery tests
### Sample Implementation
- [ ] `OrderProcessingSaga` example in WarehouseManagement
- ReserveInventory step
- ProcessPayment step
- CreateShipment step
- Full compensation flow
---
## Phase 2: Persistence
### Svrnty.CQRS.Sagas.EntityFramework
- [ ] `EfCoreSagaStateStore` implementation
- [ ] `SagaState` entity configuration
- [ ] Migration support
- [ ] PostgreSQL/SQL Server compatibility
- [ ] Optimistic concurrency handling
### Configuration
```csharp
cqrs.AddSagas()
.UseEntityFramework<AppDbContext>();
```
---
## Phase 3: Reliability
### Saga Timeout Service
- [ ] `SagaTimeoutHostedService` - background service for stalled sagas
- [ ] Configurable timeout per saga type
- [ ] Automatic compensation trigger on timeout
- [ ] Dead letter handling for failed compensations
### Retry Policies
- [ ] Exponential backoff support
- [ ] Circuit breaker integration
- [ ] Polly integration option
### Idempotency
- [ ] Message deduplication
- [ ] Idempotent step execution
- [ ] Inbox/Outbox pattern support
---
## Phase 4: Observability
### OpenTelemetry Integration
- [ ] Distributed tracing for saga execution
- [ ] Span per saga step
- [ ] Correlation ID propagation
- [ ] Metrics (saga duration, success/failure rates)
### Saga Dashboard (Optional)
- [ ] Web UI for saga monitoring
- [ ] Real-time saga status
- [ ] Manual compensation trigger
- [ ] Saga history and audit log
---
## Phase 5: Flutter Integration
### gRPC Streaming for Saga Status
- [ ] `ISagaStatusStream` service
- [ ] Real-time saga progress updates
- [ ] Step completion notifications
- [ ] Error/compensation notifications
### Flutter Client
- [ ] Dart client for saga status streaming
- [ ] Saga progress widget components
---
## Phase 6: Alternative Transports
### Svrnty.CQRS.Sagas.AzureServiceBus
- [ ] Azure Service Bus message transport
- [ ] Topic/Subscription topology
- [ ] Dead letter queue handling
### Svrnty.CQRS.Sagas.Kafka
- [ ] Kafka message transport
- [ ] Consumer group management
- [ ] Partition key strategies
---
## Future Considerations
- **Event Sourcing**: Saga state as event stream
- **Saga Versioning**: Handle saga definition changes gracefully
- **Saga Composition**: Nested/child sagas
- **Saga Scheduling**: Delayed saga start
- **Multi-tenancy**: Tenant-aware saga execution
@@ -27,6 +27,6 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
</ItemGroup>
</Project>
@@ -0,0 +1,14 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
/// <summary>
/// Marker interface for custom queryable providers that project entities to DTOs.
/// Extends <see cref="IQueryableProvider{TSource}"/> for semantic clarity in registration.
/// </summary>
/// <typeparam name="TSource">The DTO/Item type returned by the queryable.</typeparam>
public interface IQueryableProviderOverride<TSource> : IQueryableProvider<TSource>
{
}
@@ -0,0 +1,26 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using PoweredSoft.Data.Core;
using PoweredSoft.Data.EntityFrameworkCore;
namespace Svrnty.CQRS.DynamicQuery.EntityFramework;
/// <summary>
/// Extensions for configuring DynamicQuery with Entity Framework Core.
/// </summary>
public static class DynamicQueryServicesBuilderExtensions
{
/// <summary>
/// Uses Entity Framework Core for async queryable operations.
/// This replaces the default in-memory implementation with EF Core's async support.
/// </summary>
/// <param name="builder">The DynamicQuery services builder.</param>
/// <returns>The builder for chaining.</returns>
public static DynamicQueryServicesBuilder UseEntityFramework(this DynamicQueryServicesBuilder builder)
{
// Remove in-memory implementation and add EF Core implementation
builder.Services.RemoveAll<IAsyncQueryableService>();
builder.Services.AddPoweredSoftEntityFrameworkCoreDataServices();
return builder;
}
}
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsAotCompatible>false</IsAotCompatible>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<Company>Svrnty</Company>
<Authors>David Lebee, Mathias Beaulieu-Duncan</Authors>
<PackageIcon>icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://git.openharbor.io/svrnty/dotnet-cqrs</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<DebugType>portable</DebugType>
<DebugSymbols>true</DebugSymbols>
<IncludeSymbols>true</IncludeSymbols>
<IncludeSource>true</IncludeSource>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<ItemGroup>
<None Include="..\icon.png" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="PoweredSoft.Data.EntityFrameworkCore" Version="3.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Svrnty.CQRS.DynamicQuery\Svrnty.CQRS.DynamicQuery.csproj" />
</ItemGroup>
</Project>
@@ -23,7 +23,6 @@ public static class EndpointRouteBuilderExtensions
public static IEndpointRouteBuilder MapSvrntyDynamicQueries(this IEndpointRouteBuilder endpoints, string routePrefix = "api/query")
{
var queryDiscovery = endpoints.ServiceProvider.GetRequiredService<IQueryDiscovery>();
var authorizationService = endpoints.ServiceProvider.GetService<IQueryAuthorizationService>();
foreach (var queryMeta in queryDiscovery.GetQueries())
{
@@ -43,14 +42,14 @@ public static class EndpointRouteBuilderExtensions
if (dynamicQueryMeta.ParamsType == null)
{
// DynamicQuery<TSource, TDestination>
MapDynamicQueryPost(endpoints, route, dynamicQueryMeta, authorizationService);
MapDynamicQueryGet(endpoints, route, dynamicQueryMeta, authorizationService);
MapDynamicQueryPost(endpoints, route, dynamicQueryMeta);
MapDynamicQueryGet(endpoints, route, dynamicQueryMeta);
}
else
{
// DynamicQuery<TSource, TDestination, TParams>
MapDynamicQueryWithParamsPost(endpoints, route, dynamicQueryMeta, authorizationService);
MapDynamicQueryWithParamsGet(endpoints, route, dynamicQueryMeta, authorizationService);
MapDynamicQueryWithParamsPost(endpoints, route, dynamicQueryMeta);
MapDynamicQueryWithParamsGet(endpoints, route, dynamicQueryMeta);
}
}
@@ -60,8 +59,7 @@ public static class EndpointRouteBuilderExtensions
private static void MapDynamicQueryPost(
IEndpointRouteBuilder endpoints,
string route,
DynamicQueryMeta dynamicQueryMeta,
IQueryAuthorizationService? authorizationService)
DynamicQueryMeta dynamicQueryMeta)
{
var sourceType = dynamicQueryMeta.SourceType;
var destinationType = dynamicQueryMeta.DestinationType;
@@ -75,7 +73,7 @@ public static class EndpointRouteBuilderExtensions
.GetMethod(nameof(MapDynamicQueryPostTyped), BindingFlags.NonPublic | BindingFlags.Static)!
.MakeGenericMethod(sourceType, destinationType);
var endpoint = (RouteHandlerBuilder)mapPostMethod.Invoke(null, [endpoints, route, queryType, handlerType, authorizationService])!;
var endpoint = (RouteHandlerBuilder)mapPostMethod.Invoke(null, [endpoints, route, queryType, handlerType])!;
endpoint
.WithName($"DynamicQuery_{dynamicQueryMeta.LowerCamelCaseName}_Post")
@@ -91,8 +89,7 @@ public static class EndpointRouteBuilderExtensions
IEndpointRouteBuilder endpoints,
string route,
Type queryType,
Type handlerType,
IQueryAuthorizationService? authorizationService)
Type handlerType)
where TSource : class
where TDestination : class
{
@@ -102,6 +99,7 @@ public static class EndpointRouteBuilderExtensions
IServiceProvider serviceProvider,
CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<IQueryAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(queryType, cancellationToken);
@@ -129,8 +127,7 @@ public static class EndpointRouteBuilderExtensions
private static void MapDynamicQueryGet(
IEndpointRouteBuilder endpoints,
string route,
DynamicQueryMeta dynamicQueryMeta,
IQueryAuthorizationService? authorizationService)
DynamicQueryMeta dynamicQueryMeta)
{
var sourceType = dynamicQueryMeta.SourceType;
var destinationType = dynamicQueryMeta.DestinationType;
@@ -141,6 +138,7 @@ public static class EndpointRouteBuilderExtensions
endpoints.MapGet(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<IQueryAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(queryType, cancellationToken);
@@ -199,8 +197,7 @@ public static class EndpointRouteBuilderExtensions
private static void MapDynamicQueryWithParamsPost(
IEndpointRouteBuilder endpoints,
string route,
DynamicQueryMeta dynamicQueryMeta,
IQueryAuthorizationService? authorizationService)
DynamicQueryMeta dynamicQueryMeta)
{
var sourceType = dynamicQueryMeta.SourceType;
var destinationType = dynamicQueryMeta.DestinationType;
@@ -214,7 +211,7 @@ public static class EndpointRouteBuilderExtensions
.GetMethod(nameof(MapDynamicQueryWithParamsPostTyped), BindingFlags.NonPublic | BindingFlags.Static)!
.MakeGenericMethod(sourceType, destinationType, paramsType);
var endpoint = (RouteHandlerBuilder)mapPostMethod.Invoke(null, [endpoints, route, queryType, handlerType, authorizationService])!;
var endpoint = (RouteHandlerBuilder)mapPostMethod.Invoke(null, [endpoints, route, queryType, handlerType])!;
endpoint
.WithName($"DynamicQuery_{dynamicQueryMeta.LowerCamelCaseName}_WithParams_Post")
@@ -230,8 +227,7 @@ public static class EndpointRouteBuilderExtensions
IEndpointRouteBuilder endpoints,
string route,
Type queryType,
Type handlerType,
IQueryAuthorizationService? authorizationService)
Type handlerType)
where TSource : class
where TDestination : class
where TParams : class
@@ -242,6 +238,7 @@ public static class EndpointRouteBuilderExtensions
IServiceProvider serviceProvider,
CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<IQueryAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(queryType, cancellationToken);
@@ -269,8 +266,7 @@ public static class EndpointRouteBuilderExtensions
private static void MapDynamicQueryWithParamsGet(
IEndpointRouteBuilder endpoints,
string route,
DynamicQueryMeta dynamicQueryMeta,
IQueryAuthorizationService? authorizationService)
DynamicQueryMeta dynamicQueryMeta)
{
var sourceType = dynamicQueryMeta.SourceType;
var destinationType = dynamicQueryMeta.DestinationType;
@@ -282,6 +278,7 @@ public static class EndpointRouteBuilderExtensions
endpoints.MapGet(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<IQueryAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(queryType, cancellationToken);
@@ -35,7 +35,11 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
protected virtual Task<IQueryable<TSource>> GetQueryableAsync(IDynamicQuery query, CancellationToken cancellationToken = default)
{
if (_queryableProviders.Any())
return _queryableProviders.ElementAt(0).GetQueryableAsync(query, cancellationToken);
{
// Use Last() to prefer closed generic registrations (overrides) over open generic (default)
// Registration order: open generic first, closed generic (override) last
return _queryableProviders.Last().GetQueryableAsync(query, cancellationToken);
}
throw new Exception($"You must provide a QueryableProvider<TSource> for {typeof(TSource).Name}");
}
@@ -0,0 +1,19 @@
using Microsoft.Extensions.DependencyInjection;
namespace Svrnty.CQRS.DynamicQuery;
/// <summary>
/// Builder for configuring DynamicQuery services.
/// </summary>
public class DynamicQueryServicesBuilder
{
/// <summary>
/// The service collection being configured.
/// </summary>
public IServiceCollection Services { get; }
internal DynamicQueryServicesBuilder(IServiceCollection services)
{
Services = services;
}
}
@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;
using PoweredSoft.Data.Core;
namespace Svrnty.CQRS.DynamicQuery;
/// <summary>
/// In-memory implementation of IAsyncQueryableService.
/// For EF Core projects, use AddDynamicQueryServices().UseEntityFramework() instead.
/// </summary>
public class InMemoryAsyncQueryableService : IAsyncQueryableService
{
public IEnumerable<IAsyncQueryableHandlerService> Handlers { get; } = Array.Empty<IAsyncQueryableHandlerService>();
public Task<List<TSource>> ToListAsync<TSource>(IQueryable<TSource> queryable, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.ToList());
}
public Task<TSource?> FirstOrDefaultAsync<TSource>(IQueryable<TSource> queryable, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.FirstOrDefault());
}
public Task<TSource?> FirstOrDefaultAsync<TSource>(IQueryable<TSource> queryable, Expression<Func<TSource, bool>> predicate, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.FirstOrDefault(predicate));
}
public Task<TSource?> LastOrDefaultAsync<TSource>(IQueryable<TSource> queryable, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.LastOrDefault());
}
public Task<TSource?> LastOrDefaultAsync<TSource>(IQueryable<TSource> queryable, Expression<Func<TSource, bool>> predicate, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.LastOrDefault(predicate));
}
public Task<bool> AnyAsync<TSource>(IQueryable<TSource> queryable, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.Any());
}
public Task<bool> AnyAsync<TSource>(IQueryable<TSource> queryable, Expression<Func<TSource, bool>> predicate, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.Any(predicate));
}
public Task<bool> AllAsync<TSource>(IQueryable<TSource> queryable, Expression<Func<TSource, bool>> predicate, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.All(predicate));
}
public Task<int> CountAsync<TSource>(IQueryable<TSource> queryable, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.Count());
}
public Task<long> LongCountAsync<TSource>(IQueryable<TSource> queryable, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.LongCount());
}
public Task<TSource?> SingleOrDefaultAsync<TSource>(IQueryable<TSource> queryable, Expression<Func<TSource, bool>> predicate, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.SingleOrDefault(predicate));
}
public IAsyncQueryableHandlerService? GetAsyncQueryableHandler<TSource>(IQueryable<TSource> queryable)
{
return null;
}
}
@@ -1,16 +1,31 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using PoweredSoft.Data.Core;
using PoweredSoft.DynamicQuery;
using PoweredSoft.DynamicQuery.Core;
using Svrnty.CQRS.Abstractions;
using Svrnty.CQRS.Abstractions.Discovery;
using Svrnty.CQRS.DynamicQuery.Abstractions;
using Svrnty.CQRS.DynamicQuery.Discover;
using PoweredSoft.DynamicQuery.Core;
namespace Svrnty.CQRS.DynamicQuery;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers core DynamicQuery services with in-memory async queryable.
/// For EF Core projects, chain with .UseEntityFramework().
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>A builder for further configuration.</returns>
public static DynamicQueryServicesBuilder AddDynamicQueryServices(this IServiceCollection services)
{
services.TryAddTransient<IAsyncQueryableService, InMemoryAsyncQueryableService>();
services.TryAddTransient<IQueryHandlerAsync, QueryHandlerAsync>();
return new DynamicQueryServicesBuilder(services);
}
public static IServiceCollection AddDynamicQuery<TSourceAndDestination>(this IServiceCollection services, string name = null)
where TSourceAndDestination : class
=> AddDynamicQuery<TSourceAndDestination, TSourceAndDestination>(services, name: name);
@@ -55,6 +70,22 @@ public static class ServiceCollectionExtensions
return services;
}
/// <summary>
/// Registers a custom queryable provider override for the specified source type.
/// Use this for DTOs that require custom projection from entities.
/// </summary>
/// <typeparam name="TSource">The DTO/Item type returned by the queryable.</typeparam>
/// <typeparam name="TProvider">The provider implementation type.</typeparam>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddQueryableProviderOverride<TSource, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TProvider>(this IServiceCollection services)
where TSource : class
where TProvider : class, IQueryableProviderOverride<TSource>
{
services.AddTransient<IQueryableProvider<TSource>, TProvider>();
return services;
}
public static IServiceCollection AddDynamicQueryWithParams<TSourceAndDestination, TParams>(this IServiceCollection services, string name = null)
where TSourceAndDestination : class
where TParams : class
@@ -0,0 +1,17 @@
namespace Svrnty.CQRS.Events.Abstractions;
/// <summary>
/// Marker interface for domain events.
/// </summary>
public interface IDomainEvent
{
/// <summary>
/// Unique identifier for this event instance.
/// </summary>
Guid EventId { get; }
/// <summary>
/// Timestamp when the event occurred.
/// </summary>
DateTime OccurredAt { get; }
}
@@ -0,0 +1,16 @@
namespace Svrnty.CQRS.Events.Abstractions;
/// <summary>
/// Interface for publishing domain events to external systems.
/// </summary>
public interface IDomainEventPublisher
{
/// <summary>
/// Publishes a domain event.
/// </summary>
/// <typeparam name="TEvent">The type of event to publish.</typeparam>
/// <param name="event">The event to publish.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task PublishAsync<TEvent>(TEvent @event, CancellationToken cancellationToken = default)
where TEvent : IDomainEvent;
}
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsAotCompatible>true</IsAotCompatible>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Company>Svrnty</Company>
<Authors>David Lebee, Mathias Beaulieu-Duncan</Authors>
<PackageIcon>icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://git.openharbor.io/svrnty/dotnet-cqrs</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<DebugType>portable</DebugType>
<DebugSymbols>true</DebugSymbols>
<IncludeSymbols>true</IncludeSymbols>
<IncludeSource>true</IncludeSource>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<ItemGroup>
<None Include="..\icon.png" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
</ItemGroup>
</Project>
@@ -0,0 +1,163 @@
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using RabbitMQ.Client;
using Svrnty.CQRS.Events.Abstractions;
namespace Svrnty.CQRS.Events.RabbitMQ;
/// <summary>
/// RabbitMQ implementation of the domain event publisher.
/// </summary>
public class RabbitMqDomainEventPublisher : IDomainEventPublisher, IAsyncDisposable
{
private readonly RabbitMqEventOptions _options;
private readonly ILogger<RabbitMqDomainEventPublisher> _logger;
private IConnection? _connection;
private IChannel? _channel;
private readonly SemaphoreSlim _connectionLock = new(1, 1);
private bool _disposed;
/// <summary>
/// Creates a new RabbitMQ domain event publisher.
/// </summary>
public RabbitMqDomainEventPublisher(
IOptions<RabbitMqEventOptions> options,
ILogger<RabbitMqDomainEventPublisher> logger)
{
_options = options.Value;
_logger = logger;
}
/// <inheritdoc />
public async Task PublishAsync<TEvent>(TEvent @event, CancellationToken cancellationToken = default)
where TEvent : IDomainEvent
{
await EnsureConnectionAsync(cancellationToken);
var eventTypeName = typeof(TEvent).Name;
var routingKey = GetRoutingKey(eventTypeName);
var body = JsonSerializer.SerializeToUtf8Bytes(@event);
var properties = new BasicProperties
{
MessageId = @event.EventId.ToString(),
ContentType = "application/json",
DeliveryMode = _options.Durable ? DeliveryModes.Persistent : DeliveryModes.Transient,
Timestamp = new AmqpTimestamp(new DateTimeOffset(@event.OccurredAt).ToUnixTimeSeconds()),
Headers = new Dictionary<string, object?>
{
["event-type"] = eventTypeName,
["event-id"] = @event.EventId.ToString()
}
};
await _channel!.BasicPublishAsync(
exchange: _options.Exchange,
routingKey: routingKey,
mandatory: false,
basicProperties: properties,
body: body,
cancellationToken: cancellationToken);
_logger.LogDebug(
"Published domain event {EventType} with ID {EventId} to routing key {RoutingKey}",
eventTypeName, @event.EventId, routingKey);
}
private static string GetRoutingKey(string eventTypeName)
{
// Convert PascalCase to dot-notation, e.g., "InventoryMovementEvent" -> "events.inventory.movement"
var name = eventTypeName.Replace("Event", "");
var words = new List<string>();
var currentWord = new StringBuilder();
foreach (var c in name)
{
if (char.IsUpper(c) && currentWord.Length > 0)
{
words.Add(currentWord.ToString().ToLowerInvariant());
currentWord.Clear();
}
currentWord.Append(c);
}
if (currentWord.Length > 0)
{
words.Add(currentWord.ToString().ToLowerInvariant());
}
return "events." + string.Join(".", words);
}
private async Task EnsureConnectionAsync(CancellationToken cancellationToken)
{
if (_connection?.IsOpen == true && _channel?.IsOpen == true)
{
return;
}
await _connectionLock.WaitAsync(cancellationToken);
try
{
if (_connection?.IsOpen == true && _channel?.IsOpen == true)
{
return;
}
var factory = new ConnectionFactory
{
HostName = _options.HostName,
Port = _options.Port,
UserName = _options.UserName,
Password = _options.Password,
VirtualHost = _options.VirtualHost
};
_connection = await factory.CreateConnectionAsync(cancellationToken);
_channel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken);
// Declare topic exchange for domain events
await _channel.ExchangeDeclareAsync(
exchange: _options.Exchange,
type: ExchangeType.Topic,
durable: _options.Durable,
autoDelete: false,
cancellationToken: cancellationToken);
_logger.LogInformation(
"Connected to RabbitMQ at {Host}:{Port}, exchange: {Exchange}",
_options.HostName, _options.Port, _options.Exchange);
}
finally
{
_connectionLock.Release();
}
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
if (_channel?.IsOpen == true)
{
await _channel.CloseAsync();
}
_channel?.Dispose();
if (_connection?.IsOpen == true)
{
await _connection.CloseAsync();
}
_connection?.Dispose();
_connectionLock.Dispose();
}
}
@@ -0,0 +1,42 @@
namespace Svrnty.CQRS.Events.RabbitMQ;
/// <summary>
/// Configuration options for RabbitMQ domain event publishing.
/// </summary>
public class RabbitMqEventOptions
{
/// <summary>
/// RabbitMQ host name. Default: localhost
/// </summary>
public string HostName { get; set; } = "localhost";
/// <summary>
/// RabbitMQ port. Default: 5672
/// </summary>
public int Port { get; set; } = 5672;
/// <summary>
/// RabbitMQ username. Default: guest
/// </summary>
public string UserName { get; set; } = "guest";
/// <summary>
/// RabbitMQ password. Default: guest
/// </summary>
public string Password { get; set; } = "guest";
/// <summary>
/// RabbitMQ virtual host. Default: /
/// </summary>
public string VirtualHost { get; set; } = "/";
/// <summary>
/// Exchange name for domain events. Default: domain.events
/// </summary>
public string Exchange { get; set; } = "domain.events";
/// <summary>
/// Whether to use durable exchanges. Default: true
/// </summary>
public bool Durable { get; set; } = true;
}
@@ -0,0 +1,30 @@
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Events.Abstractions;
namespace Svrnty.CQRS.Events.RabbitMQ;
/// <summary>
/// Extension methods for registering RabbitMQ domain event publishing.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds RabbitMQ domain event publishing to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configure">Optional configuration action for RabbitMQ options.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddRabbitMqDomainEvents(
this IServiceCollection services,
Action<RabbitMqEventOptions>? configure = null)
{
if (configure != null)
{
services.Configure(configure);
}
services.AddSingleton<IDomainEventPublisher, RabbitMqDomainEventPublisher>();
return services;
}
}
@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsAotCompatible>false</IsAotCompatible>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Company>Svrnty</Company>
<Authors>David Lebee, Mathias Beaulieu-Duncan</Authors>
<PackageIcon>icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://git.openharbor.io/svrnty/dotnet-cqrs</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<DebugType>portable</DebugType>
<DebugSymbols>true</DebugSymbols>
<IncludeSymbols>true</IncludeSymbols>
<IncludeSource>true</IncludeSource>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<ItemGroup>
<None Include="..\icon.png" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Svrnty.CQRS.Events.Abstractions\Svrnty.CQRS.Events.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="RabbitMQ.Client" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
</ItemGroup>
</Project>
File diff suppressed because it is too large Load Diff
@@ -49,6 +49,12 @@ namespace Svrnty.CQRS.Grpc.Generators.Helpers
isRepeated = false;
isOptional = false;
// Handle byte[] as bytes proto type (NOT repeated uint32)
if (csharpType == "System.Byte[]" || csharpType == "byte[]" || csharpType == "Byte[]")
{
return "bytes";
}
// Handle arrays
if (csharpType.EndsWith("[]"))
{
@@ -35,6 +35,26 @@ namespace Svrnty.CQRS.Grpc.Generators.Models
public string FullyQualifiedType { get; set; }
public string ProtoType { get; set; }
public int FieldNumber { get; set; }
public bool IsComplexType { get; set; }
public List<PropertyInfo> NestedProperties { get; set; }
// Type conversion metadata
public bool IsEnum { get; set; }
public bool IsList { get; set; }
public bool IsNullable { get; set; }
public bool IsDecimal { get; set; }
public bool IsDateTime { get; set; }
public bool IsDateTimeOffset { get; set; }
public bool IsGuid { get; set; }
public bool IsJsonElement { get; set; }
public bool IsBinaryType { get; set; } // Stream, byte[], MemoryStream
public bool IsStream { get; set; } // Specifically Stream types (not byte[])
public bool IsReadOnly { get; set; } // Read-only/computed properties should be skipped
public bool IsValueTypeCollection { get; set; } // Value types that implement IList<T> (like NpgsqlPolygon)
public string? ElementType { get; set; }
public bool IsElementComplexType { get; set; }
public bool IsElementGuid { get; set; }
public List<PropertyInfo>? ElementNestedProperties { get; set; }
public PropertyInfo()
{
@@ -42,6 +62,22 @@ namespace Svrnty.CQRS.Grpc.Generators.Models
Type = string.Empty;
FullyQualifiedType = string.Empty;
ProtoType = string.Empty;
IsComplexType = false;
NestedProperties = new List<PropertyInfo>();
IsEnum = false;
IsList = false;
IsNullable = false;
IsDecimal = false;
IsDateTime = false;
IsDateTimeOffset = false;
IsGuid = false;
IsJsonElement = false;
IsBinaryType = false;
IsStream = false;
IsReadOnly = false;
IsValueTypeCollection = false;
IsElementComplexType = false;
IsElementGuid = false;
}
}
}
@@ -0,0 +1,50 @@
using System.Collections.Generic;
namespace Svrnty.CQRS.Grpc.Generators.Models
{
/// <summary>
/// Represents a discovered streaming notification type for proto/gRPC generation.
/// </summary>
public class NotificationInfo
{
/// <summary>
/// The notification type name (e.g., "InventoryChangeNotification").
/// </summary>
public string Name { get; set; }
/// <summary>
/// The fully qualified type name including namespace.
/// </summary>
public string FullyQualifiedName { get; set; }
/// <summary>
/// The namespace of the notification type.
/// </summary>
public string Namespace { get; set; }
/// <summary>
/// The property name used as the subscription key (from [StreamingNotification] attribute).
/// </summary>
public string SubscriptionKeyProperty { get; set; }
/// <summary>
/// The subscription key property info.
/// </summary>
public PropertyInfo SubscriptionKeyInfo { get; set; }
/// <summary>
/// All properties of the notification type.
/// </summary>
public List<PropertyInfo> Properties { get; set; }
public NotificationInfo()
{
Name = string.Empty;
FullyQualifiedName = string.Empty;
Namespace = string.Empty;
SubscriptionKeyProperty = string.Empty;
SubscriptionKeyInfo = new PropertyInfo();
Properties = new List<PropertyInfo>();
}
}
}
+453 -47
View File
@@ -2,29 +2,90 @@ using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Svrnty.CQRS.Grpc.Generators.Models;
namespace Svrnty.CQRS.Grpc.Generators;
/// <summary>
/// Generates Protocol Buffer (.proto) files from C# Command and Query types
/// Generates Protocol Buffer (.proto) files from C# Command, Query, and Notification types
/// </summary>
internal class ProtoFileGenerator
{
private readonly Compilation _compilation;
private readonly HashSet<string> _requiredImports = new HashSet<string>();
private readonly HashSet<string> _generatedMessages = new HashSet<string>();
private readonly HashSet<string> _generatedEnums = new HashSet<string>();
private readonly List<INamedTypeSymbol> _pendingEnums = new List<INamedTypeSymbol>();
private readonly StringBuilder _messagesBuilder = new StringBuilder();
private readonly StringBuilder _enumsBuilder = new StringBuilder();
private List<INamedTypeSymbol>? _allTypesCache;
/// <summary>
/// Gets the discovered notifications after Generate() is called.
/// </summary>
public List<NotificationInfo> DiscoveredNotifications { get; private set; } = new List<NotificationInfo>();
public ProtoFileGenerator(Compilation compilation)
{
_compilation = compilation;
}
/// <summary>
/// Gets all types from the compilation and all referenced assemblies
/// </summary>
private IEnumerable<INamedTypeSymbol> GetAllTypes()
{
if (_allTypesCache != null)
return _allTypesCache;
_allTypesCache = new List<INamedTypeSymbol>();
// Get types from the current assembly
CollectTypesFromNamespace(_compilation.Assembly.GlobalNamespace, _allTypesCache);
// Get types from all referenced assemblies
foreach (var reference in _compilation.References)
{
var assemblySymbol = _compilation.GetAssemblyOrModuleSymbol(reference) as IAssemblySymbol;
if (assemblySymbol != null)
{
CollectTypesFromNamespace(assemblySymbol.GlobalNamespace, _allTypesCache);
}
}
return _allTypesCache;
}
private static void CollectTypesFromNamespace(INamespaceSymbol ns, List<INamedTypeSymbol> types)
{
foreach (var type in ns.GetTypeMembers())
{
types.Add(type);
CollectNestedTypes(type, types);
}
foreach (var nestedNs in ns.GetNamespaceMembers())
{
CollectTypesFromNamespace(nestedNs, types);
}
}
private static void CollectNestedTypes(INamedTypeSymbol type, List<INamedTypeSymbol> types)
{
foreach (var nestedType in type.GetTypeMembers())
{
types.Add(nestedType);
CollectNestedTypes(nestedType, types);
}
}
public string Generate(string packageName, string csharpNamespace)
{
var commands = DiscoverCommands();
var queries = DiscoverQueries();
var dynamicQueries = DiscoverDynamicQueries();
var notifications = DiscoverNotifications();
DiscoveredNotifications = notifications;
var sb = new StringBuilder();
@@ -98,6 +159,24 @@ internal class ProtoFileGenerator
sb.AppendLine();
}
// Notification Service (server streaming)
if (notifications.Any())
{
sb.AppendLine("// NotificationService for real-time streaming notifications");
sb.AppendLine("service NotificationService {");
foreach (var notification in notifications)
{
var methodName = $"SubscribeTo{notification.Name}";
var requestType = $"SubscribeTo{notification.Name}Request";
sb.AppendLine($" // Subscribe to {notification.Name} notifications");
sb.AppendLine($" rpc {methodName} ({requestType}) returns (stream {notification.Name});");
sb.AppendLine();
}
sb.AppendLine("}");
sb.AppendLine();
}
// Generate messages for commands
foreach (var command in commands)
{
@@ -118,7 +197,17 @@ internal class ProtoFileGenerator
GenerateDynamicQueryMessages(dq);
}
// Append all generated messages
// Generate messages for notifications
foreach (var notification in notifications)
{
GenerateNotificationMessages(notification);
}
// Generate any pending enum definitions
GeneratePendingEnums();
// Append all generated enums first, then messages
sb.Append(_enumsBuilder);
sb.Append(_messagesBuilder);
// Insert imports if any were needed
@@ -138,24 +227,78 @@ internal class ProtoFileGenerator
private List<INamedTypeSymbol> DiscoverCommands()
{
return _compilation.GetSymbolsWithName(
name => name.EndsWith("Command"),
SymbolFilter.Type)
.OfType<INamedTypeSymbol>()
.Where(t => !HasGrpcIgnoreAttribute(t))
.Where(t => t.TypeKind == TypeKind.Class || t.TypeKind == TypeKind.Struct)
.ToList();
// First, find all command handlers to know which commands are actually registered
var commandHandlerInterface = _compilation.GetTypeByMetadataName("Svrnty.CQRS.Abstractions.ICommandHandler`1");
var commandHandlerWithResultInterface = _compilation.GetTypeByMetadataName("Svrnty.CQRS.Abstractions.ICommandHandler`2");
if (commandHandlerInterface == null && commandHandlerWithResultInterface == null)
return new List<INamedTypeSymbol>();
var registeredCommands = new HashSet<INamedTypeSymbol>(SymbolEqualityComparer.Default);
foreach (var type in GetAllTypes())
{
if (type.IsAbstract || type.IsStatic)
continue;
foreach (var iface in type.AllInterfaces)
{
if (iface.IsGenericType)
{
if ((commandHandlerInterface != null && SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, commandHandlerInterface)) ||
(commandHandlerWithResultInterface != null && SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, commandHandlerWithResultInterface)))
{
var commandType = iface.TypeArguments[0] as INamedTypeSymbol;
if (commandType != null && !HasGrpcIgnoreAttribute(commandType))
{
registeredCommands.Add(commandType);
}
}
}
}
}
return registeredCommands.ToList();
}
private List<INamedTypeSymbol> DiscoverQueries()
{
return _compilation.GetSymbolsWithName(
name => name.EndsWith("Query"),
SymbolFilter.Type)
.OfType<INamedTypeSymbol>()
.Where(t => !HasGrpcIgnoreAttribute(t))
.Where(t => t.TypeKind == TypeKind.Class || t.TypeKind == TypeKind.Struct)
.ToList();
// First, find all query handlers to know which queries are actually registered
var queryHandlerInterface = _compilation.GetTypeByMetadataName("Svrnty.CQRS.Abstractions.IQueryHandler`2");
var dynamicQueryInterface2 = _compilation.GetTypeByMetadataName("Svrnty.CQRS.DynamicQuery.Abstractions.IDynamicQuery`2");
var dynamicQueryInterface3 = _compilation.GetTypeByMetadataName("Svrnty.CQRS.DynamicQuery.Abstractions.IDynamicQuery`3");
if (queryHandlerInterface == null)
return new List<INamedTypeSymbol>();
var registeredQueries = new HashSet<INamedTypeSymbol>(SymbolEqualityComparer.Default);
foreach (var type in GetAllTypes())
{
if (type.IsAbstract || type.IsStatic)
continue;
foreach (var iface in type.AllInterfaces)
{
if (iface.IsGenericType && SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, queryHandlerInterface))
{
var queryType = iface.TypeArguments[0] as INamedTypeSymbol;
if (queryType != null && !HasGrpcIgnoreAttribute(queryType))
{
// Skip dynamic queries - they're handled separately
if (queryType.IsGenericType &&
((dynamicQueryInterface2 != null && SymbolEqualityComparer.Default.Equals(queryType.OriginalDefinition, dynamicQueryInterface2)) ||
(dynamicQueryInterface3 != null && SymbolEqualityComparer.Default.Equals(queryType.OriginalDefinition, dynamicQueryInterface3))))
{
continue;
}
registeredQueries.Add(queryType);
}
}
}
}
return registeredQueries.ToList();
}
private bool HasGrpcIgnoreAttribute(INamedTypeSymbol type)
@@ -177,9 +320,14 @@ internal class ProtoFileGenerator
var properties = type.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public)
.Where(p => p.DeclaredAccessibility == Accessibility.Public &&
!p.IsIndexer &&
!ProtoFileTypeMapper.IsCollectionInternalProperty(p.Name))
.ToList();
// Collect nested complex types to generate after closing this message
var nestedComplexTypes = new List<INamedTypeSymbol>();
int fieldNumber = 1;
foreach (var prop in properties)
{
@@ -199,10 +347,19 @@ internal class ProtoFileGenerator
var fieldName = ProtoFileTypeMapper.ToSnakeCase(prop.Name);
_messagesBuilder.AppendLine($" {protoType} {fieldName} = {fieldNumber};");
// If this is a complex type, generate its message too
if (IsComplexType(prop.Type))
// Track enums for later generation
var enumType = ProtoFileTypeMapper.GetEnumType(prop.Type);
if (enumType != null)
{
GenerateComplexTypeMessage(prop.Type as INamedTypeSymbol);
TrackEnumType(enumType);
}
// Collect complex types to generate after this message is closed
// Use GetElementOrUnderlyingType to extract element type from collections
var underlyingType = ProtoFileTypeMapper.GetElementOrUnderlyingType(prop.Type);
if (IsComplexType(underlyingType) && underlyingType is INamedTypeSymbol namedType)
{
nestedComplexTypes.Add(namedType);
}
fieldNumber++;
@@ -210,6 +367,12 @@ internal class ProtoFileGenerator
_messagesBuilder.AppendLine("}");
_messagesBuilder.AppendLine();
// Now generate nested complex type messages
foreach (var nestedType in nestedComplexTypes)
{
GenerateComplexTypeMessage(nestedType);
}
}
private void GenerateResponseMessage(INamedTypeSymbol type)
@@ -262,40 +425,83 @@ internal class ProtoFileGenerator
_messagesBuilder.AppendLine($"// {type.Name} entity");
_messagesBuilder.AppendLine($"message {type.Name} {{");
var properties = type.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public)
.ToList();
// Collect nested complex types to generate after closing this message
var nestedComplexTypes = new List<INamedTypeSymbol>();
int fieldNumber = 1;
foreach (var prop in properties)
// Check if this type is a collection (implements IList<T>, ICollection<T>, etc.)
var collectionElementType = ProtoFileTypeMapper.GetCollectionElementTypeByInterface(type);
if (collectionElementType != null)
{
if (ProtoFileTypeMapper.IsUnsupportedType(prop.Type))
{
_messagesBuilder.AppendLine($" // Skipped: {prop.Name} - unsupported type {prop.Type.Name}");
continue;
}
var protoType = ProtoFileTypeMapper.MapType(prop.Type, out var needsImport, out var importPath);
// This type is a collection - generate a single repeated field for items
var protoElementType = ProtoFileTypeMapper.MapType(collectionElementType, out var needsImport, out var importPath);
if (needsImport && importPath != null)
{
_requiredImports.Add(importPath);
}
var fieldName = ProtoFileTypeMapper.ToSnakeCase(prop.Name);
_messagesBuilder.AppendLine($" {protoType} {fieldName} = {fieldNumber};");
_messagesBuilder.AppendLine($" repeated {protoElementType} items = 1;");
// Recursively generate nested complex types
if (IsComplexType(prop.Type))
// Track the element type for nested generation
if (IsComplexType(collectionElementType) && collectionElementType is INamedTypeSymbol elementNamedType)
{
GenerateComplexTypeMessage(prop.Type as INamedTypeSymbol);
nestedComplexTypes.Add(elementNamedType);
}
}
else
{
// Not a collection - generate properties as usual
var properties = type.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public &&
!p.IsIndexer &&
!ProtoFileTypeMapper.IsCollectionInternalProperty(p.Name))
.ToList();
fieldNumber++;
int fieldNumber = 1;
foreach (var prop in properties)
{
if (ProtoFileTypeMapper.IsUnsupportedType(prop.Type))
{
_messagesBuilder.AppendLine($" // Skipped: {prop.Name} - unsupported type {prop.Type.Name}");
continue;
}
var protoType = ProtoFileTypeMapper.MapType(prop.Type, out var needsImport, out var importPath);
if (needsImport && importPath != null)
{
_requiredImports.Add(importPath);
}
var fieldName = ProtoFileTypeMapper.ToSnakeCase(prop.Name);
_messagesBuilder.AppendLine($" {protoType} {fieldName} = {fieldNumber};");
// Track enums for later generation
var enumType = ProtoFileTypeMapper.GetEnumType(prop.Type);
if (enumType != null)
{
TrackEnumType(enumType);
}
// Collect complex types to generate after this message is closed
// Use GetElementOrUnderlyingType to extract element type from collections
var underlyingType = ProtoFileTypeMapper.GetElementOrUnderlyingType(prop.Type);
if (IsComplexType(underlyingType) && underlyingType is INamedTypeSymbol namedType)
{
nestedComplexTypes.Add(namedType);
}
fieldNumber++;
}
}
_messagesBuilder.AppendLine("}");
_messagesBuilder.AppendLine();
// Now generate nested complex type messages
foreach (var nestedType in nestedComplexTypes)
{
GenerateComplexTypeMessage(nestedType);
}
}
private ITypeSymbol? GetResultType(INamedTypeSymbol commandOrQueryType)
@@ -305,11 +511,8 @@ internal class ProtoFileGenerator
? "ICommandHandler"
: "IQueryHandler";
// Find all types in the compilation
var allTypes = _compilation.GetSymbolsWithName(_ => true, SymbolFilter.Type)
.OfType<INamedTypeSymbol>();
foreach (var type in allTypes)
// Find all types in the compilation and referenced assemblies
foreach (var type in GetAllTypes())
{
// Check if this type implements the handler interface
foreach (var @interface in type.AllInterfaces)
@@ -372,10 +575,8 @@ internal class ProtoFileGenerator
return new List<INamedTypeSymbol>();
var dynamicQueryTypes = new List<INamedTypeSymbol>();
var allTypes = _compilation.GetSymbolsWithName(_ => true, SymbolFilter.Type)
.OfType<INamedTypeSymbol>();
foreach (var type in allTypes)
foreach (var type in GetAllTypes())
{
if (type.IsAbstract || type.IsStatic)
continue;
@@ -471,4 +672,209 @@ internal class ProtoFileGenerator
return word + "es";
return word + "s";
}
/// <summary>
/// Tracks an enum type for later generation
/// </summary>
private void TrackEnumType(INamedTypeSymbol enumType)
{
if (!_generatedEnums.Contains(enumType.Name) && !_pendingEnums.Any(e => e.Name == enumType.Name))
{
_pendingEnums.Add(enumType);
}
}
/// <summary>
/// Generates all pending enum definitions
/// </summary>
private void GeneratePendingEnums()
{
foreach (var enumType in _pendingEnums)
{
if (_generatedEnums.Contains(enumType.Name))
continue;
_generatedEnums.Add(enumType.Name);
_enumsBuilder.AppendLine($"// {enumType.Name} enum");
_enumsBuilder.AppendLine($"enum {enumType.Name} {{");
// Get all enum members
var members = enumType.GetMembers()
.OfType<IFieldSymbol>()
.Where(f => f.HasConstantValue)
.ToList();
foreach (var member in members)
{
var protoFieldName = $"{ProtoFileTypeMapper.ToSnakeCase(enumType.Name).ToUpperInvariant()}_{ProtoFileTypeMapper.ToSnakeCase(member.Name).ToUpperInvariant()}";
var value = member.ConstantValue;
_enumsBuilder.AppendLine($" {protoFieldName} = {value};");
}
_enumsBuilder.AppendLine("}");
_enumsBuilder.AppendLine();
}
}
/// <summary>
/// Discovers types marked with [StreamingNotification] attribute
/// </summary>
private List<NotificationInfo> DiscoverNotifications()
{
var streamingNotificationAttribute = _compilation.GetTypeByMetadataName(
"Svrnty.CQRS.Notifications.Abstractions.StreamingNotificationAttribute");
if (streamingNotificationAttribute == null)
return new List<NotificationInfo>();
var notifications = new List<NotificationInfo>();
foreach (var type in GetAllTypes())
{
if (type.IsAbstract || type.IsStatic)
continue;
var attr = type.GetAttributes()
.FirstOrDefault(a => SymbolEqualityComparer.Default.Equals(
a.AttributeClass, streamingNotificationAttribute));
if (attr == null)
continue;
// Extract SubscriptionKey from attribute
var subscriptionKeyArg = attr.NamedArguments
.FirstOrDefault(a => a.Key == "SubscriptionKey");
var subscriptionKeyProp = subscriptionKeyArg.Value.Value as string;
if (string.IsNullOrEmpty(subscriptionKeyProp))
continue;
// Get all properties of the notification type
var properties = ExtractNotificationProperties(type);
// Find the subscription key property info
var keyPropInfo = properties.FirstOrDefault(p => p.Name == subscriptionKeyProp);
if (keyPropInfo == null)
continue;
notifications.Add(new NotificationInfo
{
Name = type.Name,
FullyQualifiedName = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
.Replace("global::", ""),
Namespace = type.ContainingNamespace?.ToDisplayString() ?? "",
SubscriptionKeyProperty = subscriptionKeyProp!, // Already validated as non-null above
SubscriptionKeyInfo = keyPropInfo,
Properties = properties
});
}
return notifications;
}
/// <summary>
/// Extracts property information from a notification type
/// </summary>
private List<Models.PropertyInfo> ExtractNotificationProperties(INamedTypeSymbol type)
{
var properties = new List<Models.PropertyInfo>();
int fieldNumber = 1;
foreach (var prop in type.GetMembers().OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public &&
!p.IsIndexer &&
!ProtoFileTypeMapper.IsCollectionInternalProperty(p.Name)))
{
if (ProtoFileTypeMapper.IsUnsupportedType(prop.Type))
continue;
var protoType = ProtoFileTypeMapper.MapType(prop.Type, out _, out _);
var enumType = ProtoFileTypeMapper.GetEnumType(prop.Type);
properties.Add(new Models.PropertyInfo
{
Name = prop.Name,
Type = prop.Type.Name,
FullyQualifiedType = prop.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
.Replace("global::", ""),
ProtoType = protoType,
FieldNumber = fieldNumber++,
IsEnum = enumType != null,
IsDecimal = prop.Type.SpecialType == SpecialType.System_Decimal ||
prop.Type.ToDisplayString().Contains("decimal"),
IsDateTime = prop.Type.ToDisplayString().Contains("DateTime"),
IsNullable = prop.Type.NullableAnnotation == NullableAnnotation.Annotated ||
(prop.Type is INamedTypeSymbol namedType &&
namedType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T)
});
if (enumType != null)
{
TrackEnumType(enumType);
}
}
return properties;
}
/// <summary>
/// Generates proto messages for a notification type
/// </summary>
private void GenerateNotificationMessages(NotificationInfo notification)
{
// Generate subscription request message (contains only the subscription key)
var requestMessageName = $"SubscribeTo{notification.Name}Request";
if (!_generatedMessages.Contains(requestMessageName))
{
_generatedMessages.Add(requestMessageName);
_messagesBuilder.AppendLine($"// Subscription request for {notification.Name}");
_messagesBuilder.AppendLine($"message {requestMessageName} {{");
_messagesBuilder.AppendLine($" {notification.SubscriptionKeyInfo.ProtoType} {ProtoFileTypeMapper.ToSnakeCase(notification.SubscriptionKeyProperty)} = 1;");
_messagesBuilder.AppendLine("}");
_messagesBuilder.AppendLine();
}
// Generate the notification message itself
if (!_generatedMessages.Contains(notification.Name))
{
_generatedMessages.Add(notification.Name);
_messagesBuilder.AppendLine($"// {notification.Name} streaming notification");
_messagesBuilder.AppendLine($"message {notification.Name} {{");
foreach (var prop in notification.Properties)
{
var typeSymbol = _compilation.GetTypeByMetadataName(prop.FullyQualifiedType) ??
GetTypeFromName(prop.FullyQualifiedType);
if (typeSymbol != null)
{
ProtoFileTypeMapper.MapType(typeSymbol, out var needsImport, out var importPath);
if (needsImport && importPath != null)
{
_requiredImports.Add(importPath);
}
}
var fieldName = ProtoFileTypeMapper.ToSnakeCase(prop.Name);
_messagesBuilder.AppendLine($" {prop.ProtoType} {fieldName} = {prop.FieldNumber};");
}
_messagesBuilder.AppendLine("}");
_messagesBuilder.AppendLine();
}
}
/// <summary>
/// Gets a type symbol from a type name by searching all types
/// </summary>
private ITypeSymbol? GetTypeFromName(string fullTypeName)
{
// Try to find the type in all types
return GetAllTypes().FirstOrDefault(t =>
t.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat).Replace("global::", "") == fullTypeName ||
t.ToDisplayString() == fullTypeName);
}
}
@@ -20,24 +20,25 @@ public class ProtoFileSourceGenerator : IIncrementalGenerator
// Generate a placeholder - the actual proto will be generated in the source output
});
// Collect all command and query types
var commandsAndQueries = context.SyntaxProvider
// Collect type declarations to trigger generation
// We use any type declaration as a trigger since ProtoFileGenerator scans all assemblies
var typeDeclarations = context.SyntaxProvider
.CreateSyntaxProvider(
predicate: static (s, _) => IsCommandOrQuery(s),
predicate: static (s, _) => s is TypeDeclarationSyntax,
transform: static (ctx, _) => GetTypeSymbol(ctx))
.Where(static m => m is not null)
.Collect();
// Combine with compilation to have access to it
var compilationAndTypes = context.CompilationProvider.Combine(commandsAndQueries);
var compilationAndTypes = context.CompilationProvider.Combine(typeDeclarations);
// Generate proto file when commands/queries change
context.RegisterSourceOutput(compilationAndTypes, (spc, source) =>
{
var (compilation, types) = source;
if (types.IsDefaultOrEmpty)
return;
// Note: We no longer bail out early since ProtoFileGenerator now scans all referenced assemblies
// The types from source are just a trigger - the generator will find types from all assemblies
try
{
@@ -102,15 +103,6 @@ public class ProtoFileSourceGenerator : IIncrementalGenerator
});
}
private static bool IsCommandOrQuery(SyntaxNode node)
{
if (node is not TypeDeclarationSyntax typeDecl)
return false;
var name = typeDecl.Identifier.Text;
return name.EndsWith("Command") || name.EndsWith("Query");
}
private static INamedTypeSymbol? GetTypeSymbol(GeneratorSyntaxContext context)
{
var typeDecl = (TypeDeclarationSyntax)context.Node;
+269 -77
View File
@@ -17,10 +17,69 @@ internal static class ProtoFileTypeMapper
var fullTypeName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
var typeName = typeSymbol.Name;
// Nullable types - unwrap
if (typeSymbol.NullableAnnotation == NullableAnnotation.Annotated && typeSymbol is INamedTypeSymbol namedType && namedType.TypeArguments.Length > 0)
// Note: NullableAnnotation.Annotated is for reference type nullability (List<T>?, string?, etc.)
// We don't unwrap these - just use the underlying type. Nullable<T> value types are handled later.
// Handle Nullable<T> value types (e.g., int?, decimal?, enum?) FIRST
if (typeSymbol is INamedTypeSymbol nullableType &&
nullableType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T &&
nullableType.TypeArguments.Length == 1)
{
return MapType(namedType.TypeArguments[0], out needsImport, out importPath);
// Unwrap the nullable and map the inner type
return MapType(nullableType.TypeArguments[0], out needsImport, out importPath);
}
// Handle collections BEFORE basic type checks (to avoid matching List<Guid> as Guid)
if (typeSymbol is INamedTypeSymbol collectionType)
{
// List, IEnumerable, Array, ICollection etc. (but not Nullable<T>)
var collectionTypeName = collectionType.Name;
if (collectionType.TypeArguments.Length == 1 &&
(collectionTypeName.Contains("List") || collectionTypeName.Contains("Collection") ||
collectionTypeName.Contains("Enumerable") || collectionTypeName.Contains("Array") ||
collectionTypeName.Contains("Set") || collectionTypeName.Contains("IList") ||
collectionTypeName.Contains("ICollection") || collectionTypeName.Contains("IEnumerable")))
{
var elementType = collectionType.TypeArguments[0];
var protoElementType = MapType(elementType, out needsImport, out importPath);
return $"repeated {protoElementType}";
}
// Dictionary<K, V>
if (collectionType.TypeArguments.Length == 2 &&
(collectionTypeName.Contains("Dictionary") || collectionTypeName.Contains("IDictionary")))
{
var keyType = MapType(collectionType.TypeArguments[0], out var keyNeedsImport, out var keyImportPath);
var valueType = MapType(collectionType.TypeArguments[1], out var valueNeedsImport, out var valueImportPath);
// Set import flags if either key or value needs imports
if (keyNeedsImport)
{
needsImport = true;
importPath = keyImportPath;
}
if (valueNeedsImport)
{
needsImport = true;
importPath = valueImportPath; // Note: This only captures last import, may need improvement
}
return $"map<{keyType}, {valueType}>";
}
}
// Handle byte[] array type (check before switch since it's an array)
if (typeSymbol is IArrayTypeSymbol arrayType && arrayType.ElementType.SpecialType == SpecialType.System_Byte)
{
return "bytes";
}
// Handle Stream types -> bytes
if (fullTypeName.Contains("System.IO.Stream") ||
fullTypeName.Contains("System.IO.MemoryStream") ||
fullTypeName.Contains("System.IO.FileStream"))
{
return "bytes";
}
// Basic types
@@ -52,67 +111,35 @@ internal static class ProtoFileTypeMapper
return "double";
case "Byte[]":
return "bytes";
}
// Special types that need imports
if (fullTypeName.Contains("System.DateTime"))
{
needsImport = true;
importPath = "google/protobuf/timestamp.proto";
return "google.protobuf.Timestamp";
}
if (fullTypeName.Contains("System.TimeSpan"))
{
needsImport = true;
importPath = "google/protobuf/duration.proto";
return "google.protobuf.Duration";
}
if (fullTypeName.Contains("System.Guid"))
{
// Guid serialized as string
return "string";
}
if (fullTypeName.Contains("System.Decimal"))
{
// Decimal serialized as string (no native decimal in proto)
return "string";
}
// Collections
if (typeSymbol is INamedTypeSymbol collectionType)
{
// List, IEnumerable, Array, etc.
if (collectionType.TypeArguments.Length == 1)
{
var elementType = collectionType.TypeArguments[0];
var protoElementType = MapType(elementType, out needsImport, out importPath);
return $"repeated {protoElementType}";
}
// Dictionary<K, V>
if (collectionType.TypeArguments.Length == 2 &&
(typeName.Contains("Dictionary") || typeName.Contains("IDictionary")))
{
var keyType = MapType(collectionType.TypeArguments[0], out var keyNeedsImport, out var keyImportPath);
var valueType = MapType(collectionType.TypeArguments[1], out var valueNeedsImport, out var valueImportPath);
// Set import flags if either key or value needs imports
if (keyNeedsImport)
{
needsImport = true;
importPath = keyImportPath;
}
if (valueNeedsImport)
{
needsImport = true;
importPath = valueImportPath; // Note: This only captures last import, may need improvement
}
return $"map<{keyType}, {valueType}>";
}
case "Stream":
case "MemoryStream":
case "FileStream":
return "bytes";
case "Guid":
// Guid serialized as string
return "string";
case "Decimal":
// Decimal serialized as string (no native decimal in proto)
return "string";
case "DateTime":
case "DateTimeOffset":
needsImport = true;
importPath = "google/protobuf/timestamp.proto";
return "google.protobuf.Timestamp";
case "DateOnly":
// DateOnly serialized as string (YYYY-MM-DD format)
return "string";
case "TimeOnly":
// TimeOnly serialized as string (HH:mm:ss format)
return "string";
case "TimeSpan":
needsImport = true;
importPath = "google/protobuf/duration.proto";
return "google.protobuf.Duration";
case "JsonElement":
needsImport = true;
importPath = "google/protobuf/struct.proto";
return "google.protobuf.Struct";
}
// Enums
@@ -132,7 +159,10 @@ internal static class ProtoFileTypeMapper
}
/// <summary>
/// Converts C# PascalCase property name to proto snake_case field name
/// Converts C# PascalCase property name to proto snake_case field name.
/// Uses simple conversion: add underscore before each uppercase letter (except first).
/// This matches protobuf's C# codegen expectations for PascalCase conversion.
/// Example: TotalADeduire -> total_a_deduire -> TotalADeduire (in generated C#)
/// </summary>
public static string ToSnakeCase(string pascalCase)
{
@@ -147,16 +177,8 @@ internal static class ProtoFileTypeMapper
var c = pascalCase[i];
if (char.IsUpper(c))
{
// Handle sequences of uppercase letters (e.g., "APIKey" -> "api_key")
if (i + 1 < pascalCase.Length && char.IsUpper(pascalCase[i + 1]))
{
result.Append(char.ToLowerInvariant(c));
}
else
{
result.Append('_');
result.Append(char.ToLowerInvariant(c));
}
result.Append('_');
result.Append(char.ToLowerInvariant(c));
}
else
{
@@ -175,8 +197,8 @@ internal static class ProtoFileTypeMapper
var fullTypeName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
// Skip these types - they should trigger a warning/error
if (fullTypeName.Contains("System.IO.Stream") ||
fullTypeName.Contains("System.Threading.CancellationToken") ||
// Note: Stream types are now supported (mapped to bytes)
if (fullTypeName.Contains("System.Threading.CancellationToken") ||
fullTypeName.Contains("System.Threading.Tasks.Task") ||
fullTypeName.Contains("System.Collections.Generic.IAsyncEnumerable") ||
fullTypeName.Contains("System.Func") ||
@@ -188,4 +210,174 @@ internal static class ProtoFileTypeMapper
return false;
}
/// <summary>
/// Checks if a type is a Stream or byte array type (for special ByteString handling)
/// </summary>
public static bool IsBinaryType(ITypeSymbol typeSymbol)
{
var fullTypeName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
// Check for byte[]
if (typeSymbol is IArrayTypeSymbol arrayType && arrayType.ElementType.SpecialType == SpecialType.System_Byte)
{
return true;
}
// Check for Stream types
if (fullTypeName.Contains("System.IO.Stream") ||
fullTypeName.Contains("System.IO.MemoryStream") ||
fullTypeName.Contains("System.IO.FileStream"))
{
return true;
}
var typeName = typeSymbol.Name;
return typeName == "Stream" || typeName == "MemoryStream" || typeName == "FileStream";
}
/// <summary>
/// Gets the element type from a collection type, or returns the type itself if not a collection.
/// Also unwraps Nullable types.
/// </summary>
public static ITypeSymbol GetElementOrUnderlyingType(ITypeSymbol typeSymbol)
{
// Unwrap Nullable<T>
if (typeSymbol is INamedTypeSymbol nullableType &&
nullableType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T &&
nullableType.TypeArguments.Length == 1)
{
return GetElementOrUnderlyingType(nullableType.TypeArguments[0]);
}
// Extract element type from collections
if (typeSymbol is INamedTypeSymbol collectionType && collectionType.TypeArguments.Length == 1)
{
var typeName = collectionType.Name;
if (typeName.Contains("List") || typeName.Contains("Collection") ||
typeName.Contains("Enumerable") || typeName.Contains("Array") ||
typeName.Contains("Set") || typeName.Contains("IList") ||
typeName.Contains("ICollection") || typeName.Contains("IEnumerable"))
{
return GetElementOrUnderlyingType(collectionType.TypeArguments[0]);
}
}
return typeSymbol;
}
/// <summary>
/// Checks if the type is an enum (including nullable enums)
/// </summary>
public static bool IsEnumType(ITypeSymbol typeSymbol)
{
var underlying = GetElementOrUnderlyingType(typeSymbol);
return underlying.TypeKind == TypeKind.Enum;
}
/// <summary>
/// Gets the enum type symbol if this is an enum or nullable enum, otherwise null
/// </summary>
public static INamedTypeSymbol? GetEnumType(ITypeSymbol typeSymbol)
{
var underlying = GetElementOrUnderlyingType(typeSymbol);
if (underlying.TypeKind == TypeKind.Enum && underlying is INamedTypeSymbol enumType)
{
return enumType;
}
return null;
}
/// <summary>
/// Checks if a type is a collection by checking if it implements IList{T}, ICollection{T}, or IEnumerable{T}
/// This handles types like NpgsqlPolygon that implement IList{NpgsqlPoint} but aren't named "List"
/// </summary>
public static bool IsCollectionTypeByInterface(ITypeSymbol typeSymbol)
{
if (typeSymbol is not INamedTypeSymbol namedType)
return false;
// Skip string (implements IEnumerable<char>)
if (namedType.SpecialType == SpecialType.System_String)
return false;
// Check all interfaces for IList<T>, ICollection<T>, or IEnumerable<T>
foreach (var iface in namedType.AllInterfaces)
{
if (iface.IsGenericType && iface.TypeArguments.Length == 1)
{
var ifaceName = iface.OriginalDefinition.ToDisplayString();
if (ifaceName == "System.Collections.Generic.IList<T>" ||
ifaceName == "System.Collections.Generic.ICollection<T>" ||
ifaceName == "System.Collections.Generic.IEnumerable<T>" ||
ifaceName == "System.Collections.Generic.IReadOnlyList<T>" ||
ifaceName == "System.Collections.Generic.IReadOnlyCollection<T>")
{
return true;
}
}
}
return false;
}
/// <summary>
/// Gets the element type from a collection that implements IList{T}, ICollection{T}, or IEnumerable{T}
/// Returns null if the type is not a collection
/// </summary>
public static ITypeSymbol? GetCollectionElementTypeByInterface(ITypeSymbol typeSymbol)
{
if (typeSymbol is not INamedTypeSymbol namedType)
return null;
// Skip string
if (namedType.SpecialType == SpecialType.System_String)
return null;
// Prefer IList<T> over ICollection<T> over IEnumerable<T>
ITypeSymbol? elementType = null;
int priority = 0;
foreach (var iface in namedType.AllInterfaces)
{
if (iface.IsGenericType && iface.TypeArguments.Length == 1)
{
var ifaceName = iface.OriginalDefinition.ToDisplayString();
int currentPriority = 0;
if (ifaceName == "System.Collections.Generic.IList<T>" ||
ifaceName == "System.Collections.Generic.IReadOnlyList<T>")
currentPriority = 3;
else if (ifaceName == "System.Collections.Generic.ICollection<T>" ||
ifaceName == "System.Collections.Generic.IReadOnlyCollection<T>")
currentPriority = 2;
else if (ifaceName == "System.Collections.Generic.IEnumerable<T>")
currentPriority = 1;
if (currentPriority > priority)
{
priority = currentPriority;
elementType = iface.TypeArguments[0];
}
}
}
return elementType;
}
/// <summary>
/// Collection-internal properties that should be skipped when generating proto messages
/// </summary>
private static readonly System.Collections.Generic.HashSet<string> CollectionInternalProperties = new()
{
"Count", "Capacity", "IsReadOnly", "IsSynchronized", "SyncRoot", "Keys", "Values"
};
/// <summary>
/// Checks if a property name is a collection-internal property that should be skipped
/// </summary>
public static bool IsCollectionInternalProperty(string propertyName)
{
return CollectionInternalProperties.Contains(propertyName);
}
}
@@ -62,7 +62,27 @@ public class WriteProtoFileTask : Task
Log.LogWarning(
$"Generated proto file not found at {generatedFilePath}. " +
"The proto file may not have been generated yet. This is normal on first build.");
return true; // Don't fail the build, just skip
// Write a minimal placeholder proto file so Grpc.Tools doesn't fail
// The real content will be generated on the next build
var placeholderProto = @"syntax = ""proto3"";
option csharp_namespace = ""Generated.Grpc"";
package cqrs;
// Placeholder proto file - will be regenerated on next build
";
var placeholderOutputPath = Path.Combine(ProjectDirectory, OutputDirectory);
Directory.CreateDirectory(placeholderOutputPath);
var placeholderProtoFilePath = Path.Combine(placeholderOutputPath, ProtoFileName);
File.WriteAllText(placeholderProtoFilePath, placeholderProto);
Log.LogMessage(MessageImportance.High,
$"Svrnty.CQRS.Grpc: Wrote placeholder proto file at {placeholderProtoFilePath}. " +
"Run build again to generate the actual proto content.");
return true;
}
// Read the generated C# file
+54
View File
@@ -33,6 +33,7 @@ public static class CqrsBuilderExtensions
{
Console.WriteLine("Warning: AddGrpcFromConfiguration not found. gRPC services were not registered.");
Console.WriteLine("Make sure your project has source generators enabled and references Svrnty.CQRS.Grpc.Generators.");
DiagnoseGeneratedCode();
}
// Register mapping callback for automatic endpoint mapping
@@ -49,6 +50,59 @@ public static class CqrsBuilderExtensions
return builder;
}
private static void DiagnoseGeneratedCode()
{
var entryAsm = Assembly.GetEntryAssembly();
if (entryAsm == null)
{
Console.WriteLine("Diagnostic: Entry assembly is null");
return;
}
Console.WriteLine($"Diagnostic: Entry assembly = {entryAsm.GetName().Name}");
try
{
var allTypes = entryAsm.GetTypes();
Console.WriteLine($"Diagnostic: Total types in entry assembly = {allTypes.Length}");
var grpcTypes = allTypes
.Where(t => t.FullName?.Contains("Grpc") == true)
.ToList();
if (grpcTypes.Any())
{
Console.WriteLine("Diagnostic: Found Grpc-related types:");
foreach (var t in grpcTypes)
{
Console.WriteLine($" - {t.FullName} (IsClass={t.IsClass}, IsSealed={t.IsSealed}, IsPublic={t.IsPublic})");
// Check for our target method
var method = t.GetMethod("AddGrpcFromConfiguration", BindingFlags.Static | BindingFlags.Public);
if (method != null)
Console.WriteLine($" -> HAS AddGrpcFromConfiguration method!");
}
}
else
{
Console.WriteLine("Diagnostic: No Grpc-related types found. Source generator did NOT run.");
}
}
catch (ReflectionTypeLoadException ex)
{
Console.WriteLine($"Diagnostic: ReflectionTypeLoadException - {ex.Message}");
foreach (var le in ex.LoaderExceptions)
{
if (le != null)
Console.WriteLine($" LoaderException: {le.Message}");
}
}
catch (Exception ex)
{
Console.WriteLine($"Diagnostic: Exception - {ex.GetType().Name}: {ex.Message}");
}
}
private static MethodInfo? FindExtensionMethod(string methodName, Type parameterType)
{
// Search through all loaded assemblies for the extension method
+1 -1
View File
@@ -27,7 +27,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Grpc.AspNetCore" Version="2.68.0" />
<PackageReference Include="Grpc.AspNetCore" Version="2.71.0" />
</ItemGroup>
<ItemGroup>
@@ -19,7 +19,6 @@ public static class EndpointRouteBuilderExtensions
public static IEndpointRouteBuilder MapSvrntyQueries(this IEndpointRouteBuilder endpoints, string routePrefix = "api/query")
{
var queryDiscovery = endpoints.ServiceProvider.GetRequiredService<IQueryDiscovery>();
var authorizationService = endpoints.ServiceProvider.GetService<IQueryAuthorizationService>();
foreach (var queryMeta in queryDiscovery.GetQueries())
{
@@ -33,8 +32,8 @@ public static class EndpointRouteBuilderExtensions
var route = $"{routePrefix}/{queryMeta.LowerCamelCaseName}";
MapQueryPost(endpoints, route, queryMeta, authorizationService);
MapQueryGet(endpoints, route, queryMeta, authorizationService);
MapQueryPost(endpoints, route, queryMeta);
MapQueryGet(endpoints, route, queryMeta);
}
return endpoints;
@@ -43,13 +42,13 @@ public static class EndpointRouteBuilderExtensions
private static void MapQueryPost(
IEndpointRouteBuilder endpoints,
string route,
IQueryMeta queryMeta,
IQueryAuthorizationService? authorizationService)
IQueryMeta queryMeta)
{
var handlerType = typeof(IQueryHandler<,>).MakeGenericType(queryMeta.QueryType, queryMeta.QueryResultType);
endpoints.MapPost(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<IQueryAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(queryMeta.QueryType, cancellationToken);
@@ -90,13 +89,13 @@ public static class EndpointRouteBuilderExtensions
private static void MapQueryGet(
IEndpointRouteBuilder endpoints,
string route,
IQueryMeta queryMeta,
IQueryAuthorizationService? authorizationService)
IQueryMeta queryMeta)
{
var handlerType = typeof(IQueryHandler<,>).MakeGenericType(queryMeta.QueryType, queryMeta.QueryResultType);
endpoints.MapGet(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<IQueryAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(queryMeta.QueryType, cancellationToken);
@@ -153,7 +152,6 @@ public static class EndpointRouteBuilderExtensions
public static IEndpointRouteBuilder MapSvrntyCommands(this IEndpointRouteBuilder endpoints, string routePrefix = "api/command")
{
var commandDiscovery = endpoints.ServiceProvider.GetRequiredService<ICommandDiscovery>();
var authorizationService = endpoints.ServiceProvider.GetService<ICommandAuthorizationService>();
foreach (var commandMeta in commandDiscovery.GetCommands())
{
@@ -165,11 +163,11 @@ public static class EndpointRouteBuilderExtensions
if (commandMeta.CommandResultType == null)
{
MapCommandWithoutResult(endpoints, route, commandMeta, authorizationService);
MapCommandWithoutResult(endpoints, route, commandMeta);
}
else
{
MapCommandWithResult(endpoints, route, commandMeta, authorizationService);
MapCommandWithResult(endpoints, route, commandMeta);
}
}
@@ -179,13 +177,13 @@ public static class EndpointRouteBuilderExtensions
private static void MapCommandWithoutResult(
IEndpointRouteBuilder endpoints,
string route,
ICommandMeta commandMeta,
ICommandAuthorizationService? authorizationService)
ICommandMeta commandMeta)
{
var handlerType = typeof(ICommandHandler<>).MakeGenericType(commandMeta.CommandType);
endpoints.MapPost(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<ICommandAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(commandMeta.CommandType, cancellationToken);
@@ -221,13 +219,13 @@ public static class EndpointRouteBuilderExtensions
private static void MapCommandWithResult(
IEndpointRouteBuilder endpoints,
string route,
ICommandMeta commandMeta,
ICommandAuthorizationService? authorizationService)
ICommandMeta commandMeta)
{
var handlerType = typeof(ICommandHandler<,>).MakeGenericType(commandMeta.CommandType, commandMeta.CommandResultType!);
endpoints.MapPost(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<ICommandAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(commandMeta.CommandType, cancellationToken);
@@ -0,0 +1,18 @@
namespace Svrnty.CQRS.Notifications.Abstractions;
/// <summary>
/// Publishes notifications to all subscribed gRPC clients.
/// </summary>
public interface INotificationPublisher
{
/// <summary>
/// Publish a notification to all subscribers matching the subscription key.
/// The subscription key is extracted from the notification based on the
/// <see cref="StreamingNotificationAttribute.SubscriptionKey"/> property.
/// </summary>
/// <typeparam name="TNotification">The notification type marked with <see cref="StreamingNotificationAttribute"/>.</typeparam>
/// <param name="notification">The notification to publish.</param>
/// <param name="ct">Cancellation token.</param>
Task PublishAsync<TNotification>(TNotification notification, CancellationToken ct = default)
where TNotification : class;
}
@@ -0,0 +1,15 @@
namespace Svrnty.CQRS.Notifications.Abstractions;
/// <summary>
/// Marks a record as a streaming notification that can be subscribed to via gRPC.
/// The framework will auto-generate proto definitions and service implementations.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class StreamingNotificationAttribute : Attribute
{
/// <summary>
/// The property name used as the subscription key.
/// Subscribers filter notifications by this value.
/// </summary>
public required string SubscriptionKey { get; set; }
}
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsAotCompatible>true</IsAotCompatible>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Company>Svrnty</Company>
<Authors>David Lebee, Mathias Beaulieu-Duncan</Authors>
<PackageIcon>icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://git.openharbor.io/svrnty/dotnet-cqrs</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<DebugType>portable</DebugType>
<DebugSymbols>true</DebugSymbols>
<IncludeSymbols>true</IncludeSymbols>
<IncludeSource>true</IncludeSource>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<ItemGroup>
<None Include="..\icon.png" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
</ItemGroup>
</Project>
@@ -0,0 +1,76 @@
using System.Collections.Concurrent;
using System.Reflection;
using Microsoft.Extensions.Logging;
using Svrnty.CQRS.Notifications.Abstractions;
namespace Svrnty.CQRS.Notifications.Grpc;
/// <summary>
/// Publishes notifications to subscribed gRPC clients.
/// </summary>
public class NotificationPublisher : INotificationPublisher
{
private readonly NotificationSubscriptionManager _manager;
private readonly ILogger<NotificationPublisher> _logger;
// Cache subscription key property info per notification type
private static readonly ConcurrentDictionary<Type, SubscriptionKeyInfo> _keyCache = new();
public NotificationPublisher(
NotificationSubscriptionManager manager,
ILogger<NotificationPublisher> logger)
{
_manager = manager;
_logger = logger;
}
/// <inheritdoc />
public async Task PublishAsync<TNotification>(TNotification notification, CancellationToken ct = default)
where TNotification : class
{
ArgumentNullException.ThrowIfNull(notification);
var keyInfo = GetSubscriptionKeyInfo(typeof(TNotification));
var subscriptionKey = keyInfo.Property.GetValue(notification);
if (subscriptionKey == null)
{
_logger.LogWarning(
"Subscription key {PropertyName} is null on {NotificationType}, skipping notification",
keyInfo.PropertyName, typeof(TNotification).Name);
return;
}
_logger.LogDebug(
"Publishing {NotificationType} with subscription key {PropertyName}={KeyValue}",
typeof(TNotification).Name, keyInfo.PropertyName, subscriptionKey);
await _manager.NotifyAsync(notification, subscriptionKey, ct);
}
private static SubscriptionKeyInfo GetSubscriptionKeyInfo(Type type)
{
return _keyCache.GetOrAdd(type, t =>
{
var attr = t.GetCustomAttribute<StreamingNotificationAttribute>();
if (attr == null)
{
throw new InvalidOperationException(
$"Type {t.Name} is not marked with [{nameof(StreamingNotificationAttribute)}]. " +
$"Add the attribute with a SubscriptionKey to enable streaming notifications.");
}
var property = t.GetProperty(attr.SubscriptionKey);
if (property == null)
{
throw new InvalidOperationException(
$"Property '{attr.SubscriptionKey}' specified in [{nameof(StreamingNotificationAttribute)}] " +
$"was not found on type {t.Name}.");
}
return new SubscriptionKeyInfo(attr.SubscriptionKey, property);
});
}
private sealed record SubscriptionKeyInfo(string PropertyName, PropertyInfo Property);
}
@@ -0,0 +1,164 @@
using System.Collections.Concurrent;
using Grpc.Core;
using Microsoft.Extensions.Logging;
namespace Svrnty.CQRS.Notifications.Grpc;
/// <summary>
/// Manages gRPC stream subscriptions for notifications.
/// Thread-safe singleton that tracks subscriptions and routes notifications to subscribers.
/// </summary>
public class NotificationSubscriptionManager
{
private readonly ConcurrentDictionary<(string TypeName, string Key), ConcurrentBag<object>> _subscriptions = new();
private readonly ILogger<NotificationSubscriptionManager> _logger;
public NotificationSubscriptionManager(ILogger<NotificationSubscriptionManager> logger)
{
_logger = logger;
}
/// <summary>
/// Subscribe to notifications of a specific domain type with a mapper to convert to proto format.
/// </summary>
/// <typeparam name="TDomain">The domain notification type.</typeparam>
/// <typeparam name="TProto">The proto message type.</typeparam>
/// <param name="subscriptionKey">The subscription key value (e.g., inventory ID).</param>
/// <param name="stream">The gRPC server stream writer.</param>
/// <param name="mapper">Function to map domain notification to proto message.</param>
/// <returns>A disposable that removes the subscription when disposed.</returns>
public IDisposable Subscribe<TDomain, TProto>(
object subscriptionKey,
IServerStreamWriter<TProto> stream,
Func<TDomain, TProto> mapper) where TDomain : class
{
var key = (typeof(TDomain).FullName!, subscriptionKey.ToString()!);
var subscriber = new Subscriber<TDomain, TProto>(stream, mapper);
var bag = _subscriptions.GetOrAdd(key, _ => new ConcurrentBag<object>());
bag.Add(subscriber);
_logger.LogInformation(
"Client subscribed to {NotificationType} with key {SubscriptionKey}. Total subscribers: {Count}",
typeof(TDomain).Name, subscriptionKey, bag.Count);
return new SubscriptionHandle(() => Remove(key, subscriber));
}
/// <summary>
/// Notify all subscribers of a specific notification type and subscription key.
/// </summary>
internal async Task NotifyAsync<TDomain>(TDomain notification, object subscriptionKey, CancellationToken ct) where TDomain : class
{
var key = (typeof(TDomain).FullName!, subscriptionKey.ToString()!);
if (!_subscriptions.TryGetValue(key, out var subscribers))
{
_logger.LogDebug(
"No subscribers for {NotificationType} with key {SubscriptionKey}",
typeof(TDomain).Name, subscriptionKey);
return;
}
var deadSubscribers = new List<object>();
foreach (var sub in subscribers)
{
if (sub is INotifiable<TDomain> notifiable)
{
try
{
await notifiable.NotifyAsync(notification, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to notify subscriber for {NotificationType}, marking for removal",
typeof(TDomain).Name);
deadSubscribers.Add(sub);
}
}
}
// Clean up dead subscribers
foreach (var dead in deadSubscribers)
{
Remove(key, dead);
}
_logger.LogDebug(
"Notified {Count} subscribers for {NotificationType} with key {SubscriptionKey}",
subscribers.Count - deadSubscribers.Count, typeof(TDomain).Name, subscriptionKey);
}
private void Remove((string TypeName, string Key) key, object subscriber)
{
if (_subscriptions.TryGetValue(key, out var bag))
{
// ConcurrentBag doesn't support removal, so we rebuild
var remaining = bag.Where(s => !ReferenceEquals(s, subscriber)).ToList();
if (remaining.Count == 0)
{
_subscriptions.TryRemove(key, out _);
}
else
{
var newBag = new ConcurrentBag<object>(remaining);
_subscriptions.TryUpdate(key, newBag, bag);
}
_logger.LogInformation(
"Client unsubscribed from {NotificationType} with key {SubscriptionKey}",
key.TypeName.Split('.').Last(), key.Key);
}
}
}
/// <summary>
/// Internal interface for type-erased notification delivery.
/// </summary>
internal interface INotifiable<in TDomain>
{
Task NotifyAsync(TDomain notification, CancellationToken ct);
}
/// <summary>
/// Wraps a gRPC stream writer with a domain→proto mapper.
/// </summary>
internal sealed class Subscriber<TDomain, TProto> : INotifiable<TDomain>
{
private readonly IServerStreamWriter<TProto> _stream;
private readonly Func<TDomain, TProto> _mapper;
public Subscriber(IServerStreamWriter<TProto> stream, Func<TDomain, TProto> mapper)
{
_stream = stream;
_mapper = mapper;
}
public async Task NotifyAsync(TDomain notification, CancellationToken ct)
{
var proto = _mapper(notification);
await _stream.WriteAsync(proto, ct);
}
}
/// <summary>
/// Handle that removes a subscription when disposed.
/// </summary>
internal sealed class SubscriptionHandle : IDisposable
{
private readonly Action _onDispose;
private bool _disposed;
public SubscriptionHandle(Action onDispose)
{
_onDispose = onDispose;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_onDispose();
}
}
@@ -0,0 +1,26 @@
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Notifications.Abstractions;
namespace Svrnty.CQRS.Notifications.Grpc;
/// <summary>
/// Extension methods for registering streaming notification services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds gRPC streaming notification services to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddStreamingNotifications(this IServiceCollection services)
{
// Subscription manager is singleton - shared state for all subscriptions
services.AddSingleton<NotificationSubscriptionManager>();
// Publisher can be singleton since it only depends on the manager
services.AddSingleton<INotificationPublisher, NotificationPublisher>();
return services;
}
}
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsAotCompatible>false</IsAotCompatible>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Company>Svrnty</Company>
<Authors>Mathias Beaulieu-Duncan</Authors>
<PackageIcon>icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://git.openharbor.io/svrnty/dotnet-cqrs</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<DebugType>portable</DebugType>
<DebugSymbols>true</DebugSymbols>
<IncludeSymbols>true</IncludeSymbols>
<IncludeSource>true</IncludeSource>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<ItemGroup>
<None Include="..\icon.png" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Grpc.AspNetCore" Version="2.71.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Svrnty.CQRS.Notifications.Abstractions\Svrnty.CQRS.Notifications.Abstractions.csproj" />
</ItemGroup>
</Project>
+14
View File
@@ -0,0 +1,14 @@
namespace Svrnty.CQRS.Sagas.Abstractions;
/// <summary>
/// Defines a saga with its steps and compensation logic.
/// </summary>
/// <typeparam name="TData">The saga's data/context type.</typeparam>
public interface ISaga<TData> where TData : class, ISagaData, new()
{
/// <summary>
/// Configures the saga steps using the fluent builder.
/// </summary>
/// <param name="builder">The saga builder for defining steps.</param>
void Configure(ISagaBuilder<TData> builder);
}
@@ -0,0 +1,173 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Svrnty.CQRS.Sagas.Abstractions;
/// <summary>
/// Fluent builder for defining saga steps.
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
public interface ISagaBuilder<TData> where TData : class, ISagaData
{
/// <summary>
/// Adds a local step that executes synchronously within the orchestrator process.
/// </summary>
/// <param name="name">Unique name for this step.</param>
/// <returns>Builder for configuring the step.</returns>
ISagaStepBuilder<TData> Step(string name);
/// <summary>
/// Adds a step that sends a command to a remote service via messaging.
/// </summary>
/// <typeparam name="TCommand">The command type to send.</typeparam>
/// <param name="name">Unique name for this step.</param>
/// <returns>Builder for configuring the remote step.</returns>
ISagaRemoteStepBuilder<TData, TCommand> SendCommand<TCommand>(string name) where TCommand : class;
/// <summary>
/// Adds a step that sends a command and expects a specific result.
/// </summary>
/// <typeparam name="TCommand">The command type to send.</typeparam>
/// <typeparam name="TResult">The expected result type.</typeparam>
/// <param name="name">Unique name for this step.</param>
/// <returns>Builder for configuring the remote step.</returns>
ISagaRemoteStepBuilder<TData, TCommand, TResult> SendCommand<TCommand, TResult>(string name) where TCommand : class;
}
/// <summary>
/// Builder for configuring a local saga step.
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
public interface ISagaStepBuilder<TData> where TData : class, ISagaData
{
/// <summary>
/// Defines the execution action for this step.
/// </summary>
/// <param name="action">The action to execute.</param>
/// <returns>This builder for chaining.</returns>
ISagaStepBuilder<TData> Execute(Func<TData, ISagaContext, CancellationToken, Task> action);
/// <summary>
/// Defines the compensation action for this step.
/// </summary>
/// <param name="action">The compensation action to execute on rollback.</param>
/// <returns>This builder for chaining.</returns>
ISagaStepBuilder<TData> Compensate(Func<TData, ISagaContext, CancellationToken, Task> action);
/// <summary>
/// Completes this step definition and returns to the saga builder.
/// </summary>
/// <returns>The saga builder for adding more steps.</returns>
ISagaBuilder<TData> Then();
}
/// <summary>
/// Builder for configuring a remote command saga step (no result).
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
/// <typeparam name="TCommand">The command type to send.</typeparam>
public interface ISagaRemoteStepBuilder<TData, TCommand>
where TData : class, ISagaData
where TCommand : class
{
/// <summary>
/// Defines how to build the command from saga data.
/// </summary>
/// <param name="commandBuilder">Function to create the command.</param>
/// <returns>This builder for chaining.</returns>
ISagaRemoteStepBuilder<TData, TCommand> WithCommand(Func<TData, ISagaContext, TCommand> commandBuilder);
/// <summary>
/// Defines what to do when the command completes successfully.
/// </summary>
/// <param name="handler">Handler for the response.</param>
/// <returns>This builder for chaining.</returns>
ISagaRemoteStepBuilder<TData, TCommand> OnResponse(Func<TData, ISagaContext, CancellationToken, Task> handler);
/// <summary>
/// Defines the compensation command to send on rollback.
/// </summary>
/// <typeparam name="TCompensationCommand">The compensation command type.</typeparam>
/// <param name="compensationBuilder">Function to create the compensation command.</param>
/// <returns>This builder for chaining.</returns>
ISagaRemoteStepBuilder<TData, TCommand> Compensate<TCompensationCommand>(
Func<TData, ISagaContext, TCompensationCommand> compensationBuilder) where TCompensationCommand : class;
/// <summary>
/// Sets a timeout for this step.
/// </summary>
/// <param name="timeout">The timeout duration.</param>
/// <returns>This builder for chaining.</returns>
ISagaRemoteStepBuilder<TData, TCommand> WithTimeout(TimeSpan timeout);
/// <summary>
/// Configures retry behavior for this step.
/// </summary>
/// <param name="maxRetries">Maximum number of retries.</param>
/// <param name="delay">Delay between retries.</param>
/// <returns>This builder for chaining.</returns>
ISagaRemoteStepBuilder<TData, TCommand> WithRetry(int maxRetries, TimeSpan delay);
/// <summary>
/// Completes this step definition and returns to the saga builder.
/// </summary>
/// <returns>The saga builder for adding more steps.</returns>
ISagaBuilder<TData> Then();
}
/// <summary>
/// Builder for configuring a remote command saga step with result.
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
/// <typeparam name="TCommand">The command type to send.</typeparam>
/// <typeparam name="TResult">The expected result type.</typeparam>
public interface ISagaRemoteStepBuilder<TData, TCommand, TResult>
where TData : class, ISagaData
where TCommand : class
{
/// <summary>
/// Defines how to build the command from saga data.
/// </summary>
/// <param name="commandBuilder">Function to create the command.</param>
/// <returns>This builder for chaining.</returns>
ISagaRemoteStepBuilder<TData, TCommand, TResult> WithCommand(Func<TData, ISagaContext, TCommand> commandBuilder);
/// <summary>
/// Defines what to do when the command completes successfully with a result.
/// </summary>
/// <param name="handler">Handler for the response with result.</param>
/// <returns>This builder for chaining.</returns>
ISagaRemoteStepBuilder<TData, TCommand, TResult> OnResponse(
Func<TData, ISagaContext, TResult, CancellationToken, Task> handler);
/// <summary>
/// Defines the compensation command to send on rollback.
/// </summary>
/// <typeparam name="TCompensationCommand">The compensation command type.</typeparam>
/// <param name="compensationBuilder">Function to create the compensation command.</param>
/// <returns>This builder for chaining.</returns>
ISagaRemoteStepBuilder<TData, TCommand, TResult> Compensate<TCompensationCommand>(
Func<TData, ISagaContext, TCompensationCommand> compensationBuilder) where TCompensationCommand : class;
/// <summary>
/// Sets a timeout for this step.
/// </summary>
/// <param name="timeout">The timeout duration.</param>
/// <returns>This builder for chaining.</returns>
ISagaRemoteStepBuilder<TData, TCommand, TResult> WithTimeout(TimeSpan timeout);
/// <summary>
/// Configures retry behavior for this step.
/// </summary>
/// <param name="maxRetries">Maximum number of retries.</param>
/// <param name="delay">Delay between retries.</param>
/// <returns>This builder for chaining.</returns>
ISagaRemoteStepBuilder<TData, TCommand, TResult> WithRetry(int maxRetries, TimeSpan delay);
/// <summary>
/// Completes this step definition and returns to the saga builder.
/// </summary>
/// <returns>The saga builder for adding more steps.</returns>
ISagaBuilder<TData> Then();
}
@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
namespace Svrnty.CQRS.Sagas.Abstractions;
/// <summary>
/// Provides context information during saga step execution.
/// </summary>
public interface ISagaContext
{
/// <summary>
/// Unique identifier for this saga instance.
/// </summary>
Guid SagaId { get; }
/// <summary>
/// Correlation ID for tracing across services.
/// </summary>
Guid CorrelationId { get; }
/// <summary>
/// The fully qualified type name of the saga.
/// </summary>
string SagaType { get; }
/// <summary>
/// Index of the current step being executed.
/// </summary>
int CurrentStepIndex { get; }
/// <summary>
/// Name of the current step being executed.
/// </summary>
string CurrentStepName { get; }
/// <summary>
/// Results from completed steps, keyed by step name.
/// </summary>
IReadOnlyDictionary<string, object?> StepResults { get; }
/// <summary>
/// Gets a result from a previous step.
/// </summary>
/// <typeparam name="T">The expected result type.</typeparam>
/// <param name="stepName">The name of the step.</param>
/// <returns>The result, or default if not found.</returns>
T? GetStepResult<T>(string stepName);
/// <summary>
/// Sets a result for the current step (available to subsequent steps).
/// </summary>
/// <typeparam name="T">The result type.</typeparam>
/// <param name="result">The result value.</param>
void SetStepResult<T>(T result);
}
@@ -0,0 +1,14 @@
using System;
namespace Svrnty.CQRS.Sagas.Abstractions;
/// <summary>
/// Marker interface for saga data. All saga data classes must implement this interface.
/// </summary>
public interface ISagaData
{
/// <summary>
/// Correlation ID for tracing the saga across services.
/// </summary>
Guid CorrelationId { get; set; }
}
@@ -0,0 +1,52 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Svrnty.CQRS.Sagas.Abstractions;
/// <summary>
/// Orchestrates saga execution.
/// </summary>
public interface ISagaOrchestrator
{
/// <summary>
/// Starts a new saga instance with a generated correlation ID.
/// </summary>
/// <typeparam name="TSaga">The saga type.</typeparam>
/// <typeparam name="TData">The saga data type.</typeparam>
/// <param name="initialData">The initial saga data.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The saga state.</returns>
Task<SagaState> StartAsync<TSaga, TData>(TData initialData, CancellationToken cancellationToken = default)
where TSaga : ISaga<TData>
where TData : class, ISagaData, new();
/// <summary>
/// Starts a new saga instance with a specific correlation ID.
/// </summary>
/// <typeparam name="TSaga">The saga type.</typeparam>
/// <typeparam name="TData">The saga data type.</typeparam>
/// <param name="initialData">The initial saga data.</param>
/// <param name="correlationId">The correlation ID for tracing.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The saga state.</returns>
Task<SagaState> StartAsync<TSaga, TData>(TData initialData, Guid correlationId, CancellationToken cancellationToken = default)
where TSaga : ISaga<TData>
where TData : class, ISagaData, new();
/// <summary>
/// Gets the current state of a saga by its ID.
/// </summary>
/// <param name="sagaId">The saga instance ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The saga state, or null if not found.</returns>
Task<SagaState?> GetStateAsync(Guid sagaId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current state of a saga by its correlation ID.
/// </summary>
/// <param name="correlationId">The correlation ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The saga state, or null if not found.</returns>
Task<SagaState?> GetStateByCorrelationIdAsync(Guid correlationId, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,44 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Svrnty.CQRS.Sagas.Abstractions.Messaging;
/// <summary>
/// Abstraction for saga messaging transport.
/// </summary>
public interface ISagaMessageBus
{
/// <summary>
/// Publishes a saga command message to the message bus.
/// </summary>
/// <param name="message">The message to publish.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task PublishAsync(SagaMessage message, CancellationToken cancellationToken = default);
/// <summary>
/// Publishes a saga step response to the message bus.
/// </summary>
/// <param name="response">The response to publish.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task PublishResponseAsync(SagaStepResponse response, CancellationToken cancellationToken = default);
/// <summary>
/// Subscribes to saga messages for a specific command type.
/// </summary>
/// <typeparam name="TCommand">The command type to subscribe to.</typeparam>
/// <param name="handler">Handler that processes the message and returns a response.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task SubscribeAsync<TCommand>(
Func<SagaMessage, TCommand, CancellationToken, Task<SagaStepResponse>> handler,
CancellationToken cancellationToken = default) where TCommand : class;
/// <summary>
/// Subscribes to saga step responses.
/// </summary>
/// <param name="handler">Handler that processes responses.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task SubscribeToResponsesAsync(
Func<SagaStepResponse, CancellationToken, Task> handler,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
namespace Svrnty.CQRS.Sagas.Abstractions.Messaging;
/// <summary>
/// Message envelope for saga commands sent to remote services.
/// </summary>
public record SagaMessage
{
/// <summary>
/// Unique identifier for this message.
/// </summary>
public Guid MessageId { get; init; } = Guid.NewGuid();
/// <summary>
/// The saga instance ID.
/// </summary>
public Guid SagaId { get; init; }
/// <summary>
/// Correlation ID for tracing across services.
/// </summary>
public Guid CorrelationId { get; init; }
/// <summary>
/// Name of the saga step that sent this message.
/// </summary>
public string StepName { get; init; } = string.Empty;
/// <summary>
/// Fully qualified type name of the command.
/// </summary>
public string CommandType { get; init; } = string.Empty;
/// <summary>
/// Serialized command payload (JSON).
/// </summary>
public string? Payload { get; init; }
/// <summary>
/// When the message was created.
/// </summary>
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// Additional headers for the message.
/// </summary>
public Dictionary<string, string> Headers { get; init; } = new();
/// <summary>
/// Whether this is a compensation command.
/// </summary>
public bool IsCompensation { get; init; }
}
@@ -0,0 +1,59 @@
using System;
namespace Svrnty.CQRS.Sagas.Abstractions.Messaging;
/// <summary>
/// Response message from a saga step execution.
/// </summary>
public record SagaStepResponse
{
/// <summary>
/// Unique identifier for this response.
/// </summary>
public Guid MessageId { get; init; } = Guid.NewGuid();
/// <summary>
/// The saga instance ID.
/// </summary>
public Guid SagaId { get; init; }
/// <summary>
/// Correlation ID for tracing across services.
/// </summary>
public Guid CorrelationId { get; init; }
/// <summary>
/// Name of the saga step that this response is for.
/// </summary>
public string StepName { get; init; } = string.Empty;
/// <summary>
/// Whether the step executed successfully.
/// </summary>
public bool Success { get; init; }
/// <summary>
/// Fully qualified type name of the result (if any).
/// </summary>
public string? ResultType { get; init; }
/// <summary>
/// Serialized result payload (JSON).
/// </summary>
public string? ResultPayload { get; init; }
/// <summary>
/// Error message if the step failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// Stack trace if the step failed (for debugging).
/// </summary>
public string? StackTrace { get; init; }
/// <summary>
/// When the response was created.
/// </summary>
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
}
@@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Svrnty.CQRS.Sagas.Abstractions.Persistence;
/// <summary>
/// Abstraction for saga state persistence.
/// </summary>
public interface ISagaStateStore
{
/// <summary>
/// Creates a new saga state entry.
/// </summary>
/// <param name="state">The saga state to create.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created saga state.</returns>
Task<SagaState> CreateAsync(SagaState state, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a saga state by its ID.
/// </summary>
/// <param name="sagaId">The saga instance ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The saga state, or null if not found.</returns>
Task<SagaState?> GetByIdAsync(Guid sagaId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a saga state by its correlation ID.
/// </summary>
/// <param name="correlationId">The correlation ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The saga state, or null if not found.</returns>
Task<SagaState?> GetByCorrelationIdAsync(Guid correlationId, CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing saga state.
/// </summary>
/// <param name="state">The saga state to update.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The updated saga state.</returns>
Task<SagaState> UpdateAsync(SagaState state, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all pending (in progress) sagas.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of pending saga states.</returns>
Task<IReadOnlyList<SagaState>> GetPendingSagasAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets all sagas with a specific status.
/// </summary>
/// <param name="status">The status to filter by.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of saga states with the specified status.</returns>
Task<IReadOnlyList<SagaState>> GetSagasByStatusAsync(SagaStatus status, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
namespace Svrnty.CQRS.Sagas.Abstractions;
/// <summary>
/// Represents the persistent state of a saga instance.
/// </summary>
public class SagaState
{
/// <summary>
/// Unique identifier for this saga instance.
/// </summary>
public Guid SagaId { get; set; } = Guid.NewGuid();
/// <summary>
/// The fully qualified type name of the saga.
/// </summary>
public string SagaType { get; set; } = string.Empty;
/// <summary>
/// Correlation ID for tracing across services.
/// </summary>
public Guid CorrelationId { get; set; }
/// <summary>
/// Current execution status.
/// </summary>
public SagaStatus Status { get; set; } = SagaStatus.NotStarted;
/// <summary>
/// Index of the current step being executed.
/// </summary>
public int CurrentStepIndex { get; set; }
/// <summary>
/// Name of the current step being executed.
/// </summary>
public string? CurrentStepName { get; set; }
/// <summary>
/// Results from completed steps, keyed by step name.
/// </summary>
public Dictionary<string, object?> StepResults { get; set; } = new();
/// <summary>
/// Names of steps that have been completed.
/// </summary>
public List<string> CompletedSteps { get; set; } = new();
/// <summary>
/// Errors that occurred during saga execution.
/// </summary>
public List<SagaStepError> Errors { get; set; } = new();
/// <summary>
/// Serialized saga data (JSON).
/// </summary>
public string? SerializedData { get; set; }
/// <summary>
/// When the saga was created.
/// </summary>
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
/// <summary>
/// When the saga was last updated.
/// </summary>
public DateTimeOffset? UpdatedAt { get; set; }
/// <summary>
/// When the saga completed (successfully or compensated).
/// </summary>
public DateTimeOffset? CompletedAt { get; set; }
}
/// <summary>
/// Represents an error that occurred during saga step execution.
/// </summary>
public record SagaStepError(
string StepName,
string ErrorMessage,
string? StackTrace,
DateTimeOffset OccurredAt
);
@@ -0,0 +1,37 @@
namespace Svrnty.CQRS.Sagas.Abstractions;
/// <summary>
/// Represents the execution state of a saga.
/// </summary>
public enum SagaStatus
{
/// <summary>
/// Saga has not started execution.
/// </summary>
NotStarted,
/// <summary>
/// Saga is currently executing steps.
/// </summary>
InProgress,
/// <summary>
/// Saga completed successfully.
/// </summary>
Completed,
/// <summary>
/// Saga failed and compensation has not been triggered.
/// </summary>
Failed,
/// <summary>
/// Saga is currently executing compensation steps.
/// </summary>
Compensating,
/// <summary>
/// Saga has been compensated (rolled back) successfully.
/// </summary>
Compensated
}
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsAotCompatible>true</IsAotCompatible>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<Company>Svrnty</Company>
<Authors>David Lebee, Mathias Beaulieu-Duncan</Authors>
<PackageIcon>icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://git.openharbor.io/svrnty/dotnet-cqrs</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<DebugType>portable</DebugType>
<DebugSymbols>true</DebugSymbols>
<IncludeSymbols>true</IncludeSymbols>
<IncludeSource>true</IncludeSource>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<ItemGroup>
<None Include="..\icon.png" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
</ItemGroup>
</Project>
@@ -0,0 +1,60 @@
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Svrnty.CQRS.Configuration;
using Svrnty.CQRS.Sagas.Abstractions.Messaging;
namespace Svrnty.CQRS.Sagas.RabbitMQ;
/// <summary>
/// Extensions for adding RabbitMQ saga transport to the CQRS pipeline.
/// </summary>
public static class CqrsBuilderExtensions
{
/// <summary>
/// Uses RabbitMQ as the message transport for sagas.
/// </summary>
/// <param name="builder">The CQRS builder.</param>
/// <param name="configure">Configuration action for RabbitMQ options.</param>
/// <returns>The CQRS builder for chaining.</returns>
public static CqrsBuilder UseRabbitMq(this CqrsBuilder builder, Action<RabbitMqSagaOptions> configure)
{
var options = new RabbitMqSagaOptions();
configure(options);
builder.Services.Configure<RabbitMqSagaOptions>(opt =>
{
opt.HostName = options.HostName;
opt.Port = options.Port;
opt.UserName = options.UserName;
opt.Password = options.Password;
opt.VirtualHost = options.VirtualHost;
opt.CommandExchange = options.CommandExchange;
opt.ResponseExchange = options.ResponseExchange;
opt.QueuePrefix = options.QueuePrefix;
opt.DurableQueues = options.DurableQueues;
opt.PrefetchCount = options.PrefetchCount;
opt.ConnectionRetryDelay = options.ConnectionRetryDelay;
opt.MaxConnectionRetries = options.MaxConnectionRetries;
});
// Replace the default message bus with RabbitMQ implementation
builder.Services.RemoveAll<ISagaMessageBus>();
builder.Services.AddSingleton<ISagaMessageBus, RabbitMqSagaMessageBus>();
// Add hosted service for connection management
builder.Services.AddHostedService<RabbitMqSagaHostedService>();
return builder;
}
/// <summary>
/// Uses RabbitMQ as the message transport for sagas with default options.
/// </summary>
/// <param name="builder">The CQRS builder.</param>
/// <returns>The CQRS builder for chaining.</returns>
public static CqrsBuilder UseRabbitMq(this CqrsBuilder builder)
{
return builder.UseRabbitMq(_ => { });
}
}
@@ -0,0 +1,88 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Svrnty.CQRS.Sagas.Abstractions;
using Svrnty.CQRS.Sagas.Abstractions.Messaging;
namespace Svrnty.CQRS.Sagas.RabbitMQ;
/// <summary>
/// Hosted service that manages RabbitMQ saga connections and subscriptions.
/// </summary>
public class RabbitMqSagaHostedService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ISagaMessageBus _messageBus;
private readonly ILogger<RabbitMqSagaHostedService> _logger;
/// <summary>
/// Creates a new RabbitMQ saga hosted service.
/// </summary>
public RabbitMqSagaHostedService(
IServiceProvider serviceProvider,
ISagaMessageBus messageBus,
ILogger<RabbitMqSagaHostedService> logger)
{
_serviceProvider = serviceProvider;
_messageBus = messageBus;
_logger = logger;
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Starting RabbitMQ saga hosted service");
try
{
// Subscribe to saga responses so the orchestrator can process them
await _messageBus.SubscribeToResponsesAsync(
async (response, ct) =>
{
using var scope = _serviceProvider.CreateScope();
var orchestrator = scope.ServiceProvider.GetRequiredService<ISagaOrchestrator>();
// The orchestrator needs to handle responses
// This is a simplified approach - in production you'd want to handle this more robustly
_logger.LogDebug(
"Received response for saga {SagaId}, step {StepName}, success: {Success}",
response.SagaId, response.StepName, response.Success);
// For now, we just log the response
// The orchestrator's HandleResponseAsync method would be called here
// but it requires knowing the saga data type, which we don't have in this context
},
stoppingToken);
_logger.LogInformation("RabbitMQ saga hosted service started successfully");
// Keep the service running
await Task.Delay(Timeout.Infinite, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("RabbitMQ saga hosted service is stopping");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in RabbitMQ saga hosted service");
throw;
}
}
/// <inheritdoc />
public override async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Stopping RabbitMQ saga hosted service");
if (_messageBus is IAsyncDisposable disposable)
{
await disposable.DisposeAsync();
}
await base.StopAsync(cancellationToken);
}
}
@@ -0,0 +1,335 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
using Svrnty.CQRS.Sagas.Abstractions.Messaging;
namespace Svrnty.CQRS.Sagas.RabbitMQ;
/// <summary>
/// RabbitMQ implementation of the saga message bus.
/// </summary>
public class RabbitMqSagaMessageBus : ISagaMessageBus, IAsyncDisposable
{
private readonly RabbitMqSagaOptions _options;
private readonly ILogger<RabbitMqSagaMessageBus> _logger;
private IConnection? _connection;
private IChannel? _publishChannel;
private readonly ConcurrentDictionary<string, IChannel> _subscriptionChannels = new();
private readonly SemaphoreSlim _connectionLock = new(1, 1);
private bool _disposed;
/// <summary>
/// Creates a new RabbitMQ saga message bus.
/// </summary>
public RabbitMqSagaMessageBus(
IOptions<RabbitMqSagaOptions> options,
ILogger<RabbitMqSagaMessageBus> logger)
{
_options = options.Value;
_logger = logger;
}
/// <inheritdoc />
public async Task PublishAsync(SagaMessage message, CancellationToken cancellationToken = default)
{
await EnsureConnectionAsync(cancellationToken);
var routingKey = $"saga.command.{message.CommandType}";
var body = JsonSerializer.SerializeToUtf8Bytes(message);
var properties = new BasicProperties
{
MessageId = message.MessageId.ToString(),
CorrelationId = message.CorrelationId.ToString(),
ContentType = "application/json",
DeliveryMode = _options.DurableQueues ? DeliveryModes.Persistent : DeliveryModes.Transient,
Timestamp = new AmqpTimestamp(message.Timestamp.ToUnixTimeSeconds()),
Headers = new Dictionary<string, object?>
{
["saga-id"] = message.SagaId.ToString(),
["step-name"] = message.StepName,
["is-compensation"] = message.IsCompensation.ToString()
}
};
await _publishChannel!.BasicPublishAsync(
exchange: _options.CommandExchange,
routingKey: routingKey,
mandatory: false,
basicProperties: properties,
body: body,
cancellationToken: cancellationToken);
_logger.LogDebug(
"Published saga command {CommandType} for saga {SagaId}, step {StepName}",
message.CommandType, message.SagaId, message.StepName);
}
/// <inheritdoc />
public async Task PublishResponseAsync(SagaStepResponse response, CancellationToken cancellationToken = default)
{
await EnsureConnectionAsync(cancellationToken);
var routingKey = $"saga.response.{response.SagaId}";
var body = JsonSerializer.SerializeToUtf8Bytes(response);
var properties = new BasicProperties
{
MessageId = response.MessageId.ToString(),
CorrelationId = response.CorrelationId.ToString(),
ContentType = "application/json",
DeliveryMode = _options.DurableQueues ? DeliveryModes.Persistent : DeliveryModes.Transient,
Timestamp = new AmqpTimestamp(response.Timestamp.ToUnixTimeSeconds()),
Headers = new Dictionary<string, object?>
{
["saga-id"] = response.SagaId.ToString(),
["step-name"] = response.StepName,
["success"] = response.Success.ToString()
}
};
await _publishChannel!.BasicPublishAsync(
exchange: _options.ResponseExchange,
routingKey: routingKey,
mandatory: false,
basicProperties: properties,
body: body,
cancellationToken: cancellationToken);
_logger.LogDebug(
"Published saga response for saga {SagaId}, step {StepName}, success: {Success}",
response.SagaId, response.StepName, response.Success);
}
/// <inheritdoc />
public async Task SubscribeAsync<TCommand>(
Func<SagaMessage, TCommand, CancellationToken, Task<SagaStepResponse>> handler,
CancellationToken cancellationToken = default)
where TCommand : class
{
await EnsureConnectionAsync(cancellationToken);
var commandTypeName = typeof(TCommand).FullName!;
var queueName = $"{_options.QueuePrefix}.{SanitizeQueueName(commandTypeName)}";
var routingKey = $"saga.command.{commandTypeName}";
var channel = await _connection!.CreateChannelAsync(cancellationToken: cancellationToken);
_subscriptionChannels[commandTypeName] = channel;
// Declare queue
await channel.QueueDeclareAsync(
queue: queueName,
durable: _options.DurableQueues,
exclusive: false,
autoDelete: false,
cancellationToken: cancellationToken);
// Bind to command exchange
await channel.QueueBindAsync(
queue: queueName,
exchange: _options.CommandExchange,
routingKey: routingKey,
cancellationToken: cancellationToken);
await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: _options.PrefetchCount, global: false, cancellationToken: cancellationToken);
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.ReceivedAsync += async (sender, ea) =>
{
try
{
var messageJson = Encoding.UTF8.GetString(ea.Body.ToArray());
var message = JsonSerializer.Deserialize<SagaMessage>(messageJson);
if (message == null)
{
_logger.LogWarning("Received null saga message");
await channel.BasicNackAsync(ea.DeliveryTag, false, false, cancellationToken);
return;
}
var command = JsonSerializer.Deserialize<TCommand>(message.Payload!);
if (command == null)
{
_logger.LogWarning("Failed to deserialize command {CommandType}", commandTypeName);
await channel.BasicNackAsync(ea.DeliveryTag, false, false, cancellationToken);
return;
}
var response = await handler(message, command, cancellationToken);
await PublishResponseAsync(response, cancellationToken);
await channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing saga command {CommandType}", commandTypeName);
await channel.BasicNackAsync(ea.DeliveryTag, false, true, cancellationToken);
}
};
await channel.BasicConsumeAsync(queueName, false, consumer, cancellationToken);
_logger.LogInformation(
"Subscribed to saga commands of type {CommandType} on queue {QueueName}",
commandTypeName, queueName);
}
/// <inheritdoc />
public async Task SubscribeToResponsesAsync(
Func<SagaStepResponse, CancellationToken, Task> handler,
CancellationToken cancellationToken = default)
{
await EnsureConnectionAsync(cancellationToken);
var queueName = $"{_options.QueuePrefix}.responses";
var routingKey = "saga.response.#";
var channel = await _connection!.CreateChannelAsync(cancellationToken: cancellationToken);
_subscriptionChannels["responses"] = channel;
// Declare queue
await channel.QueueDeclareAsync(
queue: queueName,
durable: _options.DurableQueues,
exclusive: false,
autoDelete: false,
cancellationToken: cancellationToken);
// Bind to response exchange
await channel.QueueBindAsync(
queue: queueName,
exchange: _options.ResponseExchange,
routingKey: routingKey,
cancellationToken: cancellationToken);
await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: _options.PrefetchCount, global: false, cancellationToken: cancellationToken);
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.ReceivedAsync += async (sender, ea) =>
{
try
{
var responseJson = Encoding.UTF8.GetString(ea.Body.ToArray());
var response = JsonSerializer.Deserialize<SagaStepResponse>(responseJson);
if (response == null)
{
_logger.LogWarning("Received null saga response");
await channel.BasicNackAsync(ea.DeliveryTag, false, false, cancellationToken);
return;
}
await handler(response, cancellationToken);
await channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing saga response");
await channel.BasicNackAsync(ea.DeliveryTag, false, true, cancellationToken);
}
};
await channel.BasicConsumeAsync(queueName, false, consumer, cancellationToken);
_logger.LogInformation("Subscribed to saga responses on queue {QueueName}", queueName);
}
private async Task EnsureConnectionAsync(CancellationToken cancellationToken)
{
if (_connection?.IsOpen == true && _publishChannel?.IsOpen == true)
{
return;
}
await _connectionLock.WaitAsync(cancellationToken);
try
{
if (_connection?.IsOpen == true && _publishChannel?.IsOpen == true)
{
return;
}
var factory = new ConnectionFactory
{
HostName = _options.HostName,
Port = _options.Port,
UserName = _options.UserName,
Password = _options.Password,
VirtualHost = _options.VirtualHost
};
_connection = await factory.CreateConnectionAsync(cancellationToken);
_publishChannel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken);
// Declare exchanges
await _publishChannel.ExchangeDeclareAsync(
exchange: _options.CommandExchange,
type: ExchangeType.Topic,
durable: _options.DurableQueues,
autoDelete: false,
cancellationToken: cancellationToken);
await _publishChannel.ExchangeDeclareAsync(
exchange: _options.ResponseExchange,
type: ExchangeType.Topic,
durable: _options.DurableQueues,
autoDelete: false,
cancellationToken: cancellationToken);
_logger.LogInformation(
"Connected to RabbitMQ at {Host}:{Port}",
_options.HostName, _options.Port);
}
finally
{
_connectionLock.Release();
}
}
private static string SanitizeQueueName(string name)
{
return name.Replace(".", "-").Replace("+", "-").ToLowerInvariant();
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
foreach (var channel in _subscriptionChannels.Values)
{
if (channel.IsOpen)
{
await channel.CloseAsync();
}
channel.Dispose();
}
if (_publishChannel?.IsOpen == true)
{
await _publishChannel.CloseAsync();
}
_publishChannel?.Dispose();
if (_connection?.IsOpen == true)
{
await _connection.CloseAsync();
}
_connection?.Dispose();
_connectionLock.Dispose();
}
}
@@ -0,0 +1,69 @@
using System;
namespace Svrnty.CQRS.Sagas.RabbitMQ;
/// <summary>
/// Configuration options for RabbitMQ saga transport.
/// </summary>
public class RabbitMqSagaOptions
{
/// <summary>
/// RabbitMQ host name (default: localhost).
/// </summary>
public string HostName { get; set; } = "localhost";
/// <summary>
/// RabbitMQ port (default: 5672).
/// </summary>
public int Port { get; set; } = 5672;
/// <summary>
/// RabbitMQ user name (default: guest).
/// </summary>
public string UserName { get; set; } = "guest";
/// <summary>
/// RabbitMQ password (default: guest).
/// </summary>
public string Password { get; set; } = "guest";
/// <summary>
/// RabbitMQ virtual host (default: /).
/// </summary>
public string VirtualHost { get; set; } = "/";
/// <summary>
/// Exchange name for saga commands (default: svrnty.sagas.commands).
/// </summary>
public string CommandExchange { get; set; } = "svrnty.sagas.commands";
/// <summary>
/// Exchange name for saga responses (default: svrnty.sagas.responses).
/// </summary>
public string ResponseExchange { get; set; } = "svrnty.sagas.responses";
/// <summary>
/// Queue name prefix for this service (default: saga-service).
/// </summary>
public string QueuePrefix { get; set; } = "saga-service";
/// <summary>
/// Whether to use durable queues (default: true).
/// </summary>
public bool DurableQueues { get; set; } = true;
/// <summary>
/// Prefetch count for consumers (default: 10).
/// </summary>
public ushort PrefetchCount { get; set; } = 10;
/// <summary>
/// Connection retry delay (default: 5 seconds).
/// </summary>
public TimeSpan ConnectionRetryDelay { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Maximum connection retry attempts (default: 10).
/// </summary>
public int MaxConnectionRetries { get; set; } = 10;
}
@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsAotCompatible>false</IsAotCompatible>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<Company>Svrnty</Company>
<Authors>David Lebee, Mathias Beaulieu-Duncan</Authors>
<PackageIcon>icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://git.openharbor.io/svrnty/dotnet-cqrs</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<DebugType>portable</DebugType>
<DebugSymbols>true</DebugSymbols>
<IncludeSymbols>true</IncludeSymbols>
<IncludeSource>true</IncludeSource>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<ItemGroup>
<None Include="..\icon.png" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Svrnty.CQRS.Sagas\Svrnty.CQRS.Sagas.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="RabbitMQ.Client" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
</ItemGroup>
</Project>
@@ -0,0 +1,54 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Svrnty.CQRS.Sagas.Abstractions;
namespace Svrnty.CQRS.Sagas.Builders;
/// <summary>
/// Builder for configuring local saga steps.
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
public class LocalSagaStepBuilder<TData> : ISagaStepBuilder<TData>
where TData : class, ISagaData
{
private readonly SagaBuilder<TData> _parent;
private readonly LocalSagaStepDefinition<TData> _definition;
/// <summary>
/// Creates a new local step builder.
/// </summary>
/// <param name="parent">The parent saga builder.</param>
/// <param name="name">The step name.</param>
/// <param name="order">The step order.</param>
public LocalSagaStepBuilder(SagaBuilder<TData> parent, string name, int order)
{
_parent = parent;
_definition = new LocalSagaStepDefinition<TData>
{
Name = name,
Order = order
};
}
/// <inheritdoc />
public ISagaStepBuilder<TData> Execute(Func<TData, ISagaContext, CancellationToken, Task> action)
{
_definition.ExecuteAction = action;
return this;
}
/// <inheritdoc />
public ISagaStepBuilder<TData> Compensate(Func<TData, ISagaContext, CancellationToken, Task> action)
{
_definition.CompensateAction = action;
return this;
}
/// <inheritdoc />
public ISagaBuilder<TData> Then()
{
_parent.AddStep(_definition);
return _parent;
}
}
@@ -0,0 +1,158 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Svrnty.CQRS.Sagas.Abstractions;
namespace Svrnty.CQRS.Sagas.Builders;
/// <summary>
/// Builder for configuring remote saga steps (without result).
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
/// <typeparam name="TCommand">The command type.</typeparam>
public class RemoteSagaStepBuilder<TData, TCommand> : ISagaRemoteStepBuilder<TData, TCommand>
where TData : class, ISagaData
where TCommand : class
{
private readonly SagaBuilder<TData> _parent;
private readonly RemoteSagaStepDefinition<TData, TCommand> _definition;
/// <summary>
/// Creates a new remote step builder.
/// </summary>
/// <param name="parent">The parent saga builder.</param>
/// <param name="name">The step name.</param>
/// <param name="order">The step order.</param>
public RemoteSagaStepBuilder(SagaBuilder<TData> parent, string name, int order)
{
_parent = parent;
_definition = new RemoteSagaStepDefinition<TData, TCommand>
{
Name = name,
Order = order
};
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand> WithCommand(Func<TData, ISagaContext, TCommand> commandBuilder)
{
_definition.CommandBuilder = commandBuilder;
return this;
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand> OnResponse(Func<TData, ISagaContext, CancellationToken, Task> handler)
{
_definition.ResponseHandler = handler;
return this;
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand> Compensate<TCompensationCommand>(
Func<TData, ISagaContext, TCompensationCommand> compensationBuilder)
where TCompensationCommand : class
{
_definition.CompensationCommandType = typeof(TCompensationCommand);
_definition.CompensationBuilder = (data, ctx) => compensationBuilder(data, ctx);
return this;
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand> WithTimeout(TimeSpan timeout)
{
_definition.Timeout = timeout;
return this;
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand> WithRetry(int maxRetries, TimeSpan delay)
{
_definition.MaxRetries = maxRetries;
_definition.RetryDelay = delay;
return this;
}
/// <inheritdoc />
public ISagaBuilder<TData> Then()
{
_parent.AddStep(_definition);
return _parent;
}
}
/// <summary>
/// Builder for configuring remote saga steps with result.
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
/// <typeparam name="TCommand">The command type.</typeparam>
/// <typeparam name="TResult">The result type.</typeparam>
public class RemoteSagaStepBuilderWithResult<TData, TCommand, TResult> : ISagaRemoteStepBuilder<TData, TCommand, TResult>
where TData : class, ISagaData
where TCommand : class
{
private readonly SagaBuilder<TData> _parent;
private readonly RemoteSagaStepDefinition<TData, TCommand, TResult> _definition;
/// <summary>
/// Creates a new remote step builder with result.
/// </summary>
/// <param name="parent">The parent saga builder.</param>
/// <param name="name">The step name.</param>
/// <param name="order">The step order.</param>
public RemoteSagaStepBuilderWithResult(SagaBuilder<TData> parent, string name, int order)
{
_parent = parent;
_definition = new RemoteSagaStepDefinition<TData, TCommand, TResult>
{
Name = name,
Order = order
};
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand, TResult> WithCommand(Func<TData, ISagaContext, TCommand> commandBuilder)
{
_definition.CommandBuilder = commandBuilder;
return this;
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand, TResult> OnResponse(
Func<TData, ISagaContext, TResult, CancellationToken, Task> handler)
{
_definition.ResponseHandler = handler;
return this;
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand, TResult> Compensate<TCompensationCommand>(
Func<TData, ISagaContext, TCompensationCommand> compensationBuilder)
where TCompensationCommand : class
{
_definition.CompensationCommandType = typeof(TCompensationCommand);
_definition.CompensationBuilder = (data, ctx) => compensationBuilder(data, ctx);
return this;
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand, TResult> WithTimeout(TimeSpan timeout)
{
_definition.Timeout = timeout;
return this;
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand, TResult> WithRetry(int maxRetries, TimeSpan delay)
{
_definition.MaxRetries = maxRetries;
_definition.RetryDelay = delay;
return this;
}
/// <inheritdoc />
public ISagaBuilder<TData> Then()
{
_parent.AddStep(_definition);
return _parent;
}
}
+49
View File
@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using Svrnty.CQRS.Sagas.Abstractions;
namespace Svrnty.CQRS.Sagas.Builders;
/// <summary>
/// Implementation of the saga builder for defining saga steps.
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
public class SagaBuilder<TData> : ISagaBuilder<TData>
where TData : class, ISagaData
{
private readonly List<SagaStepDefinition> _steps = new();
/// <summary>
/// Gets the defined steps.
/// </summary>
public IReadOnlyList<SagaStepDefinition> Steps => _steps.AsReadOnly();
/// <inheritdoc />
public ISagaStepBuilder<TData> Step(string name)
{
return new LocalSagaStepBuilder<TData>(this, name, _steps.Count);
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand> SendCommand<TCommand>(string name)
where TCommand : class
{
return new RemoteSagaStepBuilder<TData, TCommand>(this, name, _steps.Count);
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand, TResult> SendCommand<TCommand, TResult>(string name)
where TCommand : class
{
return new RemoteSagaStepBuilderWithResult<TData, TCommand, TResult>(this, name, _steps.Count);
}
/// <summary>
/// Adds a step definition to the builder.
/// </summary>
/// <param name="step">The step definition to add.</param>
internal void AddStep(SagaStepDefinition step)
{
_steps.Add(step);
}
}
@@ -0,0 +1,149 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Svrnty.CQRS.Sagas.Abstractions;
namespace Svrnty.CQRS.Sagas.Builders;
/// <summary>
/// Base class for saga step definitions.
/// </summary>
public abstract class SagaStepDefinition
{
/// <summary>
/// Unique name for this step.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Order of the step in the saga.
/// </summary>
public int Order { get; set; }
/// <summary>
/// Whether this step has a compensation action.
/// </summary>
public abstract bool HasCompensation { get; }
/// <summary>
/// Whether this step is a remote step (sends a command).
/// </summary>
public abstract bool IsRemote { get; }
/// <summary>
/// Timeout for this step.
/// </summary>
public TimeSpan? Timeout { get; set; }
/// <summary>
/// Maximum number of retries.
/// </summary>
public int MaxRetries { get; set; }
/// <summary>
/// Delay between retries.
/// </summary>
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(1);
}
/// <summary>
/// Definition for a local saga step.
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
public class LocalSagaStepDefinition<TData> : SagaStepDefinition
where TData : class, ISagaData
{
/// <summary>
/// The execution action.
/// </summary>
public Func<TData, ISagaContext, CancellationToken, Task>? ExecuteAction { get; set; }
/// <summary>
/// The compensation action.
/// </summary>
public Func<TData, ISagaContext, CancellationToken, Task>? CompensateAction { get; set; }
/// <inheritdoc />
public override bool HasCompensation => CompensateAction != null;
/// <inheritdoc />
public override bool IsRemote => false;
}
/// <summary>
/// Definition for a remote saga step.
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
/// <typeparam name="TCommand">The command type.</typeparam>
public class RemoteSagaStepDefinition<TData, TCommand> : SagaStepDefinition
where TData : class, ISagaData
where TCommand : class
{
/// <summary>
/// Function to build the command.
/// </summary>
public Func<TData, ISagaContext, TCommand>? CommandBuilder { get; set; }
/// <summary>
/// Handler for successful response.
/// </summary>
public Func<TData, ISagaContext, CancellationToken, Task>? ResponseHandler { get; set; }
/// <summary>
/// Type of the compensation command.
/// </summary>
public Type? CompensationCommandType { get; set; }
/// <summary>
/// Function to build the compensation command.
/// </summary>
public Func<TData, ISagaContext, object>? CompensationBuilder { get; set; }
/// <inheritdoc />
public override bool HasCompensation => CompensationBuilder != null;
/// <inheritdoc />
public override bool IsRemote => true;
}
/// <summary>
/// Definition for a remote saga step with result.
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
/// <typeparam name="TCommand">The command type.</typeparam>
/// <typeparam name="TResult">The result type.</typeparam>
public class RemoteSagaStepDefinition<TData, TCommand, TResult> : SagaStepDefinition
where TData : class, ISagaData
where TCommand : class
{
/// <summary>
/// Function to build the command.
/// </summary>
public Func<TData, ISagaContext, TCommand>? CommandBuilder { get; set; }
/// <summary>
/// Handler for successful response with result.
/// </summary>
public Func<TData, ISagaContext, TResult, CancellationToken, Task>? ResponseHandler { get; set; }
/// <summary>
/// Type of the compensation command.
/// </summary>
public Type? CompensationCommandType { get; set; }
/// <summary>
/// Function to build the compensation command.
/// </summary>
public Func<TData, ISagaContext, object>? CompensationBuilder { get; set; }
/// <summary>
/// The expected result type.
/// </summary>
public Type ResultType => typeof(TResult);
/// <inheritdoc />
public override bool HasCompensation => CompensationBuilder != null;
/// <inheritdoc />
public override bool IsRemote => true;
}
@@ -0,0 +1,39 @@
using System;
namespace Svrnty.CQRS.Sagas.Configuration;
/// <summary>
/// Configuration options for saga orchestration.
/// </summary>
public class SagaOptions
{
/// <summary>
/// Default timeout for saga steps (default: 30 seconds).
/// </summary>
public TimeSpan DefaultStepTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Default number of retries for failed steps (default: 3).
/// </summary>
public int DefaultMaxRetries { get; set; } = 3;
/// <summary>
/// Default delay between retries (default: 1 second).
/// </summary>
public TimeSpan DefaultRetryDelay { get; set; } = TimeSpan.FromSeconds(1);
/// <summary>
/// Whether to automatically compensate on failure (default: true).
/// </summary>
public bool AutoCompensateOnFailure { get; set; } = true;
/// <summary>
/// Interval for checking pending/stalled sagas (default: 1 minute).
/// </summary>
public TimeSpan StalledSagaCheckInterval { get; set; } = TimeSpan.FromMinutes(1);
/// <summary>
/// Time after which a saga step is considered stalled (default: 5 minutes).
/// </summary>
public TimeSpan StepStalledTimeout { get; set; } = TimeSpan.FromMinutes(5);
}
@@ -0,0 +1,82 @@
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Svrnty.CQRS.Configuration;
using Svrnty.CQRS.Sagas.Abstractions;
using Svrnty.CQRS.Sagas.Abstractions.Persistence;
using Svrnty.CQRS.Sagas.Configuration;
using Svrnty.CQRS.Sagas.Persistence;
namespace Svrnty.CQRS.Sagas;
/// <summary>
/// Extensions for adding saga support to the CQRS pipeline.
/// </summary>
public static class CqrsBuilderExtensions
{
/// <summary>
/// Adds saga orchestration support to the CQRS pipeline.
/// </summary>
/// <param name="builder">The CQRS builder.</param>
/// <param name="configure">Optional configuration action.</param>
/// <returns>The CQRS builder for chaining.</returns>
public static CqrsBuilder AddSagas(this CqrsBuilder builder, Action<SagaOptions>? configure = null)
{
var options = new SagaOptions();
configure?.Invoke(options);
builder.Services.Configure<SagaOptions>(opt =>
{
opt.DefaultStepTimeout = options.DefaultStepTimeout;
opt.DefaultMaxRetries = options.DefaultMaxRetries;
opt.DefaultRetryDelay = options.DefaultRetryDelay;
opt.AutoCompensateOnFailure = options.AutoCompensateOnFailure;
opt.StalledSagaCheckInterval = options.StalledSagaCheckInterval;
opt.StepStalledTimeout = options.StepStalledTimeout;
});
// Store configuration
builder.Configuration.SetConfiguration(options);
// Register core saga services
builder.Services.TryAddSingleton<ISagaOrchestrator, SagaOrchestrator>();
// Register default in-memory state store if not already registered
builder.Services.TryAddSingleton<ISagaStateStore, InMemorySagaStateStore>();
return builder;
}
/// <summary>
/// Registers a saga type with the CQRS pipeline.
/// </summary>
/// <typeparam name="TSaga">The saga type.</typeparam>
/// <typeparam name="TData">The saga data type.</typeparam>
/// <param name="builder">The CQRS builder.</param>
/// <returns>The CQRS builder for chaining.</returns>
public static CqrsBuilder AddSaga<TSaga, TData>(this CqrsBuilder builder)
where TSaga : class, ISaga<TData>
where TData : class, ISagaData, new()
{
builder.Services.AddTransient<TSaga>();
builder.Services.AddTransient<ISaga<TData>, TSaga>();
return builder;
}
/// <summary>
/// Uses a custom saga state store implementation.
/// </summary>
/// <typeparam name="TStore">The state store implementation type.</typeparam>
/// <param name="builder">The CQRS builder.</param>
/// <returns>The CQRS builder for chaining.</returns>
public static CqrsBuilder UseSagaStateStore<TStore>(this CqrsBuilder builder)
where TStore : class, ISagaStateStore
{
// Remove existing registration
var descriptor = new ServiceDescriptor(typeof(ISagaStateStore), typeof(TStore), ServiceLifetime.Singleton);
builder.Services.Replace(descriptor);
return builder;
}
}
@@ -0,0 +1,68 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Svrnty.CQRS.Sagas.Abstractions;
using Svrnty.CQRS.Sagas.Abstractions.Persistence;
namespace Svrnty.CQRS.Sagas.Persistence;
/// <summary>
/// In-memory saga state store for development and testing.
/// </summary>
public class InMemorySagaStateStore : ISagaStateStore
{
private readonly ConcurrentDictionary<Guid, SagaState> _states = new();
/// <inheritdoc />
public Task<SagaState> CreateAsync(SagaState state, CancellationToken cancellationToken = default)
{
if (!_states.TryAdd(state.SagaId, state))
{
throw new InvalidOperationException($"Saga with ID {state.SagaId} already exists.");
}
return Task.FromResult(state);
}
/// <inheritdoc />
public Task<SagaState?> GetByIdAsync(Guid sagaId, CancellationToken cancellationToken = default)
{
_states.TryGetValue(sagaId, out var state);
return Task.FromResult(state);
}
/// <inheritdoc />
public Task<SagaState?> GetByCorrelationIdAsync(Guid correlationId, CancellationToken cancellationToken = default)
{
var state = _states.Values.FirstOrDefault(s => s.CorrelationId == correlationId);
return Task.FromResult(state);
}
/// <inheritdoc />
public Task<SagaState> UpdateAsync(SagaState state, CancellationToken cancellationToken = default)
{
state.UpdatedAt = DateTimeOffset.UtcNow;
_states[state.SagaId] = state;
return Task.FromResult(state);
}
/// <inheritdoc />
public Task<IReadOnlyList<SagaState>> GetPendingSagasAsync(CancellationToken cancellationToken = default)
{
var pending = _states.Values
.Where(s => s.Status == SagaStatus.InProgress || s.Status == SagaStatus.Compensating)
.ToList();
return Task.FromResult<IReadOnlyList<SagaState>>(pending);
}
/// <inheritdoc />
public Task<IReadOnlyList<SagaState>> GetSagasByStatusAsync(SagaStatus status, CancellationToken cancellationToken = default)
{
var sagas = _states.Values
.Where(s => s.Status == status)
.ToList();
return Task.FromResult<IReadOnlyList<SagaState>>(sagas);
}
}
+56
View File
@@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
using Svrnty.CQRS.Sagas.Abstractions;
namespace Svrnty.CQRS.Sagas;
/// <summary>
/// Implementation of saga context providing runtime information during step execution.
/// </summary>
public class SagaContext : ISagaContext
{
private readonly SagaState _state;
/// <summary>
/// Creates a new saga context from a saga state.
/// </summary>
/// <param name="state">The saga state.</param>
public SagaContext(SagaState state)
{
_state = state ?? throw new ArgumentNullException(nameof(state));
}
/// <inheritdoc />
public Guid SagaId => _state.SagaId;
/// <inheritdoc />
public Guid CorrelationId => _state.CorrelationId;
/// <inheritdoc />
public string SagaType => _state.SagaType;
/// <inheritdoc />
public int CurrentStepIndex => _state.CurrentStepIndex;
/// <inheritdoc />
public string CurrentStepName => _state.CurrentStepName ?? string.Empty;
/// <inheritdoc />
public IReadOnlyDictionary<string, object?> StepResults => _state.StepResults;
/// <inheritdoc />
public T? GetStepResult<T>(string stepName)
{
if (_state.StepResults.TryGetValue(stepName, out var value) && value is T result)
{
return result;
}
return default;
}
/// <inheritdoc />
public void SetStepResult<T>(T result)
{
_state.StepResults[CurrentStepName] = result;
}
}
+429
View File
@@ -0,0 +1,429 @@
using System;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Svrnty.CQRS.Sagas.Abstractions;
using Svrnty.CQRS.Sagas.Abstractions.Messaging;
using Svrnty.CQRS.Sagas.Abstractions.Persistence;
using Svrnty.CQRS.Sagas.Builders;
using Svrnty.CQRS.Sagas.Configuration;
namespace Svrnty.CQRS.Sagas;
/// <summary>
/// Implementation of saga orchestration.
/// </summary>
public class SagaOrchestrator : ISagaOrchestrator
{
private readonly IServiceProvider _serviceProvider;
private readonly ISagaStateStore _stateStore;
private readonly ISagaMessageBus? _messageBus;
private readonly ILogger<SagaOrchestrator> _logger;
private readonly SagaOptions _options;
/// <summary>
/// Creates a new saga orchestrator.
/// </summary>
public SagaOrchestrator(
IServiceProvider serviceProvider,
ISagaStateStore stateStore,
IOptions<SagaOptions> options,
ILogger<SagaOrchestrator> logger,
ISagaMessageBus? messageBus = null)
{
_serviceProvider = serviceProvider;
_stateStore = stateStore;
_messageBus = messageBus;
_logger = logger;
_options = options.Value;
}
/// <inheritdoc />
public Task<SagaState> StartAsync<TSaga, TData>(TData initialData, CancellationToken cancellationToken = default)
where TSaga : ISaga<TData>
where TData : class, ISagaData, new()
{
return StartAsync<TSaga, TData>(initialData, Guid.NewGuid(), cancellationToken);
}
/// <inheritdoc />
public async Task<SagaState> StartAsync<TSaga, TData>(
TData initialData,
Guid correlationId,
CancellationToken cancellationToken = default)
where TSaga : ISaga<TData>
where TData : class, ISagaData, new()
{
initialData.CorrelationId = correlationId;
// Get the saga instance and configure it
var saga = _serviceProvider.GetRequiredService<TSaga>();
var builder = new SagaBuilder<TData>();
saga.Configure(builder);
var steps = builder.Steps;
if (steps.Count == 0)
{
throw new InvalidOperationException($"Saga {typeof(TSaga).Name} has no steps configured.");
}
// Create initial state
var state = new SagaState
{
SagaType = typeof(TSaga).FullName!,
CorrelationId = correlationId,
Status = SagaStatus.InProgress,
CurrentStepIndex = 0,
CurrentStepName = steps[0].Name,
SerializedData = JsonSerializer.Serialize(initialData)
};
state = await _stateStore.CreateAsync(state, cancellationToken);
_logger.LogInformation(
"Started saga {SagaType} with ID {SagaId} and CorrelationId {CorrelationId}",
state.SagaType, state.SagaId, state.CorrelationId);
// Execute the first step
await ExecuteNextStepAsync<TData>(state, steps, initialData, cancellationToken);
return state;
}
/// <inheritdoc />
public Task<SagaState?> GetStateAsync(Guid sagaId, CancellationToken cancellationToken = default)
{
return _stateStore.GetByIdAsync(sagaId, cancellationToken);
}
/// <inheritdoc />
public Task<SagaState?> GetStateByCorrelationIdAsync(Guid correlationId, CancellationToken cancellationToken = default)
{
return _stateStore.GetByCorrelationIdAsync(correlationId, cancellationToken);
}
/// <summary>
/// Handles a response from a remote step.
/// </summary>
public async Task HandleResponseAsync<TData>(
SagaStepResponse response,
CancellationToken cancellationToken = default)
where TData : class, ISagaData, new()
{
var state = await _stateStore.GetByIdAsync(response.SagaId, cancellationToken);
if (state == null)
{
_logger.LogWarning("Received response for unknown saga {SagaId}", response.SagaId);
return;
}
var data = JsonSerializer.Deserialize<TData>(state.SerializedData!);
if (data == null)
{
_logger.LogError("Failed to deserialize saga data for {SagaId}", response.SagaId);
return;
}
// Get the saga definition
var sagaType = Type.GetType(state.SagaType);
if (sagaType == null)
{
_logger.LogError("Unknown saga type {SagaType}", state.SagaType);
return;
}
var saga = _serviceProvider.GetService(sagaType) as ISaga<TData>;
if (saga == null)
{
_logger.LogError("Could not resolve saga {SagaType}", state.SagaType);
return;
}
var builder = new SagaBuilder<TData>();
saga.Configure(builder);
var steps = builder.Steps;
if (response.Success)
{
_logger.LogInformation(
"Step {StepName} completed successfully for saga {SagaId}",
response.StepName, response.SagaId);
state.CompletedSteps.Add(response.StepName);
state.CurrentStepIndex++;
if (state.CurrentStepIndex >= steps.Count)
{
// Saga completed
state.Status = SagaStatus.Completed;
state.CompletedAt = DateTimeOffset.UtcNow;
await _stateStore.UpdateAsync(state, cancellationToken);
_logger.LogInformation("Saga {SagaId} completed successfully", state.SagaId);
}
else
{
// Move to next step
state.CurrentStepName = steps[state.CurrentStepIndex].Name;
await _stateStore.UpdateAsync(state, cancellationToken);
await ExecuteNextStepAsync(state, steps, data, cancellationToken);
}
}
else
{
_logger.LogError(
"Step {StepName} failed for saga {SagaId}: {Error}",
response.StepName, response.SagaId, response.ErrorMessage);
state.Errors.Add(new SagaStepError(
response.StepName,
response.ErrorMessage ?? "Unknown error",
response.StackTrace,
DateTimeOffset.UtcNow));
if (_options.AutoCompensateOnFailure)
{
await StartCompensationAsync(state, steps, data, cancellationToken);
}
else
{
state.Status = SagaStatus.Failed;
await _stateStore.UpdateAsync(state, cancellationToken);
}
}
}
private async Task ExecuteNextStepAsync<TData>(
SagaState state,
System.Collections.Generic.IReadOnlyList<SagaStepDefinition> steps,
TData data,
CancellationToken cancellationToken)
where TData : class, ISagaData
{
if (state.CurrentStepIndex >= steps.Count)
{
state.Status = SagaStatus.Completed;
state.CompletedAt = DateTimeOffset.UtcNow;
await _stateStore.UpdateAsync(state, cancellationToken);
return;
}
var step = steps[state.CurrentStepIndex];
var context = new SagaContext(state);
_logger.LogDebug(
"Executing step {StepName} ({StepIndex}/{TotalSteps}) for saga {SagaId}",
step.Name, state.CurrentStepIndex + 1, steps.Count, state.SagaId);
try
{
if (step.IsRemote)
{
await ExecuteRemoteStepAsync(state, step, data, context, cancellationToken);
}
else
{
await ExecuteLocalStepAsync(state, step, data, context, steps, cancellationToken);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error executing step {StepName} for saga {SagaId}", step.Name, state.SagaId);
state.Errors.Add(new SagaStepError(
step.Name,
ex.Message,
ex.StackTrace,
DateTimeOffset.UtcNow));
if (_options.AutoCompensateOnFailure)
{
await StartCompensationAsync(state, steps, data, cancellationToken);
}
else
{
state.Status = SagaStatus.Failed;
await _stateStore.UpdateAsync(state, cancellationToken);
}
}
}
private async Task ExecuteLocalStepAsync<TData>(
SagaState state,
SagaStepDefinition step,
TData data,
SagaContext context,
System.Collections.Generic.IReadOnlyList<SagaStepDefinition> steps,
CancellationToken cancellationToken)
where TData : class, ISagaData
{
if (step is LocalSagaStepDefinition<TData> localStep && localStep.ExecuteAction != null)
{
await localStep.ExecuteAction(data, context, cancellationToken);
}
// Local step completed, update state and continue
state.CompletedSteps.Add(step.Name);
state.SerializedData = JsonSerializer.Serialize(data);
state.CurrentStepIndex++;
if (state.CurrentStepIndex < steps.Count)
{
state.CurrentStepName = steps[state.CurrentStepIndex].Name;
}
await _stateStore.UpdateAsync(state, cancellationToken);
// Continue to next step
await ExecuteNextStepAsync(state, steps, data, cancellationToken);
}
private async Task ExecuteRemoteStepAsync<TData>(
SagaState state,
SagaStepDefinition step,
TData data,
SagaContext context,
CancellationToken cancellationToken)
where TData : class, ISagaData
{
if (_messageBus == null)
{
throw new InvalidOperationException(
"Remote saga steps require a message bus. Configure RabbitMQ or another transport.");
}
object? command = null;
string commandType;
// Get the command from the step definition
var stepType = step.GetType();
var commandBuilderProp = stepType.GetProperty("CommandBuilder");
if (commandBuilderProp?.GetValue(step) is Delegate commandBuilder)
{
command = commandBuilder.DynamicInvoke(data, context);
}
if (command == null)
{
throw new InvalidOperationException($"Step {step.Name} did not produce a command.");
}
commandType = command.GetType().FullName!;
var message = new SagaMessage
{
SagaId = state.SagaId,
CorrelationId = state.CorrelationId,
StepName = step.Name,
CommandType = commandType,
Payload = JsonSerializer.Serialize(command, command.GetType())
};
await _messageBus.PublishAsync(message, cancellationToken);
await _stateStore.UpdateAsync(state, cancellationToken);
_logger.LogDebug(
"Published command {CommandType} for step {StepName} of saga {SagaId}",
commandType, step.Name, state.SagaId);
}
private async Task StartCompensationAsync<TData>(
SagaState state,
System.Collections.Generic.IReadOnlyList<SagaStepDefinition> steps,
TData data,
CancellationToken cancellationToken)
where TData : class, ISagaData
{
_logger.LogInformation("Starting compensation for saga {SagaId}", state.SagaId);
state.Status = SagaStatus.Compensating;
await _stateStore.UpdateAsync(state, cancellationToken);
// Execute compensation in reverse order
var context = new SagaContext(state);
var completedSteps = state.CompletedSteps.ToList();
for (var i = completedSteps.Count - 1; i >= 0; i--)
{
var stepName = completedSteps[i];
var step = steps.FirstOrDefault(s => s.Name == stepName);
if (step == null || !step.HasCompensation)
{
continue;
}
_logger.LogDebug("Compensating step {StepName} for saga {SagaId}", stepName, state.SagaId);
try
{
if (step.IsRemote)
{
await ExecuteRemoteCompensationAsync(state, step, data, context, cancellationToken);
}
else if (step is LocalSagaStepDefinition<TData> localStep && localStep.CompensateAction != null)
{
await localStep.CompensateAction(data, context, cancellationToken);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during compensation of step {StepName} for saga {SagaId}",
stepName, state.SagaId);
// Continue with other compensations even if one fails
}
}
state.Status = SagaStatus.Compensated;
state.CompletedAt = DateTimeOffset.UtcNow;
await _stateStore.UpdateAsync(state, cancellationToken);
_logger.LogInformation("Saga {SagaId} compensation completed", state.SagaId);
}
private async Task ExecuteRemoteCompensationAsync<TData>(
SagaState state,
SagaStepDefinition step,
TData data,
SagaContext context,
CancellationToken cancellationToken)
where TData : class, ISagaData
{
if (_messageBus == null)
{
return;
}
var stepType = step.GetType();
var compensationBuilderProp = stepType.GetProperty("CompensationBuilder");
var compensationTypeProp = stepType.GetProperty("CompensationCommandType");
if (compensationBuilderProp?.GetValue(step) is Delegate compensationBuilder &&
compensationTypeProp?.GetValue(step) is Type compensationType)
{
var compensationCommand = compensationBuilder.DynamicInvoke(data, context);
if (compensationCommand != null)
{
var message = new SagaMessage
{
SagaId = state.SagaId,
CorrelationId = state.CorrelationId,
StepName = step.Name,
CommandType = compensationType.FullName!,
Payload = JsonSerializer.Serialize(compensationCommand, compensationType),
IsCompensation = true
};
await _messageBus.PublishAsync(message, cancellationToken);
_logger.LogDebug(
"Published compensation command {CommandType} for step {StepName} of saga {SagaId}",
compensationType.Name, step.Name, state.SagaId);
}
}
}
}
@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsAotCompatible>true</IsAotCompatible>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<Company>Svrnty</Company>
<Authors>David Lebee, Mathias Beaulieu-Duncan</Authors>
<PackageIcon>icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://git.openharbor.io/svrnty/dotnet-cqrs</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<DebugType>portable</DebugType>
<DebugSymbols>true</DebugSymbols>
<IncludeSymbols>true</IncludeSymbols>
<IncludeSource>true</IncludeSource>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<ItemGroup>
<None Include="..\icon.png" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Svrnty.CQRS\Svrnty.CQRS.csproj" />
<ProjectReference Include="..\Svrnty.CQRS.Sagas.Abstractions\Svrnty.CQRS.Sagas.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
</ItemGroup>
</Project>
+84
View File
@@ -31,6 +31,18 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.Sample", "Svrnty.Sam
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.DynamicQuery.MinimalApi", "Svrnty.CQRS.DynamicQuery.MinimalApi\Svrnty.CQRS.DynamicQuery.MinimalApi.csproj", "{1D0E3388-5E4B-4C0E-B826-ACF256FF7C84}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Sagas.Abstractions", "Svrnty.CQRS.Sagas.Abstractions\Svrnty.CQRS.Sagas.Abstractions.csproj", "{13B6608A-596B-495B-9C08-F9B3F0D1915A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Sagas", "Svrnty.CQRS.Sagas\Svrnty.CQRS.Sagas.csproj", "{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Sagas.RabbitMQ", "Svrnty.CQRS.Sagas.RabbitMQ\Svrnty.CQRS.Sagas.RabbitMQ.csproj", "{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.DynamicQuery.EntityFramework", "Svrnty.CQRS.DynamicQuery.EntityFramework\Svrnty.CQRS.DynamicQuery.EntityFramework.csproj", "{25456A0B-69AF-4251-B34D-2A3873CD8D80}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Events.Abstractions", "Svrnty.CQRS.Events.Abstractions\Svrnty.CQRS.Events.Abstractions.csproj", "{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Events.RabbitMQ", "Svrnty.CQRS.Events.RabbitMQ\Svrnty.CQRS.Events.RabbitMQ.csproj", "{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -173,6 +185,78 @@ Global
{1D0E3388-5E4B-4C0E-B826-ACF256FF7C84}.Release|x64.Build.0 = Release|Any CPU
{1D0E3388-5E4B-4C0E-B826-ACF256FF7C84}.Release|x86.ActiveCfg = Release|Any CPU
{1D0E3388-5E4B-4C0E-B826-ACF256FF7C84}.Release|x86.Build.0 = Release|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Debug|x64.ActiveCfg = Debug|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Debug|x64.Build.0 = Debug|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Debug|x86.ActiveCfg = Debug|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Debug|x86.Build.0 = Debug|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Release|Any CPU.Build.0 = Release|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Release|x64.ActiveCfg = Release|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Release|x64.Build.0 = Release|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Release|x86.ActiveCfg = Release|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Release|x86.Build.0 = Release|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Debug|x64.ActiveCfg = Debug|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Debug|x64.Build.0 = Debug|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Debug|x86.ActiveCfg = Debug|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Debug|x86.Build.0 = Debug|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Release|Any CPU.Build.0 = Release|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Release|x64.ActiveCfg = Release|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Release|x64.Build.0 = Release|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Release|x86.ActiveCfg = Release|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Release|x86.Build.0 = Release|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Debug|x64.ActiveCfg = Debug|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Debug|x64.Build.0 = Debug|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Debug|x86.ActiveCfg = Debug|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Debug|x86.Build.0 = Debug|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Release|Any CPU.Build.0 = Release|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Release|x64.ActiveCfg = Release|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Release|x64.Build.0 = Release|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Release|x86.ActiveCfg = Release|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Release|x86.Build.0 = Release|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Debug|Any CPU.Build.0 = Debug|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Debug|x64.ActiveCfg = Debug|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Debug|x64.Build.0 = Debug|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Debug|x86.ActiveCfg = Debug|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Debug|x86.Build.0 = Debug|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Release|Any CPU.ActiveCfg = Release|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Release|Any CPU.Build.0 = Release|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Release|x64.ActiveCfg = Release|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Release|x64.Build.0 = Release|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Release|x86.ActiveCfg = Release|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Release|x86.Build.0 = Release|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Debug|x64.ActiveCfg = Debug|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Debug|x64.Build.0 = Debug|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Debug|x86.ActiveCfg = Debug|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Debug|x86.Build.0 = Debug|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Release|Any CPU.Build.0 = Release|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Release|x64.ActiveCfg = Release|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Release|x64.Build.0 = Release|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Release|x86.ActiveCfg = Release|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Release|x86.Build.0 = Release|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Debug|x64.ActiveCfg = Debug|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Debug|x64.Build.0 = Debug|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Debug|x86.ActiveCfg = Debug|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Debug|x86.Build.0 = Debug|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|Any CPU.Build.0 = Release|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x64.ActiveCfg = Release|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x64.Build.0 = Release|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x86.ActiveCfg = Release|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
+5 -5
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.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
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
cqrs.AddGrpc(grpc =>
{
+640
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
@@ -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