Topic Filter Discovery¶
Verified by tests
TopicFilterGeneratorTests — library CI run #31657041675 (2026-08-13)
The TopicFilterGenerator discovers all ICommand implementations decorated with [TopicFilter] attributes at compile-time and generates an AOT-compatible topic filter registry. This enables zero-reflection routing configuration with support for both string literals and strongly-typed enums.
Zero Reflection Philosophy¶
Traditional message routing frameworks configure topics at runtime using reflection or configuration files:
Zero Reflection Philosophy
// ❌ Reflection-based (incompatible with AOT, error-prone)
var topicAttr = command.GetType()
.GetCustomAttribute<TopicFilterAttribute>();
var topic = topicAttr?.Filter; // Runtime reflection
// ❌ Configuration-based (disconnected from code, no type safety)
// appsettings.json
{
"CommandTopics": {
"CreateOrderCommand": "orders.created" // Typos not caught at compile time
}
}
Whizbang uses Roslyn source generators for compile-time topic extraction:
Zero Reflection Philosophy (2)
// ✅ Zero reflection (AOT-compatible, compile-time validation)
var topics = TopicFilterRegistry.GetTopicFilters<CreateOrderCommand>();
// Returns: ["orders.created"]
// Generated at compile-time!
Benefits:
- ✅ AOT Compatible: No runtime reflection or attribute scanning
- ✅ Type Safe: Enum-based topics with compile-time validation
- ✅ Fast Lookup: Direct type checks (~5ns overhead)
- ✅ Centralized: All topic mappings in one generated registry
- ✅ Description Support: Extracts [Description] attributes from enums
How It Works¶
1. Compile-Time Discovery¶
flowchart TD
Code["Your Code<br/><br/>[TopicFilter(#quot;orders.created#quot;)]<br/>public record CreateOrderCommand : ICommand {<br/>// ...<br/>}"]
Generator["TopicFilterGenerator (Roslyn)<br/><br/>1. Scan syntax tree for classes/records<br/>2. Filter types with attributes<br/>3. Check for ICommand implementation<br/>4. Extract TopicFilter attribute values<br/>5. Extract enum Description attributes"]
Generated["Generated Code<br/><br/>TopicFilterRegistry.g.cs<br/>— GetTopicFilters<TCommand>()<br/>— GetAllFilters()"]
Code --> Generator
Generator --> Generated
class Code layer-command
class Generator layer-infrastructure
class Generated layer-core
2. String-Based Filter Extraction¶
String-Based Filter Extraction
// Input: Attribute on command
[TopicFilter("orders.created")]
public record CreateOrderCommand : ICommand { }
// Generator extracts string literal directly
// Output: Registry entry
if (typeof(TCommand) == typeof(global::MyApp.Commands.CreateOrderCommand)) {
return new[] { "orders.created" };
}
3. Enum-Based Filter Extraction¶
Enum-Based Filter Extraction
// Input: Enum with Description attribute
public enum ServiceBusTopics {
[Description("orders.created")]
OrdersCreated
}
[TopicFilter<ServiceBusTopics>(ServiceBusTopics.OrdersCreated)]
public record CreateOrderCommand : ICommand { }
// Generator:
// 1. Gets enum value (ServiceBusTopics.OrdersCreated)
// 2. Finds enum field by ConstantValue
// 3. Extracts [Description] attribute ("orders.created")
// 4. Falls back to symbol name if no Description ("OrdersCreated")
// Output: Registry entry
if (typeof(TCommand) == typeof(global::MyApp.Commands.CreateOrderCommand)) {
return new[] { "orders.created" }; // From Description attribute
}
Generated File¶
TopicFilterRegistry.g.cs:
Generated File
// <auto-generated/>
// Generated by TopicFilterGenerator at 2024-12-14 15:00:00 UTC
// DO NOT EDIT - Changes will be overwritten
#nullable enable
namespace MyApp.Generated;
using System;
using System.Collections.Generic;
using Whizbang.Core;
/// <summary>
/// Auto-generated registry for topic filter lookups.
/// Generated from 3 topic filter(s) across 2 command(s).
/// </summary>
public static class TopicFilterRegistry {
/// <summary>
/// Gets all topic filters for the specified command type.
/// Returns empty array if command has no topic filters.
/// </summary>
/// <typeparam name="TCommand">The command type to look up</typeparam>
/// <returns>Array of topic filter strings</returns>
public static string[] GetTopicFilters<TCommand>() where TCommand : ICommand {
if (typeof(TCommand) == typeof(global::MyApp.Commands.CreateOrderCommand)) {
return new[] { "orders.created", "analytics.orders" };
}
if (typeof(TCommand) == typeof(global::MyApp.Commands.ProcessPaymentCommand)) {
return new[] { "payments.processed" };
}
return Array.Empty<string>();
}
/// <summary>
/// Gets all topic filters for all commands (for diagnostics and tooling).
/// </summary>
/// <returns>Dictionary mapping command names to their topic filters</returns>
public static IReadOnlyDictionary<string, string[]> GetAllFilters() {
return new Dictionary<string, string[]> {
{ "CreateOrderCommand", new[] { "orders.created", "analytics.orders" } },
{ "ProcessPaymentCommand", new[] { "payments.processed" } }
};
}
}
Using Generated Registry¶
Basic Lookup¶
Basic Lookup
using MyApp.Generated;
// Get topic filters for a specific command
var topics = TopicFilterRegistry.GetTopicFilters<CreateOrderCommand>();
// Returns: ["orders.created", "analytics.orders"]
// Use in routing logic
foreach (var topic in topics) {
await _transport.PublishAsync(command, new TransportDestination {
Topic = topic,
// ... other routing metadata
});
}
Startup Validation¶
Startup Validation
// Validate all topics exist in message broker at startup
public static void ValidateTopics(IServiceProvider services) {
var allFilters = TopicFilterRegistry.GetAllFilters();
var transport = services.GetRequiredService<ITransport>();
foreach (var (command, topics) in allFilters) {
foreach (var topic in topics) {
if (!transport.TopicExists(topic)) {
throw new InvalidOperationException(
$"Command '{command}' references non-existent topic '{topic}'"
);
}
}
}
}
Diagnostics and Tooling¶
Diagnostics and Tooling
// List all command → topic mappings
var allFilters = TopicFilterRegistry.GetAllFilters();
foreach (var (command, topics) in allFilters) {
Console.WriteLine($"{command}:");
foreach (var topic in topics) {
Console.WriteLine($" → {topic}");
}
}
// Output:
// CreateOrderCommand:
// → orders.created
// → analytics.orders
// ProcessPaymentCommand:
// → payments.processed
Generator Internals¶
Value Type Record for Caching¶
Value Type Record for Caching
Why sealed record? - Value equality: Incremental caching relies on structural comparison - Immutable: No risk of cache invalidation from mutation - Performance: Compiler optimizes sealed types
Impact: Record caching saves 50-200ms per incremental build.
Syntactic Filtering¶
Generator uses syntactic predicates to filter 95%+ of nodes before expensive semantic analysis:
Syntactic Filtering
// Fast syntactic check (no semantic model access)
predicate: static (node, _) =>
(node is ClassDeclarationSyntax or RecordDeclarationSyntax) &&
((TypeDeclarationSyntax)node).AttributeLists.Count > 0,
// Only runs on types with attributes (~5% of nodes)
transform: static (ctx, ct) => ExtractTopicFilters(ctx, ct)
Performance: - Without predicate: ~10,000ms on 10,000 types (analyzes everything) - With predicate: ~50-100ms on 10,000 types (analyzes only attributed types)
100x faster with proper filtering!
Attribute Inheritance Detection¶
Generator recognizes derived attributes:
Attribute Inheritance Detection
// Check if attribute is TopicFilterAttribute or derived from it
var currentClass = attr.AttributeClass;
while (currentClass is not null) {
var fullName = currentClass.ToDisplayString();
if (fullName == TOPIC_FILTER_ATTRIBUTE ||
fullName.StartsWith(TOPIC_FILTER_ATTRIBUTE + "<")) {
return true; // Recognize both base and generic versions
}
currentClass = currentClass.BaseType;
}
Supports:
- TopicFilterAttribute (base, string parameter)
- TopicFilterAttribute<TEnum> (generic, enum parameter)
- Any derived custom attributes
Enum Description Extraction¶
Enum Description Extraction
// Get enum value from attribute constructor argument
var enumValue = firstArg.Value; // Numeric value (0, 1, 2...)
// Find enum field by matching ConstantValue (not symbol name!)
var enumField = enumType.GetMembers()
.OfType<IFieldSymbol>()
.FirstOrDefault(f => f.HasConstantValue && Equals(f.ConstantValue, enumValue));
// Extract Description attribute
var descriptionAttr = enumField.GetAttributes()
.FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == DESCRIPTION_ATTRIBUTE);
if (descriptionAttr is not null && descriptionAttr.ConstructorArguments.Length > 0) {
return descriptionAttr.ConstructorArguments[0].Value?.ToString(); // "orders.created"
}
// Fallback to enum symbol name
return enumField.Name; // "OrdersCreated"
Key Insight: Match enum fields by ConstantValue, not by name, since ToString() on numeric values returns "0", "1", etc.
Generator Performance¶
Incremental Compilation¶
Roslyn incremental generators use value-based caching to skip work when inputs haven't changed:
| Scenario | Step | Time |
|---|---|---|
| First compilation | Scan syntax tree | 30ms |
| Extract topic filters | 15ms | |
| Generate registry | 5ms | |
| Total | 50ms | |
| Subsequent compilation (no changes) | Check cache | 1ms (inputs unchanged) |
| Skip generation | 0ms | |
| Total | 1ms (49ms saved!) | |
| Compilation after topic filter change | Check cache | 1ms (CreateOrder filter changed) |
| Scan syntax tree | 30ms | |
| Extract topic filters | 15ms | |
| Generate registry | 5ms | |
| Total | 51ms (only re-runs affected pipeline) |
Lookup Performance¶
| Method | Overhead | Notes |
|---|---|---|
| GetTopicFilters |
~5ns | Direct type check |
| GetAllFilters() | ~10ns | Dictionary access |
Benchmark: Lookup Performance
[Benchmark]
public string[] GetTopicFilters_CreateOrder() {
return TopicFilterRegistry.GetTopicFilters<CreateOrderCommand>();
}
// Result: ~5ns per lookup (200M operations/second)
Debugging Generated Code¶
View Generated Files¶
Generated file is written to:
Or optionally configured output folder: View Generated Files
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>.whizbang/cache</CompilerGeneratedFilesOutputPath>
</PropertyGroup>
Build Diagnostics¶
Generator reports discoveries during build:
Build started...
info WHIZ022: Found topic filter 'orders.created' on command 'CreateOrderCommand'
info WHIZ022: Found topic filter 'analytics.orders' on command 'CreateOrderCommand'
info WHIZ022: Found topic filter 'payments.processed' on command 'ProcessPaymentCommand'
Build succeeded.
3 topic filters discovered across 2 commands
Diagnostics¶
WHIZ022: Topic Filter Discovered¶
Severity: Info
Message: Found topic filter '{1}' on command '{0}'
Example:
When: Reported for each discovered topic filter during compilation.
WHIZ023: Enum Filter No Description¶
Severity: Info
Message: Enum value '{0}.{1}' has no [Description] attribute. Using enum symbol name '{1}' as filter.
Updated
Shipped behavior (verified at commit 1b31f58d): the WHIZ023 descriptor is defined in DiagnosticDescriptors.cs, but TopicFilterGenerator does not currently emit it — the fallback to the enum symbol name happens silently. The fallback behavior itself is implemented and locked by Generator_WithEnumFilterNoDescription_UsesSymbolNameAsync.
When: An enum-based topic filter lacks a [Description] attribute. The enum symbol name is used as fallback.
Fix (if Description is desired): WHIZ023: Enum Filter No Description
WHIZ025: TopicFilter On Non-Command¶
Severity: Warning
Message: [TopicFilter] on type '{0}' which does not implement ICommand. Filter will be ignored.
Example:
warning WHIZ025: [TopicFilter] on type 'MyClass' which does not implement ICommand. Filter will be ignored.
Updated
Shipped behavior (verified at commit 1b31f58d): the WHIZ025 descriptor is defined in DiagnosticDescriptors.cs, but TopicFilterGenerator does not currently emit it — types with [TopicFilter] that do not implement ICommand are silently skipped (the extraction returns early before attribute processing). The filter is still ignored, exactly as the message describes; you just won't see a build warning.
When: [TopicFilter] is placed on a type that doesn't implement ICommand.
Fix: WHIZ025: TopicFilter On Non-Command
// ✅ CORRECT: Implement ICommand
[TopicFilter("orders.created")]
public record CreateOrderCommand : ICommand { }
// ❌ WRONG: Missing ICommand
[TopicFilter("orders.created")]
public record MyClass { } // No ICommand!
WHIZ026: No Topic Filters Found¶
Severity: Info
Message: No [TopicFilter] attributes were found in the compilation. TopicFilterRegistry will not be generated.
Example:
info WHIZ026: No [TopicFilter] attributes were found in the compilation. TopicFilterRegistry will not be generated.
When: No commands with [TopicFilter] attributes are found in the assembly. This is informational only.
Best Practices¶
DO ✅¶
- ✅ Use enums for centralized topic definitions
- ✅ Add [Description] attributes to enum values for production topic names
- ✅ Group topics by domain or transport (OrderTopics, PaymentTopics, etc.)
- ✅ Validate topics at startup against message broker
- ✅ Use multiple filters for legitimate fan-out scenarios
- ✅ Create custom derived attributes for transport-specific configuration
DON'T ❌¶
- ❌ Use string literals for repeated topics (centralize via enums)
- ❌ Place
[TopicFilter]on non-ICommandtypes - ❌ Create flat enums with 100+ topics (group by domain)
- ❌ Skip Description attributes on production enums
- ❌ Modify generated TopicFilterRegistry.g.cs (will be overwritten)
- ❌ Forget to validate topics exist in message broker at startup
Troubleshooting¶
Problem: Generator Doesn't Run¶
Symptoms: No TopicFilterRegistry.g.cs in obj/ directory.
Causes: 1. Whizbang.Generators not referenced 2. Generator disabled in project file
Solution: Problem: Generator Doesn't Run
<ItemGroup>
<PackageReference Include="Whizbang.Generators" OutputItemType="Analyzer" />
</ItemGroup>
Problem: No Topic Filters Found (WHIZ026)¶
Symptoms: info WHIZ026: No [TopicFilter] attributes were found
Causes:
1. Forgot to add [TopicFilter] attributes to commands
2. Namespace import missing
Solution: Problem: No Topic Filters Found (WHIZ026)
using Whizbang.Core; // Required!
[TopicFilter("orders.created")]
public record CreateOrderCommand : ICommand {
// Implementation...
}
Problem: Enum Symbol Name Instead of Description¶
Symptoms: GetTopicFilters<T>() returns "OrdersCreated" instead of "orders.created"
Causes: Forgot to add [Description] attribute to enum value.
Solution: Problem: Enum Symbol Name Instead of Description
using System.ComponentModel; // Required for [Description]
public enum Topics {
[Description("orders.created")] // Add this!
OrdersCreated
}
Problem: TopicFilter Attribute Not Found¶
Symptoms: Compilation error: 'TopicFilterAttribute' could not be found
Causes: Missing using Whizbang.Core; directive.
Solution: Problem: TopicFilter Attribute Not Found
using Whizbang.Core; // Required!
[TopicFilter("orders.created")]
public record CreateOrderCommand : ICommand { }
Further Reading¶
Source Generators: - Receptor Discovery - Discovering IReceptor implementations - Perspective Discovery - Discovering IPerspectiveFor implementations - Message Registry - VSCode extension integration - JSON Contexts - AOT-compatible JSON serialization
Messaging: - Topic Filters - User guide for topic filters - Commands and Events - Core message types - Transports - Message transport abstraction
Version 1.0.0 - Foundation Release | Last Updated: 2024-12-14