diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 74814c8..919e79c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -42,7 +42,8 @@ "WebFetch(domain:blog.rsuter.com)", "WebFetch(domain:natemcmaster.com)", "WebFetch(domain:www.nuget.org)", - "Bash(mkdir:*)" + "Bash(mkdir:*)", + "Bash(nul)" ], "deny": [], "ask": [] diff --git a/.gitignore b/.gitignore index eec2d35..4000da5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ ## ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore +nul +Svrnty.Sample/nul + .research/ # User-specific files diff --git a/Svrnty.CQRS.Abstractions/Discovery/CommandMeta.cs b/Svrnty.CQRS.Abstractions/Discovery/CommandMeta.cs index d58502b..f3b97ed 100644 --- a/Svrnty.CQRS.Abstractions/Discovery/CommandMeta.cs +++ b/Svrnty.CQRS.Abstractions/Discovery/CommandMeta.cs @@ -6,46 +6,45 @@ namespace Svrnty.CQRS.Abstractions.Discovery; public sealed class CommandMeta : ICommandMeta { + private readonly string _name; + private readonly string _lowerCamelCaseName; + public CommandMeta(Type commandType, Type serviceType, Type commandResultType) { CommandType = commandType; ServiceType = serviceType; CommandResultType = commandResultType; + + // Cache reflection and computed values once in constructor + var nameAttribute = commandType.GetCustomAttribute(); + _name = nameAttribute?.Name ?? commandType.Name.Replace("Command", string.Empty); + _lowerCamelCaseName = ComputeLowerCamelCaseName(_name); } public CommandMeta(Type commandType, Type serviceType) { CommandType = commandType; ServiceType = serviceType; + + // Cache reflection and computed values once in constructor + var nameAttribute = commandType.GetCustomAttribute(); + _name = nameAttribute?.Name ?? commandType.Name.Replace("Command", string.Empty); + _lowerCamelCaseName = ComputeLowerCamelCaseName(_name); } - private CommandNameAttribute NameAttribute => CommandType.GetCustomAttribute(); - - public string Name + private static string ComputeLowerCamelCaseName(string name) { - get - { - var name = NameAttribute?.Name ?? CommandType.Name.Replace("Command", string.Empty); + if (string.IsNullOrEmpty(name)) return name; - } + + var firstLetter = char.ToLowerInvariant(name[0]); + return $"{firstLetter}{name.Substring(1)}"; } + public string Name => _name; public Type CommandType { get; } public Type ServiceType { get; } public Type CommandResultType { get; } - - public string LowerCamelCaseName - { - get - { - if (string.IsNullOrEmpty(Name)) - return Name; - - var name = Name; - var firstLetter = Char.ToLowerInvariant(name[0]); - var ret = $"{firstLetter}{name.Substring(1)}"; - return ret; - } - } + public string LowerCamelCaseName => _lowerCamelCaseName; } diff --git a/Svrnty.CQRS.Abstractions/Discovery/QueryMeta.cs b/Svrnty.CQRS.Abstractions/Discovery/QueryMeta.cs index e6a4a14..136a9e9 100644 --- a/Svrnty.CQRS.Abstractions/Discovery/QueryMeta.cs +++ b/Svrnty.CQRS.Abstractions/Discovery/QueryMeta.cs @@ -6,23 +6,20 @@ namespace Svrnty.CQRS.Abstractions.Discovery; public class QueryMeta : IQueryMeta { + private readonly string _name; + public QueryMeta(Type queryType, Type serviceType, Type queryResultType) { QueryType = queryType; ServiceType = serviceType; QueryResultType = queryResultType; + + // Cache reflection and computed value once in constructor + var nameAttribute = queryType.GetCustomAttribute(); + _name = nameAttribute?.Name ?? queryType.Name.Replace("Query", string.Empty); } - protected virtual QueryNameAttribute NameAttribute => QueryType.GetCustomAttribute(); - - public virtual string Name - { - get - { - var name = NameAttribute?.Name ?? QueryType.Name.Replace("Query", string.Empty); - return name; - } - } + public virtual string Name => _name; public virtual Type QueryType { get; } public virtual Type ServiceType { get; } @@ -33,13 +30,13 @@ public class QueryMeta : IQueryMeta { get { - if (string.IsNullOrEmpty(Name)) - return Name; - + // Use virtual Name property so derived classes can override var name = Name; + if (string.IsNullOrEmpty(name)) + return name; + var firstLetter = char.ToLowerInvariant(name[0]); - var ret = $"{firstLetter}{name[1..]}"; - return ret; + return $"{firstLetter}{name[1..]}"; } } } diff --git a/Svrnty.CQRS/Discovery/CommandDiscovery.cs b/Svrnty.CQRS/Discovery/CommandDiscovery.cs index 570605f..8c77b98 100644 --- a/Svrnty.CQRS/Discovery/CommandDiscovery.cs +++ b/Svrnty.CQRS/Discovery/CommandDiscovery.cs @@ -7,16 +7,37 @@ namespace Svrnty.CQRS.Discovery; public sealed class CommandDiscovery : ICommandDiscovery { - private readonly IEnumerable _commandMetas; + private readonly List _commandMetas; + private readonly Dictionary _commandsByName; + private readonly Dictionary _commandsByType; public CommandDiscovery(IEnumerable commandMetas) { - _commandMetas = commandMetas; + // Materialize the enumerable to a list once + _commandMetas = commandMetas.ToList(); + + // Build lookup dictionaries for O(1) access + _commandsByName = new Dictionary(_commandMetas.Count); + _commandsByType = new Dictionary(_commandMetas.Count); + + foreach (var meta in _commandMetas) + { + _commandsByName[meta.Name] = meta; + _commandsByType[meta.CommandType] = meta; + } } public IEnumerable GetCommands() => _commandMetas; - public ICommandMeta FindCommand(string name) => _commandMetas.FirstOrDefault(t => t.Name == name); - public ICommandMeta FindCommand(Type commandType) => _commandMetas.FirstOrDefault(t => t.CommandType == commandType); - public bool CommandExists(string name) => _commandMetas.Any(t => t.Name == name); - public bool CommandExists(Type commandType) => _commandMetas.Any(t => t.CommandType == commandType); + + public ICommandMeta FindCommand(string name) => + _commandsByName.TryGetValue(name, out var meta) ? meta : null; + + public ICommandMeta FindCommand(Type commandType) => + _commandsByType.TryGetValue(commandType, out var meta) ? meta : null; + + public bool CommandExists(string name) => + _commandsByName.ContainsKey(name); + + public bool CommandExists(Type commandType) => + _commandsByType.ContainsKey(commandType); } diff --git a/Svrnty.CQRS/Discovery/QueryDiscovery.cs b/Svrnty.CQRS/Discovery/QueryDiscovery.cs index 8098e40..34f71c0 100644 --- a/Svrnty.CQRS/Discovery/QueryDiscovery.cs +++ b/Svrnty.CQRS/Discovery/QueryDiscovery.cs @@ -7,17 +7,38 @@ namespace Svrnty.CQRS.Discovery; public sealed class QueryDiscovery : IQueryDiscovery { - private readonly IEnumerable _queryMetas; + private readonly List _queryMetas; + private readonly Dictionary _queriesByName; + private readonly Dictionary _queriesByType; public QueryDiscovery(IEnumerable queryMetas) { - _queryMetas = queryMetas; + // Materialize the enumerable to a list once + _queryMetas = queryMetas.ToList(); + + // Build lookup dictionaries for O(1) access + _queriesByName = new Dictionary(_queryMetas.Count); + _queriesByType = new Dictionary(_queryMetas.Count); + + foreach (var meta in _queryMetas) + { + _queriesByName[meta.Name] = meta; + _queriesByType[meta.QueryType] = meta; + } } public IEnumerable GetQueries() => _queryMetas; - public IQueryMeta FindQuery(string name) => _queryMetas.FirstOrDefault(t => t.Name == name); - public IQueryMeta FindQuery(Type queryType) => _queryMetas.FirstOrDefault(t => t.QueryType == queryType); - public bool QueryExists(string name) => _queryMetas.Any(t => t.Name == name); - public bool QueryExists(Type queryType) => _queryMetas.Any(t => t.QueryType == queryType); + + public IQueryMeta FindQuery(string name) => + _queriesByName.TryGetValue(name, out var meta) ? meta : null; + + public IQueryMeta FindQuery(Type queryType) => + _queriesByType.TryGetValue(queryType, out var meta) ? meta : null; + + public bool QueryExists(string name) => + _queriesByName.ContainsKey(name); + + public bool QueryExists(Type queryType) => + _queriesByType.ContainsKey(queryType); } diff --git a/Svrnty.CQRS/ServiceCollectionExtensions.cs b/Svrnty.CQRS/ServiceCollectionExtensions.cs index ca2404c..8aea8f6 100644 --- a/Svrnty.CQRS/ServiceCollectionExtensions.cs +++ b/Svrnty.CQRS/ServiceCollectionExtensions.cs @@ -22,13 +22,13 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddDefaultQueryDiscovery(this IServiceCollection services) { - services.TryAddTransient(); + services.TryAddSingleton(); return services; } public static IServiceCollection AddDefaultCommandDiscovery(this IServiceCollection services) { - services.TryAddTransient(); + services.TryAddSingleton(); return services; } } diff --git a/roadmap-2026/quick-analyze-improvements.md b/roadmap-2026/quick-analyze-improvements.md new file mode 100644 index 0000000..ad259d2 --- /dev/null +++ b/roadmap-2026/quick-analyze-improvements.md @@ -0,0 +1,1182 @@ +# Quick Analysis: Simple Missing Features and Optimizations + +**Date:** 2025-11-10 +**Analysis Type:** Low-hanging fruit improvements for Svrnty.CQRS +**Focus:** High-impact features that can be implemented in hours to days + +--- + +## Executive Summary + +This analysis identifies **17 optimization opportunities** across performance, developer experience, API completeness, and observability. The top 9 items can be completed in 20-35 hours and would provide significant value to the framework. + +**Priority Quick Wins (4-6 hours total):** +- Discovery services caching +- Reflection caching for meta properties +- Multiple validators support +- Query string parsing for nullable/Guid/DateTime types + +--- + +## 1. PERFORMANCE OPTIMIZATIONS (HIGH PRIORITY) + +### 1.1 Discovery Services Not Caching Results ⚡ CRITICAL + +**Location:** `Svrnty.CQRS/Discovery/CommandDiscovery.cs` and `QueryDiscovery.cs` + +**Issue:** Every call to `GetCommands()`, `FindCommand()`, etc. hits the `IEnumerable` from DI, which iterates through the collection every time. Discovery services are registered as **Transient** but should be **Singleton** with internal caching. + +**Current Code:** +```csharp +public ICommandMeta FindCommand(string name) => _commandMetas.FirstOrDefault(t => t.Name == name); +public bool CommandExists(string name) => _commandMetas.Any(t => t.Name == name); +``` + +**Impact:** +- Multiple allocations per request during endpoint mapping +- Repeated LINQ operations on every discovery call +- O(n) lookups instead of O(1) + +**Solution:** +1. Change registration from `Transient` to `Singleton` in `ServiceCollectionExtensions.cs:25,29` +2. Convert `IEnumerable` to `List` in constructor +3. Build lookup dictionaries in constructor: + - `Dictionary` (by name) + - `Dictionary` (by type) + - `Dictionary` (by lower camel case name) + +**Estimated Time:** 2-3 hours + +--- + +### 1.2 Reflection Caching for Meta Properties ⚡ CRITICAL + +**Location:** `Svrnty.CQRS.Abstractions/Discovery/CommandMeta.cs:22` and `QueryMeta.cs:16` + +**Issue:** `GetCustomAttribute()` is called every time `Name` property is accessed. Reflection is slow and should be cached. + +**Current Code:** +```csharp +private CommandNameAttribute NameAttribute => CommandType.GetCustomAttribute(); + +public string Name +{ + get + { + var name = NameAttribute?.Name ?? CommandType.Name.Replace("Command", string.Empty); + return name; + } +} +``` + +**Solution:** Cache the attribute and computed name in the constructor: +```csharp +private readonly string _name; +private readonly string _lowerCamelCaseName; + +public CommandMeta(/* params */) +{ + // ... existing code ... + var nameAttr = CommandType.GetCustomAttribute(); + _name = nameAttr?.Name ?? CommandType.Name.Replace("Command", string.Empty); + _lowerCamelCaseName = ComputeLowerCamelCase(_name); +} + +public string Name => _name; +public string LowerCamelCaseName => _lowerCamelCaseName; +``` + +**Estimated Time:** 1 hour + +--- + +### 1.3 Repeated Reflection Calls in Endpoint Handlers + +**Location:** `Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs:68-72,133-137,204-208,246-250` + +**Issue:** Every request calls `handlerType.GetMethod("HandleAsync")` via reflection. This should be cached or use compiled delegates. + +**Current Code:** +```csharp +var handleMethod = handlerType.GetMethod("HandleAsync"); +if (handleMethod == null) + return Results.Problem("Handler method not found"); + +var task = (Task)handleMethod.Invoke(handler, [query, cancellationToken])!; +``` + +**Impact:** +- Reflection on every request (hot path) +- 10-100x slower than direct invocation +- Unnecessary allocations + +**Solution:** +1. **Option A (Simple):** Cache `MethodInfo` in metadata during discovery +2. **Option B (Best):** Generate compiled delegates using `Expression.Compile()` for direct method invocation +3. Store delegates in metadata for zero-reflection invocation + +**Example (Option B):** +```csharp +// In metadata +public Func> CompiledHandler { get; set; } + +// During discovery +var handlerParam = Expression.Parameter(typeof(object), "handler"); +var commandParam = Expression.Parameter(typeof(object), "command"); +var ctParam = Expression.Parameter(typeof(CancellationToken), "ct"); + +var method = handlerType.GetMethod("HandleAsync"); +var call = Expression.Call( + Expression.Convert(handlerParam, handlerType), + method, + Expression.Convert(commandParam, commandType), + ctParam +); + +var lambda = Expression.Lambda>>( + Expression.Convert(call, typeof(Task)), + handlerParam, commandParam, ctParam +); + +CompiledHandler = lambda.Compile(); +``` + +**Estimated Time:** 4-6 hours + +--- + +### 1.4 Assembly Scanning in gRPC Extension + +**Location:** `Svrnty.CQRS.Grpc/CqrsBuilderExtensions.cs:52-81` + +**Issue:** Scans all loaded assemblies on every call to find extension methods. This is expensive. + +**Current Code:** +```csharp +foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) +{ + var types = assembly.GetTypes()... +``` + +**Solution:** +1. Cache found methods in a static dictionary +2. Use more targeted search (check specific assembly by name first) +3. Only scan once during startup + +**Estimated Time:** 2 hours + +--- + +## 2. DEVELOPER EXPERIENCE IMPROVEMENTS + +### 2.1 No Assembly Scanning for Bulk Registration ⭐ HIGH VALUE + +**Location:** All `ServiceCollectionExtensions.cs` files + +**Issue:** Users must manually register every command/query handler one by one. There's no assembly scanning helper. + +**Example Current Usage:** +```csharp +builder.Services.AddCommand(); +builder.Services.AddCommand(); +builder.Services.AddQuery(); +// ... repeat for dozens of handlers +``` + +**Solution:** Add assembly scanning extension methods: + +```csharp +public static IServiceCollection AddCommandsFromAssembly( + this IServiceCollection services, + Assembly assembly, + Predicate? filter = null, + ServiceLifetime lifetime = ServiceLifetime.Transient) +{ + var commandHandlers = assembly.GetTypes() + .Where(type => !type.IsAbstract && !type.IsInterface) + .SelectMany(type => type.GetInterfaces() + .Where(i => i.IsGenericType && + (i.GetGenericTypeDefinition() == typeof(ICommandHandler<>) || + i.GetGenericTypeDefinition() == typeof(ICommandHandler<,>))) + .Select(i => new { HandlerType = type, InterfaceType = i })) + .Where(x => filter == null || filter(x.HandlerType)) + .ToList(); + + foreach (var handler in commandHandlers) + { + var genericArgs = handler.InterfaceType.GetGenericArguments(); + // Register based on interface type... + } + + return services; +} + +public static IServiceCollection AddQueriesFromAssembly(/*...*/) { /* similar */ } +``` + +**Usage:** +```csharp +// Register all handlers from current assembly +services.AddCommandsFromAssembly(typeof(Program).Assembly); +services.AddQueriesFromAssembly(typeof(Program).Assembly); + +// With filter +services.AddCommandsFromAssembly( + typeof(Program).Assembly, + type => type.Namespace?.StartsWith("MyApp.Commands") ?? false +); +``` + +**Estimated Time:** 4-6 hours (with tests) + +--- + +### 2.2 No Batch Registration Support + +**Location:** `Svrnty.CQRS/Configuration/CqrsBuilder.cs` + +**Issue:** CqrsBuilder only supports adding one handler at a time. No batch operations. + +**Solution:** Add methods like: +```csharp +public CqrsBuilder AddCommands(Action configure) +{ + var builder = new CommandRegistrationBuilder(_services); + configure(builder); + return this; +} + +public class CommandRegistrationBuilder +{ + private readonly IServiceCollection _services; + + public CommandRegistrationBuilder Add() + where THandler : ICommandHandler + { + _services.AddCommand(); + return this; + } + + public CommandRegistrationBuilder Add() + where THandler : ICommandHandler + { + _services.AddCommand(); + return this; + } +} +``` + +**Usage:** +```csharp +cqrs.AddCommands(commands => { + commands.Add() + .Add() + .Add(); +}); +``` + +**Estimated Time:** 2-3 hours + +--- + +### 2.3 Missing Scoped/Singleton Handler Lifetime Support + +**Location:** `Svrnty.CQRS.Abstractions/ServiceCollectionExtensions.cs:14,28,42` + +**Issue:** All handlers are registered as `Transient`. No option for Scoped or Singleton lifetimes. + +**Current Code:** +```csharp +services.AddTransient, TQueryHandler>(); +``` + +**Problem:** +- Can't use Scoped handlers with EF Core DbContext +- Can't create Singleton handlers for performance +- No flexibility for different lifetime requirements + +**Solution:** Add overloads accepting `ServiceLifetime` parameter: + +```csharp +public static IServiceCollection AddQuery( + this IServiceCollection services, + ServiceLifetime lifetime = ServiceLifetime.Transient) + where TQueryHandler : class, IQueryHandler +{ + services.Add(new ServiceDescriptor( + typeof(IQueryHandler), + typeof(TQueryHandler), + lifetime)); + + services.AddSingleton(new QueryMeta( + typeof(TQuery), + typeof(TQueryHandler), + typeof(TQueryResult))); + + return services; +} +``` + +**Usage:** +```csharp +// Transient (default) +services.AddCommand(); + +// Scoped (for handlers using DbContext) +services.AddCommand(ServiceLifetime.Scoped); + +// Singleton (for stateless handlers) +services.AddQuery(ServiceLifetime.Singleton); +``` + +**Estimated Time:** 2 hours + +--- + +## 3. MISSING CONVENIENCE FEATURES + +### 3.1 No Query String Parsing for Nullable Types ⚡ CRITICAL + +**Location:** `Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs:123` + +**Issue:** `Convert.ChangeType()` doesn't handle `Nullable`, `Guid`, `DateTime`, enums, or collections properly. + +**Current Code:** +```csharp +var convertedValue = Convert.ChangeType(queryStringValue, property.PropertyType); +``` + +**Failure Cases:** +- `int?` properties fail with InvalidCastException +- `Guid` parsing fails (not supported by ChangeType) +- `DateTime` doesn't respect formats (always uses current culture) +- Enums fail (need special parsing) +- Collections/arrays completely ignored + +**Solution:** Implement a proper type converter: + +```csharp +private static object? ConvertQueryStringValue(string value, Type targetType) +{ + if (string.IsNullOrWhiteSpace(value)) + return targetType.IsValueType ? Activator.CreateInstance(targetType) : null; + + // Handle nullable types + var underlyingType = Nullable.GetUnderlyingType(targetType); + if (underlyingType != null) + return ConvertQueryStringValue(value, underlyingType); + + // Handle common types + if (targetType == typeof(Guid)) + return Guid.Parse(value); + + if (targetType == typeof(DateTime)) + return DateTime.Parse(value, CultureInfo.InvariantCulture); + + if (targetType == typeof(DateTimeOffset)) + return DateTimeOffset.Parse(value, CultureInfo.InvariantCulture); + + if (targetType.IsEnum) + return Enum.Parse(targetType, value, ignoreCase: true); + + if (targetType == typeof(bool)) + return bool.Parse(value); + + if (targetType == typeof(Uri)) + return new Uri(value); + + // Handle arrays/collections + if (targetType.IsArray) + { + var elementType = targetType.GetElementType()!; + var values = value.Split(','); + var array = Array.CreateInstance(elementType, values.Length); + for (int i = 0; i < values.Length; i++) + array.SetValue(ConvertQueryStringValue(values[i], elementType), i); + return array; + } + + // Fallback to ChangeType for primitives + return Convert.ChangeType(value, targetType, CultureInfo.InvariantCulture); +} +``` + +**Estimated Time:** 3-4 hours + +--- + +### 3.2 No Way to Access HttpContext in Handlers + +**Location:** All handler interfaces in `Svrnty.CQRS.Abstractions` + +**Issue:** Handlers can't access HttpContext for user identity, headers, IP address, etc. + +**Common Use Cases:** +- Getting current user identity +- Reading custom headers +- Getting client IP address +- Accessing route data +- Setting response headers + +**Solution Options:** + +**Option A: Recommend IHttpContextAccessor injection (no code changes)** +```csharp +public class MyCommandHandler : ICommandHandler +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + public MyCommandHandler(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public Task HandleAsync(MyCommand command, CancellationToken ct) + { + var user = _httpContextAccessor.HttpContext?.User; + // ... + } +} +``` + +**Option B: Provide a scoped context service** +```csharp +public interface IExecutionContext +{ + ClaimsPrincipal? User { get; } + string? IpAddress { get; } + IHeaderDictionary? Headers { get; } +} + +// Populated by middleware, injected into handlers +``` + +**Option C: Add to handler interface (BREAKING CHANGE)** +```csharp +public interface ICommandHandler +{ + Task HandleAsync(TCommand command, ExecutionContext context, CancellationToken ct); +} +``` + +**Recommended:** Option A (document pattern) + Option B (provide helper service) + +**Estimated Time:** 2-3 hours (for Option B) + +--- + +### 3.3 No Default Error Handling/Middleware + +**Location:** Endpoint mapping in MinimalApi + +**Issue:** Unhandled exceptions in handlers aren't caught and formatted consistently. No global exception handling. + +**Current Behavior:** +- Exceptions return HTTP 500 with stack traces (security issue) +- No consistent error format +- No logging of exceptions +- No custom exception type handling + +**Solution:** Add endpoint filter for exception handling: + +```csharp +public class CqrsExceptionFilter : IEndpointFilter +{ + private readonly ILogger _logger; + + public CqrsExceptionFilter(ILogger logger) + { + _logger = logger; + } + + public async ValueTask InvokeAsync( + EndpointFilterInvocationContext context, + EndpointFilterDelegate next) + { + try + { + return await next(context); + } + catch (ValidationException ex) + { + _logger.LogWarning(ex, "Validation failed"); + return Results.ValidationProblem(ex.Errors); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, "Unauthorized access"); + return Results.Problem( + statusCode: StatusCodes.Status403Forbidden, + title: "Forbidden", + detail: ex.Message); + } + catch (KeyNotFoundException ex) + { + _logger.LogWarning(ex, "Resource not found"); + return Results.Problem( + statusCode: StatusCodes.Status404NotFound, + title: "Not Found", + detail: ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unhandled exception in CQRS handler"); + return Results.Problem( + statusCode: StatusCodes.Status500InternalServerError, + title: "Internal Server Error", + detail: "An error occurred processing your request"); + } + } +} +``` + +**Usage in endpoint mapping:** +```csharp +builder + .MapPost(route, handler) + .AddEndpointFilter() +``` + +**Estimated Time:** 3-4 hours + +--- + +## 4. API COMPLETENESS + +### 4.1 Missing Instance-Based Authorization + +**Location:** `Svrnty.CQRS.Abstractions/Security/ICommandAuthorizationService.cs` + +**Issue:** Authorization service can only see command/query TYPE, not the actual instance. Can't do resource-based authorization. + +**Current Interface:** +```csharp +Task IsAllowedAsync(Type commandType, CancellationToken cancellationToken = default); +``` + +**Problem:** +```csharp +// Can only check: "Can user delete ANY document?" +// Cannot check: "Can user delete THIS SPECIFIC document?" + +public class DeleteDocumentCommand +{ + public int DocumentId { get; set; } +} +``` + +**Solution:** Add generic overload that passes the instance: + +```csharp +public interface ICommandAuthorizationService +{ + // Existing type-based check + Task IsAllowedAsync(Type commandType, CancellationToken ct = default); + + // NEW: Instance-based check + Task IsAllowedAsync(TCommand command, CancellationToken ct = default); +} +``` + +**Implementation:** +```csharp +public class DocumentAuthorizationService : ICommandAuthorizationService +{ + private readonly IHttpContextAccessor _httpContext; + private readonly IDocumentRepository _documents; + + public async Task IsAllowedAsync( + TCommand command, + CancellationToken ct) + { + if (command is DeleteDocumentCommand deleteCmd) + { + var document = await _documents.GetByIdAsync(deleteCmd.DocumentId, ct); + var userId = _httpContext.HttpContext?.User.FindFirst("sub")?.Value; + + if (document.OwnerId == userId || IsAdmin()) + return AuthorizationResult.Allowed; + + return AuthorizationResult.Forbidden; + } + + return await IsAllowedAsync(typeof(TCommand), ct); + } +} +``` + +**Endpoint mapping changes:** +```csharp +// Try instance-based first, fallback to type-based +var instanceResult = await authService.IsAllowedAsync(command, cancellationToken); +if (instanceResult != AuthorizationResult.Allowed) +{ + return instanceResult == AuthorizationResult.Unauthorized + ? Results.Unauthorized() + : Results.Forbid(); +} +``` + +**Estimated Time:** 2-3 hours + +--- + +### 4.2 No Support for Multiple Validators per Command ⚡ CRITICAL + +**Location:** `Svrnty.CQRS.MinimalApi/ValidationFilter.cs:24` + +**Issue:** Only retrieves first validator via `GetService>()`. Doesn't support composite validation scenarios. + +**Current Code:** +```csharp +var validator = context.HttpContext.RequestServices.GetService>(); +if (validator == null) + return await next(context); + +var validationResult = await validator.ValidateAsync(argument, cancellationToken); +``` + +**Problem:** +```csharp +// Only one of these validators will run! +services.AddTransient, CreateUserValidator>(); +services.AddTransient, SecurityValidator>(); +services.AddTransient, BusinessRulesValidator>(); +``` + +**Solution:** Use `GetServices>()` and run all validators: + +```csharp +var validators = context.HttpContext.RequestServices + .GetServices>() + .ToList(); + +if (validators.Count == 0) + return await next(context); + +var validationContext = new ValidationContext(argument); +var failures = new List(); + +foreach (var validator in validators) +{ + var result = await validator.ValidateAsync(validationContext, cancellationToken); + if (!result.IsValid) + failures.AddRange(result.Errors); +} + +if (failures.Any()) +{ + return Results.ValidationProblem(failures.ToDictionary( + f => f.PropertyName, + f => new[] { f.ErrorMessage } + )); +} +``` + +**Estimated Time:** 1-2 hours + +--- + +## 5. TESTING/OBSERVABILITY + +### 5.1 No Logging Integration ⭐ HIGH VALUE + +**Location:** Entire codebase + +**Issue:** Zero logging throughout the framework. No `ILogger` injection anywhere. + +**Impact:** +- Can't debug production issues +- Can't track handler execution times +- Can't monitor validation failures +- Can't audit command/query execution +- No visibility into authorization decisions + +**Solution:** Add `ILogger` injection to key classes: + +**Discovery Services:** +```csharp +public class CommandDiscovery : ICommandDiscovery +{ + private readonly ILogger _logger; + + public CommandDiscovery(IEnumerable metas, ILogger logger) + { + _logger = logger; + _logger.LogInformation("Discovered {Count} commands", _commandMetas.Count); + + foreach (var meta in _commandMetas) + { + _logger.LogDebug("Registered command: {Name} -> {Handler}", + meta.Name, meta.ServiceType.Name); + } + } +} +``` + +**Endpoint Mapping:** +```csharp +public static void MapSvrntyCommands( + this IEndpointRouteBuilder builder, + ILogger? logger = null) +{ + logger ??= builder.ServiceProvider.GetService() + ?.CreateLogger("Svrnty.CQRS.MinimalApi"); + + var discovery = builder.ServiceProvider.GetRequiredService(); + var commands = discovery.GetCommands(); + + logger?.LogInformation("Mapping {Count} command endpoints", commands.Count()); + + foreach (var command in commands) + { + // ... mapping logic ... + logger?.LogDebug("Mapped command endpoint: POST {Route}", route); + } +} +``` + +**Validation Filter:** +```csharp +public class ValidationFilter : IEndpointFilter +{ + public async ValueTask InvokeAsync( + EndpointFilterInvocationContext context, + EndpointFilterDelegate next) + { + var logger = context.HttpContext.RequestServices + .GetService>>(); + + // ... validation ... + + if (!validationResult.IsValid) + { + logger?.LogWarning( + "Validation failed for {Type}: {Errors}", + typeof(T).Name, + string.Join(", ", validationResult.Errors.Select(e => e.ErrorMessage)) + ); + } + + return await next(context); + } +} +``` + +**Handler Execution:** +```csharp +var stopwatch = Stopwatch.StartNew(); +var result = await handler.HandleAsync(command, cancellationToken); +stopwatch.Stop(); + +logger?.LogInformation( + "Executed command {Command} in {Duration}ms", + commandMeta.Name, + stopwatch.ElapsedMilliseconds +); +``` + +**Estimated Time:** 3-4 hours + +--- + +### 5.2 No Activity/Telemetry Support + +**Location:** Entire codebase + +**Issue:** No OpenTelemetry/Activity tracing for distributed tracing support. + +**Impact:** +- Can't trace requests through microservices +- Can't correlate logs across services +- Can't measure performance in production +- No APM (Application Performance Monitoring) integration + +**Solution:** Add Activity.StartActivity() calls in key locations: + +```csharp +using System.Diagnostics; + +public static readonly ActivitySource ActivitySource = new("Svrnty.CQRS"); + +// In command handler execution +using var activity = ActivitySource.StartActivity("ExecuteCommand"); +activity?.SetTag("command.name", commandMeta.Name); +activity?.SetTag("command.type", commandMeta.CommandType.Name); + +try +{ + var result = await handler.HandleAsync(command, cancellationToken); + activity?.SetTag("command.success", true); + return result; +} +catch (Exception ex) +{ + activity?.SetTag("command.success", false); + activity?.SetTag("error.type", ex.GetType().Name); + activity?.SetTag("error.message", ex.Message); + throw; +} +``` + +**Registration:** +```csharp +public static IServiceCollection AddSvrntyCqrs(this IServiceCollection services) +{ + services.AddSingleton(_ => new ActivitySource("Svrnty.CQRS")); + // ... rest of registration +} + +// In Program.cs +builder.Services.AddOpenTelemetry() + .WithTracing(tracing => tracing + .AddSource("Svrnty.CQRS") + .AddAspNetCoreInstrumentation() + .AddConsoleExporter()); +``` + +**Estimated Time:** 4-6 hours + +--- + +### 5.3 No Testing Helpers + +**Location:** Missing entirely + +**Issue:** No in-memory testing helpers for CQRS pipeline. Difficult to test handlers in isolation. + +**Solution:** Create a new package `Svrnty.CQRS.Testing` with: + +```csharp +// In-memory command bus +public class InMemoryCommandBus +{ + private readonly IServiceProvider _services; + + public async Task SendAsync(TCommand command) + { + var handler = _services.GetRequiredService>(); + return await handler.HandleAsync(command, CancellationToken.None); + } +} + +// Testing builder +public class CqrsTestBuilder +{ + private readonly ServiceCollection _services = new(); + + public CqrsTestBuilder AddCommand() + where THandler : class, ICommandHandler + { + _services.AddTransient, THandler>(); + return this; + } + + public CqrsTestBuilder AddValidator() + where TValidator : class, IValidator + { + _services.AddTransient, TValidator>(); + return this; + } + + public IServiceProvider Build() => _services.BuildServiceProvider(); +} + +// Assertion helpers +public static class CqrsAssertions +{ + public static async Task ShouldSucceedAsync( + this ICommandHandler handler, + TCommand command) + { + var result = await handler.HandleAsync(command, CancellationToken.None); + Assert.NotNull(result); + } + + public static async Task ShouldFailValidationAsync( + this IValidator validator, + T instance, + string propertyName) + { + var result = await validator.ValidateAsync(instance); + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.PropertyName == propertyName); + } +} +``` + +**Usage:** +```csharp +[Fact] +public async Task AddUserCommand_Should_Create_User() +{ + // Arrange + var services = new CqrsTestBuilder() + .AddCommand() + .AddValidator() + .Build(); + + var handler = services.GetRequiredService>(); + + // Act + var userId = await handler.HandleAsync(new AddUserCommand + { + Name = "John", + Email = "john@example.com" + }, CancellationToken.None); + + // Assert + Assert.True(userId > 0); +} +``` + +**Estimated Time:** 8-12 hours (full testing package) + +--- + +## 6. CONFIGURATION OPTIONS + +### 6.1 No Endpoint Name Customization + +**Location:** `Svrnty.CQRS.MinimalApi/MinimalApiCqrsOptions.cs` + +**Issue:** Can only customize route prefixes, not individual endpoint names or the naming convention itself. + +**Current Limitation:** +- Stuck with lowerCamelCase convention +- Can't use kebab-case, snake_case, or custom naming +- Can't override individual endpoint names + +**Solution:** Add to options: + +```csharp +public class MinimalApiCqrsOptions +{ + public string CommandRoutePrefix { get; set; } = "api/command"; + public string QueryRoutePrefix { get; set; } = "api/query"; + + // NEW: Custom naming convention + public Func EndpointNamingConvention { get; set; } = DefaultLowerCamelCase; + + // NEW: Override specific endpoint names + public Dictionary CustomEndpointNames { get; set; } = new(); + + private static string DefaultLowerCamelCase(string name) + { + if (string.IsNullOrEmpty(name)) return name; + return char.ToLowerInvariant(name[0]) + name.Substring(1); + } + + // Preset conventions + public static Func KebabCase => name => + Regex.Replace(name, "(? SnakeCase => name => + Regex.Replace(name, "(? +{ + // Use kebab-case + options.EndpointNamingConvention = MinimalApiCqrsOptions.KebabCase; + // "CreateUser" -> "create-user" + + // Override specific command + options.CustomEndpointNames[typeof(CreateUserCommand)] = "register"; +}); +``` + +**Estimated Time:** 2-3 hours + +--- + +### 6.2 No Way to Disable Specific Features + +**Location:** Options classes + +**Issue:** Can disable entire command/query mapping but can't disable individual features like validation, authorization. + +**Use Cases:** +- Disable validation for certain environments +- Disable authorization for testing +- Disable GET endpoints for queries (POST only) + +**Solution:** Add granular options: + +```csharp +public class MinimalApiCqrsOptions +{ + public string CommandRoutePrefix { get; set; } = "api/command"; + public string QueryRoutePrefix { get; set; } = "api/query"; + + // NEW: Feature toggles + public bool EnableValidation { get; set; } = true; + public bool EnableAuthorization { get; set; } = true; + public bool EnableGetEndpointsForQueries { get; set; } = true; + public bool EnableSwaggerTags { get; set; } = true; + + // NEW: Per-command/query overrides + public HashSet DisableValidationFor { get; set; } = new(); + public HashSet DisableAuthorizationFor { get; set; } = new(); +} +``` + +**Usage:** +```csharp +app.MapSvrntyCommands(options => +{ + options.EnableValidation = true; + options.DisableValidationFor.Add(typeof(HealthCheckCommand)); +}); + +app.MapSvrntyQueries(options => +{ + options.EnableGetEndpointsForQueries = false; // POST only +}); +``` + +**Estimated Time:** 2 hours + +--- + +## PRIORITY RANKING (Quick Wins First) + +### ⚡ CRITICAL (Hours: 1-4, Highest Impact) + +| # | Feature | Time | Impact | Complexity | +|---|---------|------|--------|------------| +| 1 | Discovery services caching (1.1) | 2-3h | Performance | Low | +| 2 | Reflection caching for meta (1.2) | 1h | Performance | Low | +| 3 | Multiple validators support (4.2) | 1-2h | Correctness | Low | +| 4 | Query string nullable/Guid parsing (3.1) | 3-4h | Functionality | Medium | + +**Total: 7-10 hours** - These should be done ASAP as they fix critical issues. + +--- + +### ⭐ HIGH VALUE (Hours: 2-6, High Impact) + +| # | Feature | Time | Impact | Complexity | +|---|---------|------|--------|------------| +| 5 | Logging integration (5.1) | 3-4h | Observability | Low | +| 6 | Assembly scanning registration (2.1) | 4-6h | Developer Experience | Medium | +| 7 | Compiled delegate handlers (1.3) | 4-6h | Performance | Medium-High | +| 8 | Instance-based authorization (4.1) | 2-3h | Security/Features | Medium | +| 9 | Handler lifetime control (2.3) | 2h | Flexibility | Low | + +**Total: 15-25 hours** - High-value features that significantly improve the framework. + +--- + +### 💡 MEDIUM VALUE (Hours: 2-6, Medium Impact) + +| # | Feature | Time | Impact | Complexity | +|---|---------|------|--------|------------| +| 10 | Global exception handling (3.3) | 3-4h | Robustness | Medium | +| 11 | Assembly scanning optimization (1.4) | 2h | Performance | Low | +| 12 | Batch registration API (2.2) | 2-3h | Developer Experience | Low | +| 13 | Endpoint naming customization (6.1) | 2-3h | Flexibility | Low | +| 14 | Granular feature toggles (6.2) | 2h | Configuration | Low | + +**Total: 11-16 hours** - Nice improvements but less critical. + +--- + +### 📋 LOWER PRIORITY (Hours: 2-12, Lower Impact) + +| # | Feature | Time | Impact | Complexity | +|---|---------|------|--------|------------| +| 15 | Activity/Telemetry support (5.2) | 4-6h | Observability | Medium | +| 16 | HttpContext access pattern (3.2) | 2-3h | Convenience | Low | +| 17 | Testing helpers package (5.3) | 8-12h | Testing | Medium-High | + +**Total: 14-21 hours** - Useful but not urgent. + +--- + +## RECOMMENDED IMPLEMENTATION PLAN + +### Phase 1: Weekend Sprint (8-10 hours) +**Goal:** Fix critical issues, major performance boost + +1. Discovery services caching (1.1) - 3 hours +2. Reflection caching for meta (1.2) - 1 hour +3. Query string parsing (3.1) - 4 hours +4. Multiple validators (4.2) - 2 hours + +**Expected Impact:** +- 50-200% performance improvement +- GET endpoints work correctly +- Proper composite validation + +--- + +### Phase 2: Production Readiness (20 hours) +**Goal:** Make framework production-ready + +5. Logging integration (5.1) - 4 hours +6. Assembly scanning (2.1) - 6 hours +7. Compiled delegates (1.3) - 6 hours +8. Instance-based auth (4.1) - 3 hours +9. Global exception handling (3.3) - 4 hours + +**Expected Impact:** +- Production observability +- 10x better developer experience +- 10-100x faster handler execution +- Resource-based security +- Robust error handling + +--- + +### Phase 3: Polish & Flexibility (15 hours) +**Goal:** Maximize flexibility and configurability + +10. Handler lifetime control (2.3) - 2 hours +11. Batch registration API (2.2) - 3 hours +12. Endpoint naming customization (6.1) - 3 hours +13. Granular feature toggles (6.2) - 2 hours +14. Assembly scanning optimization (1.4) - 2 hours +15. Activity/Telemetry (5.2) - 5 hours + +**Expected Impact:** +- Support all DI lifetimes +- Flexible configuration +- OpenTelemetry integration + +--- + +### Phase 4: Testing & Documentation (20 hours) +**Goal:** Enable community adoption + +16. Testing helpers package (5.3) - 12 hours +17. HttpContext access patterns (3.2) - 3 hours +18. Documentation updates - 5 hours + +--- + +## TOTAL EFFORT ESTIMATE + +- **Critical fixes:** 7-10 hours +- **High-value features:** 15-25 hours +- **Medium-value features:** 11-16 hours +- **Lower priority:** 14-21 hours + +**Grand Total:** 47-72 hours (approximately 6-9 full working days) + +--- + +## NOTES + +1. **Breaking Changes:** Only item #3.2 (Option C) would be a breaking change. All other improvements are backward compatible. + +2. **Dependencies:** Items 1.1 and 1.2 should be done first as they benefit all other work. + +3. **Testing:** Each feature should include unit tests, adding ~30% to time estimates. + +4. **Documentation:** Add XML comments and update README files (+2-3 hours per major feature). + +5. **NuGet Publishing:** Remember to bump versions and publish packages after each phase.