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

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:

  1. Remove "Command" or "Query" suffix
  2. 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?

See Also