Skip to content

Query-Command System Alignment Design

This document outlines a design for aligning the query system architecture with the command system patterns to achieve consistency and reduce complexity.

Implementation note (current)

The proposed simplification of removing the query registry and factory layers was not adopted. The following types are still present and in use:

  • IQueryTypeRegistry (src/Core/LBS.Augment/Query/) and QueryTypeRegistry (src/Domain/LBS.Domain.Infrastructure/Query/Services/).
  • ISearchQueryFactory (src/Core/LBS.Augment/Query/) and SearchQueryFactory (src/Domain/LBS.Domain.Infrastructure/Query/Services/).
  • IDocumentTypeResolver (src/Core/LBS.Augment/Query/) and DocumentTypeResolver (src/Domain/LBS.Domain.Infrastructure/Query/Services/).

What did ship from this design is the nested query handler pattern described in the Nested Query Handler Pattern section below, which is the live pattern for composing query handlers. The rest of the design body is retained for context.

Current State Analysis

Command System Architecture (Target Pattern)

HTTP Request → CommandEndpoint → IDomainTypeMapping → JSON Deserialization → ICommandExecutor → Events

Characteristics: - Simple, direct pattern - Uses existing IDomainTypeMapping for type resolution - Direct JSON deserialization in endpoint - Minimal abstractions - Leverages DataContract names and class name fallbacks

Query System Architecture (Current)

HTTP Request → ReadModelEndpoint → IDocumentTypeResolver → ISearchQueryFactory → IQueryTypeRegistry → IQueryService → Marten → Results

Characteristics: - Multiple abstraction layers - Separate type registry (IQueryTypeRegistry) - Custom factory (ISearchQueryFactory) - Complex service dependencies - Custom JSON converters and model binding

Architectural Inconsistencies

Type Resolution

  • Commands: Use IDomainTypeMapping.GetCommandTypeByName()
  • Queries: Use separate IQueryTypeRegistry.GetQueryType()

Object Creation

  • Commands: Direct JsonSerializer.Deserialize() in endpoint
  • Queries: Custom ISearchQueryFactory.CreateQuery()

Service Dependencies

  • Commands: 2 dependencies (ICommandExecutor, IDomainTypeMapping)
  • Queries: 4+ dependencies (IQueryService, IQueryTypeRegistry, ISearchQueryFactory, IDocumentTypeResolver)

Registration Patterns

  • Commands: Auto-discovered and registered in IDomainTypeMapping
  • Queries: Separate auto-discovery in QueryTypeRegistry

Proposed Hybrid Approach

Core Principle

Align type resolution and object creation patterns while preserving query-specific execution capabilities.

Unified Type Resolution

Extend IDomainTypeMapping to handle both commands and queries:

public interface IDomainTypeMapping
{
    // Existing command methods
    IEnumerable<Type> GetAllCommandTypes();
    Type? GetCommandTypeByName(string commandTypeName);

    // New query methods
    IEnumerable<Type> GetAllQueryTypes();
    Type? GetQueryTypeByName(string queryTypeName);
    void RegisterQuery(Type queryType);
}

Simplified ReadModelEndpoint

Align endpoint pattern with command system:

public class ReadModelEndpoint : Endpoint<ReadModelQueryContract, ReadModelResponse>
{
    private readonly IQueryService queryService;
    private readonly IDomainTypeMapping domainTypeMapping;

    public override async Task HandleAsync(ReadModelQueryContract req, CancellationToken ct)
    {
        try
        {
            // 1. Resolve query type (same pattern as commands)
            var queryType = this.domainTypeMapping.GetQueryTypeByName(req.Query.QueryType);
            if (queryType == null)
            {
                await this.SendErrorResponse("Unknown query type", 400);
                return;
            }

            // 2. Deserialize JSON to specific query type (same pattern as commands)
            var queryJson = JsonSerializer.Serialize(req.Query);
            var query = JsonSerializer.Deserialize(queryJson, queryType) as ISearchQuery;

            if (query == null)
            {
                await this.SendErrorResponse("Failed to deserialize query", 400);
                return;
            }

            // 3. Execute query (preserve existing query execution logic)
            var result = await this.queryService.ExecuteAsync(query, req.Skip, req.Take, req.OrderBy);

            await this.SendOkAsync(result, ct);
        }
        catch (Exception ex)
        {
            // Consistent error handling pattern
            await this.SendErrorResponse("Query execution failed", 500);
        }
    }
}

