Skip to content

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&lt;TCommand&gt;()<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

internal sealed record TopicFilterInfo(
  string CommandType,
  string Filter
);

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:

obj/Debug/net10.0/generated/Whizbang.Generators/TopicFilterGenerator/
└── TopicFilterRegistry.g.cs

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:

info WHIZ022: Found topic filter 'orders.created' on command 'CreateOrderCommand'

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

public enum Topics {
  [Description("orders.created")]  // Add this!
  OrdersCreated
}

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-ICommand types
  • ❌ 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