34 KiB
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<ICommandMeta> from DI, which iterates through the collection every time. Discovery services are registered as Transient but should be Singleton with internal caching.
Current Code:
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:
- Change registration from
TransienttoSingletoninServiceCollectionExtensions.cs:25,29 - Convert
IEnumerable<ICommandMeta>toList<ICommandMeta>in constructor - Build lookup dictionaries in constructor:
Dictionary<string, ICommandMeta>(by name)Dictionary<Type, ICommandMeta>(by type)Dictionary<string, ICommandMeta>(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<CommandNameAttribute>() is called every time Name property is accessed. Reflection is slow and should be cached.
Current Code:
private CommandNameAttribute NameAttribute => CommandType.GetCustomAttribute<CommandNameAttribute>();
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:
private readonly string _name;
private readonly string _lowerCamelCaseName;
public CommandMeta(/* params */)
{
// ... existing code ...
var nameAttr = CommandType.GetCustomAttribute<CommandNameAttribute>();
_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:
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:
- Option A (Simple): Cache
MethodInfoin metadata during discovery - Option B (Best): Generate compiled delegates using
Expression.Compile()for direct method invocation - Store delegates in metadata for zero-reflection invocation
Example (Option B):
// In metadata
public Func<object, object, CancellationToken, Task<object>> 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<Func<object, object, CancellationToken, Task<object>>>(
Expression.Convert(call, typeof(Task<object>)),
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:
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
var types = assembly.GetTypes()...
Solution:
- Cache found methods in a static dictionary
- Use more targeted search (check specific assembly by name first)
- 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:
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler>();
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
builder.Services.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
// ... repeat for dozens of handlers
Solution: Add assembly scanning extension methods:
public static IServiceCollection AddCommandsFromAssembly(
this IServiceCollection services,
Assembly assembly,
Predicate<Type>? 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:
// 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:
public CqrsBuilder AddCommands(Action<CommandRegistrationBuilder> configure)
{
var builder = new CommandRegistrationBuilder(_services);
configure(builder);
return this;
}
public class CommandRegistrationBuilder
{
private readonly IServiceCollection _services;
public CommandRegistrationBuilder Add<TCommand, THandler>()
where THandler : ICommandHandler<TCommand>
{
_services.AddCommand<TCommand, THandler>();
return this;
}
public CommandRegistrationBuilder Add<TCommand, TResult, THandler>()
where THandler : ICommandHandler<TCommand, TResult>
{
_services.AddCommand<TCommand, TResult, THandler>();
return this;
}
}
Usage:
cqrs.AddCommands(commands => {
commands.Add<AddUserCommand, int, AddUserCommandHandler>()
.Add<RemoveUserCommand, RemoveUserCommandHandler>()
.Add<UpdateUserCommand, UpdateUserCommandHandler>();
});
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:
services.AddTransient<IQueryHandler<TQuery, TQueryResult>, 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:
public static IServiceCollection AddQuery<TQuery, TQueryResult, TQueryHandler>(
this IServiceCollection services,
ServiceLifetime lifetime = ServiceLifetime.Transient)
where TQueryHandler : class, IQueryHandler<TQuery, TQueryResult>
{
services.Add(new ServiceDescriptor(
typeof(IQueryHandler<TQuery, TQueryResult>),
typeof(TQueryHandler),
lifetime));
services.AddSingleton<IQueryMeta>(new QueryMeta(
typeof(TQuery),
typeof(TQueryHandler),
typeof(TQueryResult)));
return services;
}
Usage:
// Transient (default)
services.AddCommand<AddUserCommand, int, AddUserCommandHandler>();
// Scoped (for handlers using DbContext)
services.AddCommand<AddUserCommand, int, AddUserCommandHandler>(ServiceLifetime.Scoped);
// Singleton (for stateless handlers)
services.AddQuery<GetConfigQuery, Config, GetConfigQueryHandler>(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<T>, Guid, DateTime, enums, or collections properly.
Current Code:
var convertedValue = Convert.ChangeType(queryStringValue, property.PropertyType);
Failure Cases:
int?properties fail with InvalidCastExceptionGuidparsing fails (not supported by ChangeType)DateTimedoesn't respect formats (always uses current culture)- Enums fail (need special parsing)
- Collections/arrays completely ignored
Solution: Implement a proper type converter:
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)
public class MyCommandHandler : ICommandHandler<MyCommand, int>
{
private readonly IHttpContextAccessor _httpContextAccessor;
public MyCommandHandler(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public Task<int> HandleAsync(MyCommand command, CancellationToken ct)
{
var user = _httpContextAccessor.HttpContext?.User;
// ...
}
}
Option B: Provide a scoped context service
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)
public interface ICommandHandler<TCommand, TResult>
{
Task<TResult> 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:
public class CqrsExceptionFilter : IEndpointFilter
{
private readonly ILogger<CqrsExceptionFilter> _logger;
public CqrsExceptionFilter(ILogger<CqrsExceptionFilter> logger)
{
_logger = logger;
}
public async ValueTask<object?> 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:
builder
.MapPost(route, handler)
.AddEndpointFilter<CqrsExceptionFilter>()
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:
Task<AuthorizationResult> IsAllowedAsync(Type commandType, CancellationToken cancellationToken = default);
Problem:
// 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:
public interface ICommandAuthorizationService
{
// Existing type-based check
Task<AuthorizationResult> IsAllowedAsync(Type commandType, CancellationToken ct = default);
// NEW: Instance-based check
Task<AuthorizationResult> IsAllowedAsync<TCommand>(TCommand command, CancellationToken ct = default);
}
Implementation:
public class DocumentAuthorizationService : ICommandAuthorizationService
{
private readonly IHttpContextAccessor _httpContext;
private readonly IDocumentRepository _documents;
public async Task<AuthorizationResult> IsAllowedAsync<TCommand>(
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:
// 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<IValidator<T>>(). Doesn't support composite validation scenarios.
Current Code:
var validator = context.HttpContext.RequestServices.GetService<IValidator<T>>();
if (validator == null)
return await next(context);
var validationResult = await validator.ValidateAsync(argument, cancellationToken);
Problem:
// Only one of these validators will run!
services.AddTransient<IValidator<CreateUserCommand>, CreateUserValidator>();
services.AddTransient<IValidator<CreateUserCommand>, SecurityValidator>();
services.AddTransient<IValidator<CreateUserCommand>, BusinessRulesValidator>();
Solution: Use GetServices<IValidator<T>>() and run all validators:
var validators = context.HttpContext.RequestServices
.GetServices<IValidator<T>>()
.ToList();
if (validators.Count == 0)
return await next(context);
var validationContext = new ValidationContext<T>(argument);
var failures = new List<ValidationFailure>();
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:
public class CommandDiscovery : ICommandDiscovery
{
private readonly ILogger<CommandDiscovery> _logger;
public CommandDiscovery(IEnumerable<ICommandMeta> metas, ILogger<CommandDiscovery> 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:
public static void MapSvrntyCommands(
this IEndpointRouteBuilder builder,
ILogger? logger = null)
{
logger ??= builder.ServiceProvider.GetService<ILoggerFactory>()
?.CreateLogger("Svrnty.CQRS.MinimalApi");
var discovery = builder.ServiceProvider.GetRequiredService<ICommandDiscovery>();
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:
public class ValidationFilter<T> : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var logger = context.HttpContext.RequestServices
.GetService<ILogger<ValidationFilter<T>>>();
// ... 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:
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:
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:
public static IServiceCollection AddSvrntyCqrs(this IServiceCollection services)
{
services.AddSingleton<ActivitySource>(_ => 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:
// In-memory command bus
public class InMemoryCommandBus
{
private readonly IServiceProvider _services;
public async Task<TResult> SendAsync<TCommand, TResult>(TCommand command)
{
var handler = _services.GetRequiredService<ICommandHandler<TCommand, TResult>>();
return await handler.HandleAsync(command, CancellationToken.None);
}
}
// Testing builder
public class CqrsTestBuilder
{
private readonly ServiceCollection _services = new();
public CqrsTestBuilder AddCommand<TCommand, TResult, THandler>()
where THandler : class, ICommandHandler<TCommand, TResult>
{
_services.AddTransient<ICommandHandler<TCommand, TResult>, THandler>();
return this;
}
public CqrsTestBuilder AddValidator<T, TValidator>()
where TValidator : class, IValidator<T>
{
_services.AddTransient<IValidator<T>, TValidator>();
return this;
}
public IServiceProvider Build() => _services.BuildServiceProvider();
}
// Assertion helpers
public static class CqrsAssertions
{
public static async Task ShouldSucceedAsync<TCommand, TResult>(
this ICommandHandler<TCommand, TResult> handler,
TCommand command)
{
var result = await handler.HandleAsync(command, CancellationToken.None);
Assert.NotNull(result);
}
public static async Task ShouldFailValidationAsync<T>(
this IValidator<T> validator,
T instance,
string propertyName)
{
var result = await validator.ValidateAsync(instance);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.PropertyName == propertyName);
}
}
Usage:
[Fact]
public async Task AddUserCommand_Should_Create_User()
{
// Arrange
var services = new CqrsTestBuilder()
.AddCommand<AddUserCommand, int, AddUserCommandHandler>()
.AddValidator<AddUserCommand, AddUserCommandValidator>()
.Build();
var handler = services.GetRequiredService<ICommandHandler<AddUserCommand, int>>();
// 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:
public class MinimalApiCqrsOptions
{
public string CommandRoutePrefix { get; set; } = "api/command";
public string QueryRoutePrefix { get; set; } = "api/query";
// NEW: Custom naming convention
public Func<string, string> EndpointNamingConvention { get; set; } = DefaultLowerCamelCase;
// NEW: Override specific endpoint names
public Dictionary<Type, string> 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<string, string> KebabCase => name =>
Regex.Replace(name, "(?<!^)([A-Z])", "-$1").ToLower();
public static Func<string, string> SnakeCase => name =>
Regex.Replace(name, "(?<!^)([A-Z])", "_$1").ToLower();
}
Usage:
app.MapSvrntyCommands(options =>
{
// 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:
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<Type> DisableValidationFor { get; set; } = new();
public HashSet<Type> DisableAuthorizationFor { get; set; } = new();
}
Usage:
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
- Discovery services caching (1.1) - 3 hours
- Reflection caching for meta (1.2) - 1 hour
- Query string parsing (3.1) - 4 hours
- 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
- Logging integration (5.1) - 4 hours
- Assembly scanning (2.1) - 6 hours
- Compiled delegates (1.3) - 6 hours
- Instance-based auth (4.1) - 3 hours
- 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
- Handler lifetime control (2.3) - 2 hours
- Batch registration API (2.2) - 3 hours
- Endpoint naming customization (6.1) - 3 hours
- Granular feature toggles (6.2) - 2 hours
- Assembly scanning optimization (1.4) - 2 hours
- 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
- Testing helpers package (5.3) - 12 hours
- HttpContext access patterns (3.2) - 3 hours
- 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
-
Breaking Changes: Only item #3.2 (Option C) would be a breaking change. All other improvements are backward compatible.
-
Dependencies: Items 1.1 and 1.2 should be done first as they benefit all other work.
-
Testing: Each feature should include unit tests, adding ~30% to time estimates.
-
Documentation: Add XML comments and update README files (+2-3 hours per major feature).
-
NuGet Publishing: Remember to bump versions and publish packages after each phase.