dotnet-cqrs/docs/architecture/metadata-discovery.md

538 lines
14 KiB
Markdown

# 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)
```csharp
// ❌ 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)
```csharp
// ✅ 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()`:
```csharp
// User code
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
```
This extension method does two things:
**1. Registers the handler in DI:**
```csharp
services.AddScoped<ICommandHandler<CreateUserCommand, int>, CreateUserCommandHandler>();
```
**2. Creates and registers metadata:**
```csharp
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:
```csharp
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:
```csharp
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:
```csharp
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:
```csharp
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:
```csharp
public interface ICommandDiscovery
{
IEnumerable<ICommandMeta> GetCommands();
ICommandMeta? FindCommand(string name);
bool CommandExists(Type commandType);
}
```
**Usage:**
```csharp
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:
```csharp
public interface IQueryDiscovery
{
IEnumerable<IQueryMeta> GetQueries();
IQueryMeta? FindQuery(string name);
bool QueryExists(Type queryType);
}
```
**Usage:**
```csharp
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
```csharp
CreateUserCommand "createUser"
UpdateProfileCommand "updateProfile"
DeleteOrderCommand "deleteOrder"
GetUserQuery "getUser"
SearchProductsQuery "searchProducts"
ListOrdersQuery "listOrders"
```
**Rules:**
1. Remove "Command" or "Query" suffix
2. Convert PascalCase to lowerCamelCase
### Custom Naming
Use attributes to override:
```csharp
[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:
```csharp
[IgnoreCommand]
public record InternalCommand { /* ... */ }
// No endpoint created
[IgnoreQuery]
public record InternalQuery { /* ... */ }
// No endpoint created
```
## Registration Patterns
### Basic Registration
```csharp
// 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
```csharp
services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler, CreateUserCommandValidator>();
services.AddQuery<SearchUsersQuery, List<UserDto>, SearchUsersQueryHandler, SearchUsersQueryValidator>();
```
### Bulk Registration
For multiple handlers, use loops:
```csharp
// 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
```csharp
// ✅ 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:
```csharp
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
```csharp
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:
```csharp
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:
```csharp
// ✅ 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:**
```csharp
// 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:**
```csharp
// ❌ 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](modular-solution-structure.md)** - Organize large applications
- **[Dependency Injection](dependency-injection.md)** - DI patterns for handlers
- **[Extensibility Points](extensibility-points.md)** - Customization mechanisms
## See Also
- [Core Features: Commands](../core-features/commands/README.md) - Command documentation
- [Core Features: Queries](../core-features/queries/README.md) - Query documentation
- [HTTP Integration](../http-integration/README.md) - How HTTP endpoints are generated
- [gRPC Integration](../grpc-integration/README.md) - How gRPC services are generated