Enhanced DomainTypeMappings Implementation

public sealed class DomainTypeMappings : IDomainTypeMapping
{
    // Existing command type mappings
    private readonly Lazy<Dictionary<string, Type>> commandTypesByName;

    // New query type mappings
    private readonly List<Type> queryTypes;
    private readonly Lazy<Dictionary<string, Type>> queryTypesByName;

    public DomainTypeMappings()
    {
        this.queryTypes = [];

        // Initialize query type lookup with same pattern as commands
        this.queryTypesByName = new Lazy<Dictionary<string, Type>>(() =>
        {
            var queryTypes = new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase);

            foreach (var type in this.GetAllQueryTypes())
            {
                // Try to get QueryType property value first (from ISearchQuery interface)
                if (TryGetQueryTypeProperty(type, out var queryTypeName))
                {
                    queryTypes[queryTypeName] = type;
                }

                // Also add by class name without "Query" suffix as fallback
                var fallbackName = type.Name.Replace("Query", "");
                if (!queryTypes.ContainsKey(fallbackName))
                {
                    queryTypes[fallbackName] = type;
                }
            }

            return queryTypes;
        });
    }

    public void RegisterQuery(Type queryType)
    {
        if (!typeof(ISearchQuery).IsAssignableFrom(queryType))
        {
            throw new ArgumentException($"Type {queryType.Name} does not implement ISearchQuery");
        }

        if (!this.queryTypes.Contains(queryType))
        {
            this.queryTypes.Add(queryType);
        }
    }

    public IEnumerable<Type> GetAllQueryTypes() => this.queryTypes;

    public Type? GetQueryTypeByName(string queryTypeName)
    {
        return this.queryTypesByName.Value.TryGetValue(queryTypeName, out var queryType) ? queryType : null;
    }

    private static bool TryGetQueryTypeProperty(Type type, out string queryTypeName)
    {
        try
        {
            // Create instance and get QueryType property value
            var instance = Activator.CreateInstance(type) as ISearchQuery;
            queryTypeName = instance?.QueryType ?? string.Empty;
            return !string.IsNullOrEmpty(queryTypeName);
        }
        catch
        {
            queryTypeName = string.Empty;
            return false;
        }
    }
}

Implementation Plan

Phase 1: Extend IDomainTypeMapping

  1. Add query-related methods to IDomainTypeMapping interface
  2. Implement query type registration and lookup in DomainTypeMappings
  3. Add auto-discovery of query types during startup
  4. Update service registration to register query types

Phase 2: Simplify ReadModelEndpoint

  1. Update ReadModelEndpoint to use IDomainTypeMapping instead of IQueryTypeRegistry
  2. Replace ISearchQueryFactory with direct JSON deserialization
  3. Remove custom JSON converter dependencies
  4. Maintain IDocumentTypeResolver for data type mapping

Phase 3: Remove Redundant Services

  1. Mark IQueryTypeRegistry as obsolete
  2. Mark ISearchQueryFactory as obsolete
  3. Remove custom query JSON converters
  4. Update service registration to remove obsolete services

Phase 4: Update Service Registration

public static IServiceCollection AddLuckboxCoreServices(this IServiceCollection services, IConfiguration configuration)
{
    // Register query types with IDomainTypeMapping
    services.AddSingleton<IDomainTypeMapping>(provider =>
    {
        var mapping = new DomainTypeMappings();

        // Register command types (existing)
        RegisterCommandTypes(mapping);

        // Register query types (new)
        RegisterQueryTypes(mapping);

        return mapping;
    });

    // Keep essential query services
    services.AddSingleton<IDocumentTypeResolver, DocumentTypeResolver>();
    services.AddScoped<IQueryService, QueryService>();

    // Remove obsolete services
    // services.AddSingleton<IQueryTypeRegistry, QueryTypeRegistry>(); // REMOVE
    // services.AddSingleton<ISearchQueryFactory, SearchQueryFactory>(); // REMOVE

    return services;
}

