14 KiB
Metadata Discovery
Learn how Svrnty.CQRS uses metadata-driven discovery for automatic endpoint generation.
Overview
Svrnty.CQRS uses a metadata pattern instead of reflection-heavy assembly scanning. When you register a handler, the framework creates metadata that describes it. Discovery services then query this metadata to generate endpoints.
Why Metadata Instead of Reflection?
Traditional Approach (Assembly Scanning)
// ❌ Slow, not AOT-compatible
public void MapEndpoints(IApplicationBuilder app)
{
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
foreach (var assembly in assemblies)
{
var handlers = assembly.GetTypes()
.Where(t => t.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(ICommandHandler<,>)));
foreach (var handler in handlers)
{
// Create endpoint...
}
}
}
Problems:
- Slow startup (scanning all assemblies)
- Not AOT-compatible (heavy reflection)
- May find unintended handlers
- Hard to control what gets discovered
Metadata Approach (Svrnty.CQRS)
// ✅ Fast, AOT-compatible
services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
// Behind the scenes:
services.AddSingleton<ICommandMeta>(
new CommandMeta<CreateUserCommand, int, CreateUserCommandHandler>());
Benefits:
- Fast startup (no assembly scanning)
- AOT-compatible (metadata created at compile time)
- Explicit control (only registered handlers discovered)
- Type-safe (compile-time checking)
How It Works
Step 1: Registration Creates Metadata
When you call AddCommand() or AddQuery():
// User code
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
This extension method does two things:
1. Registers the handler in DI:
services.AddScoped<ICommandHandler<CreateUserCommand, int>, CreateUserCommandHandler>();
2. Creates and registers metadata:
services.AddSingleton<ICommandMeta>(new CommandMeta
{
CommandType = typeof(CreateUserCommand),
HandlerType = typeof(CreateUserCommandHandler),
ResultType = typeof(int),
CommandName = "createUser" // Auto-generated from type name
});
Step 2: Discovery Service Enumerates Metadata
The discovery service queries all registered metadata:
public class CommandDiscovery : ICommandDiscovery
{
private readonly IEnumerable<ICommandMeta> _commandMetas;
public CommandDiscovery(IEnumerable<ICommandMeta> commandMetas)
{
_commandMetas = commandMetas;
}
public IEnumerable<ICommandMeta> GetCommands() => _commandMetas;
public ICommandMeta? FindCommand(string name)
{
return _commandMetas.FirstOrDefault(m =>
m.CommandName.Equals(name, StringComparison.OrdinalIgnoreCase));
}
public bool CommandExists(Type commandType)
{
return _commandMetas.Any(m => m.CommandType == commandType);
}
}
Step 3: Endpoint Generation Uses Discovery
HTTP and gRPC integrations use discovery to create endpoints:
public static IEndpointRouteBuilder UseSvrntyCqrs(this IEndpointRouteBuilder endpoints)
{
var commandDiscovery = endpoints.ServiceProvider.GetRequiredService<ICommandDiscovery>();
// Enumerate all registered commands
foreach (var commandMeta in commandDiscovery.GetCommands())
{
if (commandMeta.IgnoreEndpoint)
continue;
// Create endpoint for this command
endpoints.MapPost($"/api/command/{commandMeta.CommandName}", async (HttpContext context) =>
{
// Deserialize, validate, execute handler...
});
}
// Same for queries...
}
Metadata Interfaces
ICommandMeta
Describes a command handler:
public interface ICommandMeta
{
Type CommandType { get; } // typeof(CreateUserCommand)
Type HandlerType { get; } // typeof(CreateUserCommandHandler)
Type? ResultType { get; } // typeof(int) or null
string CommandName { get; } // "createUser"
bool IgnoreEndpoint { get; } // From [IgnoreCommand]
string? CustomName { get; } // From [CommandName("...")]
}
IQueryMeta
Describes a query handler:
public interface IQueryMeta
{
Type QueryType { get; } // typeof(GetUserQuery)
Type HandlerType { get; } // typeof(GetUserQueryHandler)
Type ResultType { get; } // typeof(UserDto)
string QueryName { get; } // "getUser"
bool IgnoreEndpoint { get; } // From [IgnoreQuery]
string? CustomName { get; } // From [QueryName("...")]
string Category { get; } // "Query" or "DynamicQuery"
}
Discovery Services
ICommandDiscovery
Provides command enumeration and lookup:
public interface ICommandDiscovery
{
IEnumerable<ICommandMeta> GetCommands();
ICommandMeta? FindCommand(string name);
bool CommandExists(Type commandType);
}
Usage:
var commandDiscovery = serviceProvider.GetRequiredService<ICommandDiscovery>();
// List all commands
foreach (var meta in commandDiscovery.GetCommands())
{
Console.WriteLine($"Command: {meta.CommandName} → {meta.HandlerType.Name}");
}
// Find specific command
var meta = commandDiscovery.FindCommand("createUser");
if (meta != null)
{
Console.WriteLine($"Found: {meta.CommandType.Name}");
}
IQueryDiscovery
Provides query enumeration and lookup:
public interface IQueryDiscovery
{
IEnumerable<IQueryMeta> GetQueries();
IQueryMeta? FindQuery(string name);
bool QueryExists(Type queryType);
}
Usage:
var queryDiscovery = serviceProvider.GetRequiredService<IQueryDiscovery>();
// List all queries
foreach (var meta in queryDiscovery.GetQueries())
{
Console.WriteLine($"Query: {meta.QueryName} → {meta.ResultType.Name}");
}
// Find specific query
var meta = queryDiscovery.FindQuery("getUser");
Naming Conventions
Endpoint names are auto-generated from type names:
Default Naming
CreateUserCommand → "createUser"
UpdateProfileCommand → "updateProfile"
DeleteOrderCommand → "deleteOrder"
GetUserQuery → "getUser"
SearchProductsQuery → "searchProducts"
ListOrdersQuery → "listOrders"
Rules:
- Remove "Command" or "Query" suffix
- Convert PascalCase to lowerCamelCase
Custom Naming
Use attributes to override:
[CommandName("register")]
public record CreateUserCommand { /* ... */ }
// Endpoint: POST /api/command/register
[QueryName("user")]
public record GetUserQuery { /* ... */ }
// Endpoint: GET /api/query/user
Ignoring Endpoints
Prevent endpoint generation:
[IgnoreCommand]
public record InternalCommand { /* ... */ }
// No endpoint created
[IgnoreQuery]
public record InternalQuery { /* ... */ }
// No endpoint created
Registration Patterns
Basic Registration
// Command with result
services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
// Command without result
services.AddCommand<DeleteUserCommand, DeleteUserCommandHandler>();
// Query (always has result)
services.AddQuery<GetUserQuery, UserDto, GetUserQueryHandler>();
Registration with Validator
services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler, CreateUserCommandValidator>();
services.AddQuery<SearchUsersQuery, List<UserDto>, SearchUsersQueryHandler, SearchUsersQueryValidator>();
Bulk Registration
For multiple handlers, use loops:
// Register all commands in namespace
services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
services.AddCommand<UpdateUserCommand, UpdateUserCommandHandler>();
services.AddCommand<DeleteUserCommand, DeleteUserCommandHandler>();
// Or use a helper method
public static class CommandRegistration
{
public static IServiceCollection AddUserCommands(this IServiceCollection services)
{
services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
services.AddCommand<UpdateUserCommand, UpdateUserCommandHandler>();
services.AddCommand<DeleteUserCommand, DeleteUserCommandHandler>();
return services;
}
}
// Usage
builder.Services.AddUserCommands();
Type Safety
Metadata discovery maintains compile-time type safety:
Compile-Time Checking
// ✅ Correct - handler matches command and result
services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
// ❌ Compile error - handler doesn't implement ICommandHandler<CreateUserCommand, int>
services.AddCommand<CreateUserCommand, int, WrongHandler>();
// ❌ Compile error - result type mismatch
services.AddCommand<CreateUserCommand, string, CreateUserCommandHandler>();
// (Handler returns int, not string)
Generic Constraints
Registration methods use generic constraints:
public static IServiceCollection AddCommand<TCommand, TResult, THandler>(
this IServiceCollection services)
where THandler : class, ICommandHandler<TCommand, TResult>
{
// Constraint ensures THandler implements correct interface
}
Discovery at Runtime
Accessing Discovery Services
var app = builder.Build();
// Get discovery service
var commandDiscovery = app.Services.GetRequiredService<ICommandDiscovery>();
// List all registered commands
foreach (var meta in commandDiscovery.GetCommands())
{
Console.WriteLine($"Command: {meta.CommandName}");
Console.WriteLine($" Type: {meta.CommandType.Name}");
Console.WriteLine($" Handler: {meta.HandlerType.Name}");
Console.WriteLine($" Result: {meta.ResultType?.Name ?? "void"}");
Console.WriteLine();
}
Dynamic Endpoint Creation
Discovery enables dynamic behavior:
app.MapGet("/api/commands", (ICommandDiscovery discovery) =>
{
var commands = discovery.GetCommands()
.Select(m => new
{
name = m.CommandName,
type = m.CommandType.Name,
hasResult = m.ResultType != null
});
return Results.Ok(commands);
});
// GET /api/commands returns:
// [
// { "name": "createUser", "type": "CreateUserCommand", "hasResult": true },
// { "name": "deleteUser", "type": "DeleteUserCommand", "hasResult": false }
// ]
Performance
Startup Performance
Fast startup:
- No assembly scanning
- Metadata created during registration (one-time cost)
- Discovery just queries singletons
Benchmark:
Assembly scanning: ~200ms for 100 handlers
Metadata pattern: ~5ms for 100 handlers
Runtime Performance
Fast lookup:
- Metadata stored as singletons
- Simple enumeration (no reflection)
- LINQ queries over in-memory collection
Memory Footprint
Minimal memory:
- One metadata object per handler
- Metadata is small (just type references and strings)
- Singleton lifetime (shared across requests)
AOT Compatibility
Metadata pattern is AOT (Ahead-of-Time) compatible:
// ✅ AOT-compatible
services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
// Metadata types known at compile time
// No runtime type discovery
// No assembly scanning
Benefits:
- Faster startup
- Smaller deployment size
- Better performance
- Suitable for containers and serverless
Comparison: Metadata vs Reflection
| Aspect | Metadata Pattern | Assembly Scanning |
|---|---|---|
| Startup time | Fast (~5ms) | Slow (~200ms) |
| AOT compatible | ✅ Yes | ❌ No |
| Type safety | ✅ Compile-time | ⚠️ Runtime |
| Explicit control | ✅ Yes | ❌ No (finds all) |
| Memory usage | Low | Medium |
| Maintainability | ✅ Clear registration | ⚠️ "Magic" discovery |
Best Practices
✅ DO
- Register handlers explicitly
- Use meaningful command/query names
- Group related registrations
- Create extension methods for bulk registration
- Use attributes for customization ([CommandName], [IgnoreCommand])
❌ DON'T
- Don't rely on assembly scanning
- Don't use reflection to find handlers
- Don't duplicate registrations
- Don't forget to register handlers (they won't be discovered)
Troubleshooting
Handler Not Found
Problem: Command/query endpoint returns 404
Cause: Handler not registered
Solution:
// Ensure you registered the handler
builder.Services.AddCommand<YourCommand, YourResult, YourHandler>();
// Verify registration at startup
var discovery = app.Services.GetRequiredService<ICommandDiscovery>();
var found = discovery.FindCommand("yourCommand");
if (found == null)
{
Console.WriteLine("ERROR: YourCommand not registered!");
}
Wrong Handler Invoked
Problem: Different handler executes than expected
Cause: Multiple handlers for same command
Solution:
// ❌ Don't register multiple handlers for same command
services.AddCommand<CreateUserCommand, int, HandlerA>();
services.AddCommand<CreateUserCommand, int, HandlerB>(); // Last one wins
// ✅ Only register one handler per command
services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
What's Next?
- Modular Solution Structure - Organize large applications
- Dependency Injection - DI patterns for handlers
- Extensibility Points - Customization mechanisms
See Also
- Core Features: Commands - Command documentation
- Core Features: Queries - Query documentation
- HTTP Integration - How HTTP endpoints are generated
- gRPC Integration - How gRPC services are generated