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/) andQueryTypeRegistry(src/Domain/LBS.Domain.Infrastructure/Query/Services/).ISearchQueryFactory(src/Core/LBS.Augment/Query/) andSearchQueryFactory(src/Domain/LBS.Domain.Infrastructure/Query/Services/).IDocumentTypeResolver(src/Core/LBS.Augment/Query/) andDocumentTypeResolver(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¶
- Add query-related methods to
IDomainTypeMappinginterface - Implement query type registration and lookup in
DomainTypeMappings - Add auto-discovery of query types during startup
- Update service registration to register query types
Phase 2: Simplify ReadModelEndpoint¶
- Update
ReadModelEndpointto useIDomainTypeMappinginstead ofIQueryTypeRegistry - Replace
ISearchQueryFactorywith direct JSON deserialization - Remove custom JSON converter dependencies
- Maintain
IDocumentTypeResolverfor data type mapping
Phase 3: Remove Redundant Services¶
- Mark
IQueryTypeRegistryas obsolete - Mark
ISearchQueryFactoryas obsolete - Remove custom query JSON converters
- 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
IQueryTypeRegistryandISearchQueryFactory - Fewer services to maintain and test
- Simpler dependency injection setup
- Reduced cognitive overhead for developers
Preserved Functionality¶
- Maintain existing query execution capabilities
- Keep
IQueryServicefor complex query logic - Preserve
IDocumentTypeResolverfor 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:
- Base handler (
PlayerSearchQueryHandler) takesIDocumentStorein constructor - Foundry handler (
FoundryPlayerSearchQueryHandler) inherits from base and takesIFoundryStore - The
IFoundryStoreis passed to the base constructor (which acceptsIDocumentStore) - When the base handler instantiates child handlers with
this.store, the same store (IFoundryStore) is used - 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¶
- DO inject
ILoggerFactoryin the constructor - DO instantiate child handlers with
new ChildHandler(this.store, this.loggerFactory.CreateLogger<ChildHandler>()) - DO pass
this.storeto child handlers so the same database connection is used - DO NOT inject
IQueryHandler<TQuery, TResult>as constructor dependencies for nested queries - 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.