private static void RegisterQueryTypes(IDomainTypeMapping mapping)
{
    var assemblies = new[] { typeof(ParticipantId).Assembly, typeof(NrlSuperCoachParticipantStatId).Assembly };
    var queryTypes = assemblies.SelectMany(assembly =>
        assembly.GetTypes()
            .Where(type => !type.IsAbstract && !type.IsInterface)
            .Where(type => typeof(ISearchQuery).IsAssignableFrom(type)));

    foreach (var queryType in queryTypes)
    {
        mapping.RegisterQuery(queryType);
    }
}

Benefits of Hybrid Approach

Architectural Consistency

  • Unified type resolution pattern for commands and queries
  • Consistent JSON deserialization approach
  • Same error handling patterns
  • Aligned service dependency patterns

Reduced Complexity

  • Eliminate redundant IQueryTypeRegistry and ISearchQueryFactory
  • Fewer services to maintain and test
  • Simpler dependency injection setup
  • Reduced cognitive overhead for developers

Preserved Functionality

  • Maintain existing query execution capabilities
  • Keep IQueryService for complex query logic
  • Preserve IDocumentTypeResolver for data type mapping
  • No loss of current query features (pagination, sorting, filtering)

Developer Experience

  • Same mental model for both commands and queries
  • Easier onboarding for new developers
  • Consistent debugging and testing patterns
  • Reduced learning curve

Migration Considerations

Backward Compatibility

  • Mark obsolete services as [Obsolete] initially
  • Provide migration period before removal
  • Document breaking changes clearly
  • Offer migration scripts if needed

Testing Strategy

  • Comprehensive integration tests for new pattern
  • Verify existing query functionality remains intact
  • Test error scenarios with new error handling
  • Performance testing to ensure no regression

Rollout Plan

  • Implement in feature branch first
  • Test with subset of queries initially
  • Gradual migration of existing queries
  • Monitor for any performance or functionality issues

Potential Challenges

Query Type Resolution Complexity

Query types use the QueryType property from ISearchQuery interface, which requires instance creation to access. This is different from commands that use DataContract attributes.

Mitigation: - Cache query type instances during startup - Consider adding static QueryType property or attribute to query classes - Fall back to class name pattern as secondary resolution

Breaking Changes

Services that directly depend on IQueryTypeRegistry or ISearchQueryFactory will need updates.

Mitigation: - Provide clear migration documentation - Use obsolete attributes with guidance - Offer compatibility shims during transition period

Performance Considerations

Creating query instances to get QueryType values during startup could impact performance.

Mitigation: - Implement lazy loading patterns - Cache results after first resolution - Consider alternative query type identification approaches

Alternative Approaches Considered

Full Command Pattern Adoption

Move queries to exact same pattern as commands with IQueryExecutor.

Rejected because: - Queries have fundamentally different requirements (pagination, sorting, filtering) - Would require significant rewrite of existing query infrastructure - Query handlers have different patterns than command handlers

Full Query Pattern for Commands

Move commands to use registry/factory pattern like queries.

Rejected because: - Commands work well with current simple pattern - Would add unnecessary complexity to command system - No clear benefit over current command implementation

Nested Query Handler Pattern

When a query handler needs to call another query handler (nested queries), use direct instantiation with ILoggerFactory - do NOT inject IQueryHandler<TQuery, TResult> interfaces as dependencies.

Why NOT to Use IQueryHandler Injection

Injecting IQueryHandler<TQuery, TResult> for nested queries causes problems: - Creates tight coupling and complicates the Foundry override inheritance pattern - Requires the child handlers to be registered in DI, which isn't always appropriate - Makes it difficult for the two-store pattern (IDocumentStore / IFoundryStore) to work correctly

Correct Pattern: Direct Instantiation with ILoggerFactory

[RequiresModule(ModuleDefinition.SuperCoach)]
public class PlayerSearchQueryHandler : IQueryHandler<PlayerSearchQuery, PlayerSearchQueryResultContract>
{
    private readonly IDocumentStore store;
    private readonly ILogger<PlayerSearchQueryHandler> logger;
    private readonly ILoggerFactory loggerFactory;

