dotnet-cqrs/roadmap-2026/quick-analyze-improvements.md
2025-11-10 03:40:25 -05:00

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:

  1. Change registration from Transient to Singleton in ServiceCollectionExtensions.cs:25,29
  2. Convert IEnumerable<ICommandMeta> to List<ICommandMeta> in constructor
  3. 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:

  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):

// 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:

  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:

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 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:

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.


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

  1. Logging integration (5.1) - 4 hours
  2. Assembly scanning (2.1) - 6 hours
  3. Compiled delegates (1.3) - 6 hours
  4. Instance-based auth (4.1) - 3 hours
  5. 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

  1. Handler lifetime control (2.3) - 2 hours
  2. Batch registration API (2.2) - 3 hours
  3. Endpoint naming customization (6.1) - 3 hours
  4. Granular feature toggles (6.2) - 2 hours
  5. Assembly scanning optimization (1.4) - 2 hours
  6. 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

  1. Testing helpers package (5.3) - 12 hours
  2. HttpContext access patterns (3.2) - 3 hours
  3. 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.