    public PlayerSearchQueryHandler(
        IDocumentStore store,
        ILogger<PlayerSearchQueryHandler> logger,
        ILoggerFactory loggerFactory)
    {
        this.store = store;
        this.logger = logger;
        this.loggerFactory = loggerFactory;
    }

    private async Task<(...) Results, int TotalCount)> GetNrlParticipants(PlayerSearchQuery query)
    {
        // Instantiate child handler directly, passing the SAME store
        var handler = new NrlPlayerRoundSearchQueryHandler(
            this.store,
            this.loggerFactory.CreateLogger<NrlPlayerRoundSearchQueryHandler>());
        var (results, totalCount) = await handler.HandleAsync(roundSearch);
        // ...
    }
}

Why This Works with the Two-Store Pattern

When using the Foundry override pattern:

  1. Base handler (PlayerSearchQueryHandler) takes IDocumentStore in constructor
  2. Foundry handler (FoundryPlayerSearchQueryHandler) inherits from base and takes IFoundryStore
  3. The IFoundryStore is passed to the base constructor (which accepts IDocumentStore)
  4. When the base handler instantiates child handlers with this.store, the same store (IFoundryStore) is used
  5. Child handlers work correctly against the Foundry database without needing separate overrides
// Foundry override - passes IFoundryStore to base
[RequiresModule(BallrModuleDefinition.FoundryReadModels)]
public class FoundryPlayerSearchQueryHandler : PlayerSearchQueryHandler
{
    public FoundryPlayerSearchQueryHandler(
        IFoundryStore store,  // IFoundryStore implements IDocumentStore
        ILogger<PlayerSearchQueryHandler> logger,
        ILoggerFactory loggerFactory)
        : base(store, logger, loggerFactory)  // IFoundryStore flows through
    {
    }
}

Child Handler Registration

Child handlers that are only instantiated directly by a parent handler should NOT be registered in feature definitions:

// BallrCoreFeature.cs
public Type[] GetQueryHandlerTypes()
{
    return new[]
    {
        typeof(PlayerSearchQueryHandler),      // DI registered - called via API
        typeof(PlayerComparisonQueryHandler),  // DI registered - called via API
        // NOT registered: NrlPlayerRoundSearchQueryHandler (instantiated directly)
        // NOT registered: BblPlayerRoundSearchQueryHandler (instantiated directly)
        typeof(SuperCoachParticipantProfileBySlugQueryHandler),
    };
}

Example: GetSuperCoachMatchStatsByIdQueryHandler

Another example of this pattern in the codebase:

public class GetSuperCoachMatchStatsByIdQueryHandler : IQueryHandler<...>
{
    private readonly IDocumentStore store;
    private readonly ILoggerFactory loggerFactory;

    public async Task<...> HandleAsync(GetSuperCoachMatchStatsByIdQuery query)
    {
        // Instantiate child handler directly
        var handler = new GetCricketScoreCardByEventQueryHandler(
            this.store,
            this.loggerFactory.CreateLogger<GetCricketScoreCardByEventQueryHandler>());

        var scoreCard = await handler.HandleAsync(new GetCricketScoreCardByEventQuery { ... });
        // ...
    }
}

Key Rules

  1. DO inject ILoggerFactory in the constructor
  2. DO instantiate child handlers with new ChildHandler(this.store, this.loggerFactory.CreateLogger<ChildHandler>())
  3. DO pass this.store to child handlers so the same database connection is used
  4. DO NOT inject IQueryHandler<TQuery, TResult> as constructor dependencies for nested queries
  5. DO NOT register child handlers in feature definitions if they're only used via direct instantiation

Conclusion

The hybrid approach provides the best balance of consistency, simplicity, and functionality preservation. It aligns the type resolution and object creation patterns between commands and queries while maintaining the specialized query execution capabilities that are essential for the read side of the CQRS pattern.

This approach reduces architectural inconsistencies and complexity while preserving all existing functionality, making it the recommended path forward for system alignment.