Skip to main content

Source Generators

Dispatch includes Roslyn source generators that enable ahead-of-time (AOT) compilation and Native AOT support by generating explicit code at compile time instead of using runtime reflection.

Before You Start​

Overview​

What Are Source Generators?​

Source generators are Roslyn compiler extensions that analyze your code during compilation and generate additional C# source files. Unlike runtime reflection, all code generation happens at compile time, making your application:

  • AOT-compatible - Works with Native AOT (PublishAot=true)
  • Trimming-safe - No types unexpectedly removed by IL Linker
  • Faster at startup - No reflection-based discovery
  • Debuggable - Generated code is visible and can be inspected

Why Dispatch Uses Source Generators​

BenefitDescription
Zero-reflection dispatchHandler resolution at compile time, not runtime
Faster handler activationPre-compiled property setters, no Expression.Compile()
Call-site forwardingSupported calls preserve the selected dispatcher overload
AOT deploymentNative executables without JIT compilation

Generator Inventory​

The principal generators bundled in the Excalibur.Dispatch package are:

GeneratorPurposeOutput File
HandlerRegistrySourceGeneratorDiscovers, registers, and resolves handlers; generates AddDiscoveredHandlers()PrecompiledHandlerRegistry.g.cs, PrecompiledHandlerMetadata.g.cs, GeneratedHandlerRegistrationExtensions.g.cs, GeneratedHandlerActivatorRegistrations.g.cs, PrecompiledDirectActionDispatch.g.cs
DispatchActionExtensionGeneratorTyped dispatch with TResponse inferenceTypedDispatchExtensions.g.cs
HandlerInvokerSourceGeneratorAOT-compatible handler/message invoker registrationsHandlerInvokerRegistry.g.cs
MessageTypeSourceGeneratorPreserves discovered message types from trimmingGeneratedMessageTypeRegistrations.g.cs
StaticPipelineGeneratorDispatcher call-site forwardingStaticPipelines.g.cs
MiddlewareDecompositionAnalyzerMiddleware analysisMiddlewareDecomposition.g.cs
CachePolicySourceGeneratorCache policy registrationCacheInfoRegistry.g.cs
JsonSerializationSourceGeneratorMessage type metadata for AOT serializationDiscoveredMessageTypeRegistry.g.cs, DiscoveredMessageTypeMetadata.g.cs
MessageResultExtractorGeneratorAOT result factory (no reflection)ResultFactoryRegistry.g.cs
ServiceRegistrationSourceGeneratorDI service registrationGeneratedServiceCollectionExtensions.g.cs

The package ships further generators that this page does not walk through individually — cache-info extraction, middleware invoker interception, projection tag resolution, event-store type mapping, fluent and ahead-of-time validation, saga registration and metadata, and pipeline determinism analysis. For the complete generator-to-output-file inventory, see Viewing Generated Code.

Installation​

dotnet add package Excalibur.Dispatch

The generators run automatically during compilation - no additional configuration required.


Handler Generators​

HandlerRegistrySourceGenerator​

Discovers all handler implementations at compile time and generates registration code.

Discovers:

  • IActionHandler<TMessage> implementations
  • IActionHandler<TMessage, TResponse> implementations
  • IEventHandler<TEvent> implementations
  • IDocumentHandler<TDocument> implementations

Generated Output:

// PrecompiledHandlerRegistry.g.cs
internal static class PrecompiledHandlerRegistry
{
public static void RegisterAll(IHandlerRegistry registry)
{
registry.Register(typeof(CreateOrderCommand), typeof(CreateOrderHandler), false);
registry.Register(typeof(GetOrderQuery), typeof(GetOrderHandler), true);
}

// AOT handler resolution — switch expression, zero reflection
public static Type? ResolveHandlerType(Type messageType)
{
return messageType switch
{
Type t when t == typeof(global::MyApp.CreateOrderCommand)
=> typeof(global::MyApp.CreateOrderHandler),
Type t when t == typeof(global::MyApp.GetOrderQuery)
=> typeof(global::MyApp.GetOrderHandler),
_ => null
};
}

// AOT handler creation via DI — no Type.GetType() or assembly scanning
public static object? CreateHandler(Type messageType, IServiceProvider provider)
{
ArgumentNullException.ThrowIfNull(provider);
var handlerType = ResolveHandlerType(messageType);
return handlerType is not null ? provider.GetRequiredService(handlerType) : null;
}

public static int HandlerCount => 2;
}

The generator also emits a GeneratedHandlerRegistrationExtensions class with an AddDiscoveredHandlers() extension method on IDispatchBuilder. This is the preferred AOT-safe registration path — zero reflection, zero assembly scanning:

services.AddDispatch(dispatch => dispatch.AddDiscoveredHandlers());

AddDiscoveredHandlers() replaces the reflection-based AddHandlersFromAssembly() for AOT scenarios. The reflection fallback remains available for JIT-compiled applications. See Native AOT for details.

Diagnostic: HND001 - Reports handler discovery count during compilation.


DispatchActionExtensionGenerator​

Generates typed extension methods for IDispatcher dispatch operations, enabling TResponse type inference without explicit type arguments.

For each concrete type implementing IDispatchAction<TResponse>, the generator emits extension methods with the concrete message type as the parameter. C# overload resolution prefers these generated overloads because the concrete parameter type is more specific than IDispatchAction<TResponse>.

Generated Output:

// TypedDispatchExtensions.g.cs
// The class name carries the assembly name, so two assemblies generating these
// extensions cannot collide. The class is internal: it is generated into your own
// assembly for your own call sites, and does not widen your public API surface.
internal static class TypedDispatchExtensions_MyAssembly
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Task<IMessageResult<OrderDto>> DispatchAsync(
this IDispatcher dispatcher,
GetOrderQuery message,
IMessageContext context,
CancellationToken cancellationToken)
{
return dispatcher.DispatchAsync<GetOrderQuery, OrderDto>(message, context, cancellationToken);
}
}

Benefits:

  • No explicit <TMessage, TResponse> type arguments needed at call sites
  • Full compile-time type safety for response types
  • Zero overhead — [AggressiveInlining] forwarders to existing dispatch methods

HandlerInvokerSourceGenerator​

Emits module-initializer registrations through HandlerInvokerRegistry.RegisterInvoker<THandler, TMessage>. Registrations are keyed by the exact handler and runtime message type, so one handler can implement multiple message contracts, including explicit interface implementations. Register all pairs before freezing the registry. HandlerInvokerAot refuses an unregistered pair; it does not fall back to reflection or a handler-only registration for another message type. Inaccessible/file-local and open generic handler shapes cannot be emitted from a separate generated file. Make handler and message types accessible or register an explicit accessible invoker; a successful build alone does not prove that every runtime pair has a registration.


MessageTypeSourceGenerator​

Discovers the message types in the compilation and emits a module initializer that keeps them, and their constructors, properties and fields, from being removed by the trimmer.

Generated Output:

// GeneratedMessageTypeRegistrations.g.cs
namespace Excalibur.Dispatch.Generated;

internal static class GeneratedMessageTypeRegistrations
{
// One entry per discovered message type. Referencing the types statically is what
// survives trimming; nothing here is called at runtime.
private static readonly Type[] PreservedMessageTypes = [
typeof(MyApp.Orders.GetOrderQuery),
typeof(MyApp.Orders.PlaceOrderCommand),
];

[ModuleInitializer]
public static void Initialize() { }
}

Use Cases:

  • Keep message types reachable in trimmed and Native AOT builds
  • Preserve the members a serializer reaches reflectively

To query handler registrations at runtime, resolve IHandlerRegistry from the service provider and use TryGetHandler(Type messageType, out IHandlerRegistryEntry entry) or GetAll(). The registry is the supported public surface for introspection; the metadata class emitted by HandlerRegistrySourceGenerator is an internal implementation detail that populates it and is not callable from consumer code.

var registry = serviceProvider.GetRequiredService<IHandlerRegistry>();

if (registry.TryGetHandler(typeof(GetOrderQuery), out var entry))
{
Console.WriteLine(entry.HandlerType);
}

foreach (var registration in registry.GetAll())
{
Console.WriteLine(registration.MessageType);
}

Pipeline Generators​

StaticPipelineGenerator​

Emits forwarding methods for supported calls on IDispatcher. The forwarder invokes the selected overload directly and returns its original task. It does not catch exceptions, wrap the task, or inline middleware. Middleware selection, ordering, scopes and execution remain the configured dispatcher's responsibility.

// Representative generated body; the call-site attribute is omitted here.
internal static Task<IMessageResult> Forward_0(
this IDispatcher dispatcher, CreateOrderCommand message,
IMessageContext context, CancellationToken cancellationToken)
=> dispatcher.DispatchAsync<CreateOrderCommand>(message, context, cancellationToken);

The generator skips call sites whose message or response types cannot be named from a separate generated file, including open generic, private nested and file-local types. Those calls retain ordinary dispatcher behavior. Internal accessible types and closed generic types can be supported. Existing eligibility filters may also skip call sites; generating a forwarder is not proof that a pipeline is deterministic or allocation-free. Publish and execute the application to validate its actual Native AOT path.


MiddlewareDecompositionAnalyzer​

Analyzes middleware implementations to determine if they can be decomposed into Before/After phases for static inlining.

Analyzes:

  • Statement position relative to next() call
  • State variables crossing the next() boundary
  • Control flow patterns (try/catch/finally, using)

Generated Output:

// MiddlewareDecomposition.g.cs
file static class MiddlewareDecompositionMetadata
{
private static readonly FrozenDictionary<Type, DecompositionInfo> _decompositions;

public static bool IsDecomposable<TMiddleware>();
public static bool IsDecomposable(Type middlewareType);
public static DecompositionInfo? GetInfo(Type middlewareType);

public static int TotalCount => 5;
public static int DecomposableCount => 3;
}

file readonly record struct DecompositionInfo(
bool IsDecomposable,
bool HasBeforePhase,
bool HasAfterPhase,
bool HasTryCatch,
bool HasFinally,
bool HasUsing,
bool CanShortCircuit,
int StateVariableCount,
string? NonDecomposableReason);

Non-Decomposable Patterns:

  • Multiple next() calls (retry patterns)
  • next() inside loops
  • next() inside runtime-dependent conditionals
  • Missing next() call

Caching Generator​

CachePolicySourceGenerator​

Generates compile-time cache policy registration for types implementing ICacheable<T> or decorated with [CacheResult].

Generated Output:

// CacheInfoRegistry.g.cs
internal static class CacheInfoRegistry
{
public static CacheableInfo? GetCacheableInfo(IDispatchMessage message);
public static CacheAttributeInfo? GetCacheAttributeInfo(IDispatchMessage message);
public static bool IsCacheable(Type messageType);
public static bool HasCacheAttribute(Type messageType);
public static bool? InvokeCachePolicy(object policy, IDispatchMessage message, object? result);
public static object? ExtractReturnValue(IMessageResult result);

public sealed class CacheableInfo { ... }
public sealed class CacheAttributeInfo { ... }
}

Input Requirements:

// Via ICacheable<T> interface
public record GetProductQuery(Guid Id) : IDispatchAction<ProductDto>, ICacheable<ProductDto>
{
public bool ShouldCache(ProductDto result) => result != null;
public int ExpirationSeconds => 300;
public string[]? GetCacheTags() => new[] { $"product:{Id}" };
}

// Via [CacheResult] attribute
[CacheResult(ExpirationSeconds = 60, Tags = new[] { "products" })]
public record GetAllProductsQuery : IDispatchAction<ProductDto[]>;

Serialization Generators​

JsonSerializationSourceGenerator​

Generates compile-time metadata for all discovered IDispatchMessage types, enabling AOT-safe serialization.

Discovers:

  • All concrete (non-abstract, non-generic, public) types implementing IDispatchMessage

Generated Output:

// DiscoveredMessageTypeMetadata.g.cs
internal static class DiscoveredMessageTypeMetadata
{
/// <summary>
/// Gets all discovered concrete message types (compile-time).
/// </summary>
public static IReadOnlyList<Type> MessageTypes { get; } = ImmutableArray.Create(new Type[]
{
typeof(CreateOrderCommand),
typeof(GetOrderQuery),
typeof(OrderCreatedEvent),
});

/// <summary>
/// Checks whether a given type is a discovered message type.
/// </summary>
public static bool IsMessageType(Type type)
{
return type switch
{
Type t when t == typeof(CreateOrderCommand) => true,
Type t when t == typeof(GetOrderQuery) => true,
Type t when t == typeof(OrderCreatedEvent) => true,
_ => false
};
}
}

This metadata is consumed by the framework's CoreMessageJsonContext and CompositeAotJsonSerializer for AOT-safe JSON serialization without runtime type discovery.

Type Filtering: Abstract types, open generic types, and non-public/nested types are automatically excluded.

Diagnostic: JSON001 - Reports discovered message type count during compilation.


MessageResultExtractorGenerator​

Generates AOT-safe result creation code that replaces the reflection-based ResultFactoryCache (which uses MakeGenericMethod() and MethodInfo.Invoke()).

Discovers:

  • Concrete result types from IDispatchAction<TResult> implementations
  • Return types from IActionHandler<TAction, TResult> implementations

Generated Output:

// ResultFactoryRegistry.g.cs
public static partial class ResultFactoryRegistry
{
// Factory dictionary keyed by result Type
internal static Func<object?, RoutingDecision?, object?, IAuthorizationResult?, bool, IMessageResult>?
GetFactory(Type resultType)
{
return _factories.TryGetValue(resultType, out var factory) ? factory : null;
}

// AOT-safe return value extraction
public static object? ExtractReturnValue(IMessageResult result)
{
return result switch
{
global::Excalibur.Dispatch.IMessageResult<Guid> r => r.ReturnValue,
global::Excalibur.Dispatch.IMessageResult<OrderDto> r => r.ReturnValue,
_ => null
};
}
}

Under #if AOT_ENABLED, FinalDispatchHandler.CreateTypedResult() calls ResultFactoryRegistry.GetFactory() instead of using reflection. The existing ResultFactoryCache remains as the JIT fallback.

Generated code uses IMessageResult<T> interface matching — return value extraction uses pattern matching on the Excalibur.Dispatch.IMessageResult<T> interface, accessing ReturnValue directly.


DI Registration Generator​

ServiceRegistrationSourceGenerator​

Generates DI registration code for types marked with [AutoRegister] attribute.

Quick Start:

using Excalibur.Dispatch;

// 1. Mark services for auto-registration
[AutoRegister]
public class OrderHandler : IActionHandler<CreateOrderCommand>
{
// IActionHandler<T> returns Task (void) - framework handles result wrapping
public Task HandleAsync(CreateOrderCommand message, CancellationToken ct)
=> Task.CompletedTask;
}

// 2. Call the generated extension
builder.Services.AddGeneratedServices();

The [AutoRegister] Attribute:

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class AutoRegisterAttribute : Attribute
{
public ServiceLifetime Lifetime { get; set; } = ServiceLifetime.Scoped;
public bool AsSelf { get; set; } = true;
public bool AsInterfaces { get; set; } = true;
}

Usage Examples:

// Default: Scoped, registered as self AND all interfaces
[AutoRegister]
public class MyService : IFirst, ISecond { }
// Generates:
// services.AddScoped<MyService>();
// services.AddScoped<IFirst, MyService>();
// services.AddScoped<ISecond, MyService>();

// Singleton lifetime
[AutoRegister(Lifetime = ServiceLifetime.Singleton)]
public class CacheService : ICacheService { }

// Interfaces only (no concrete type registration)
[AutoRegister(AsSelf = false)]
public class MultiImpl : IReader, IWriter { }

Generated Output:

// GeneratedServiceCollectionExtensions.g.cs
namespace Microsoft.Extensions.DependencyInjection;

internal static class GeneratedServiceCollectionExtensions
{
public static IServiceCollection AddGeneratedServices(this IServiceCollection services)
{
services.AddScoped<global::MyApp.MyService>();
services.AddScoped<global::MyApp.IFirst, global::MyApp.MyService>();
services.AddScoped<global::MyApp.ISecond, global::MyApp.MyService>();
return services;
}

public static int GeneratedServiceCount => 1;
}

Interface Discovery: Uses AllInterfaces (not just Interfaces) to discover handler interfaces from base types. A handler inheriting from BaseHandler<T> that implements IActionHandler<T> will have the interface registration generated automatically. System namespaces (System, Microsoft.Extensions.DependencyInjection, Microsoft.Extensions.Hosting) are excluded from interface discovery.

Diagnostics:

  • SRG001 - Reports discovered services during compilation
  • SRG002 - Warning when [AutoRegister(AsInterfaces=true)] is used but no discoverable interfaces are found

Viewing Generated Code​

See Viewing Generated Code for IDE-specific instructions.

Quick MSBuild Configuration:

<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)\GeneratedFiles</CompilerGeneratedFilesOutputPath>
</PropertyGroup>

Generated files appear in obj/GeneratedFiles/Excalibur.Dispatch.SourceGenerators/.


Integration Testing with CSharpGeneratorDriver​

All source generators are verified through CSharpGeneratorDriver integration tests that compile real C# source code through the Roslyn pipeline and assert on the generated output.

Test Pattern​

[Fact]
public void HandlerRegistry_WithActionHandler_GeneratesPrecompiledHandlerRegistry()
{
const string source = """
using System;
using System.Threading;
using System.Threading.Tasks;
using Excalibur.Dispatch;
using Excalibur.Dispatch.Delivery;

namespace TestApp
{
public class CreateOrderCommand : IDispatchAction<Guid> { }
public class CreateOrderHandler : IActionHandler<CreateOrderCommand, Guid>
{
public Task<Guid> HandleAsync(CreateOrderCommand message, CancellationToken cancellationToken)
=> Task.FromResult(Guid.Empty);
}
}
""";

var result = RunGenerator<HandlerRegistrySourceGenerator>(source);

result.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error).ShouldBeEmpty();
result.GeneratedTrees.ShouldNotBeEmpty();

var generatedFiles = result.GeneratedTrees
.Select(t => Path.GetFileName(t.FilePath)).ToList();
generatedFiles.ShouldContain("PrecompiledHandlerRegistry.g.cs");
}

Tests use semantic assertions (verify generated files exist, output contains expected signatures, code compiles without errors) rather than snapshot testing of exact text, which is brittle against whitespace and ordering changes.

Coverage​

GeneratorIntegration TestsFocus
HandlerRegistrySourceGenerator8ResolveHandlerType, CreateHandler, AddDiscoveredHandlers, multiple handlers
JsonSerializationSourceGenerator8MessageTypeMetadata, type filtering, abstract/generic exclusion
MessageResultExtractorGenerator7ResultFactoryRegistry, factory methods, ExtractReturnValue
ServiceRegistrationSourceGenerator7AutoRegister, AllInterfaces, SRG002 diagnostic

See tests/unit/Excalibur.Dispatch.SourceGenerators.Tests/Generators/Integration/CSharpGeneratorDriverIntegrationShould.cs for the full test suite.


Troubleshooting​

Generator Not Running​

Symptoms: No generated files, handlers not discovered

Solutions:

  1. Ensure the Excalibur.Dispatch package is referenced
  2. Clean and rebuild the solution
  3. Check for analyzer errors in build output
  4. Verify handler interfaces are from Excalibur.Dispatch.Delivery

Duplicate Registration Errors​

Symptoms: InvalidOperationException: Service already registered

Solutions:

  1. Check for manual registration of the same type
  2. Remove [AutoRegister] if manually registering
  3. Use services.TryAdd* for conditional registration

AOT/Trimming Warnings​

Symptoms: IL2026 or IL2104 warnings with Native AOT

Solutions:

  1. All Dispatch generators include [UnconditionalSuppressMessage] for known-safe patterns
  2. For custom code, add [DynamicallyAccessedMembers] to reflection-heavy methods
  3. Test with PublishAot=true during development

Interceptor Issues​

Symptoms: Interceptors not activating

Solutions:

  1. Verify C# 12+ language version
  2. Check call site is not within Excalibur.Dispatch.* namespaces (excluded to avoid conflicts)
  3. Ensure message type is concrete (not interface or type parameter)
  4. Check for hot reload mode (interceptors fall back to dynamic dispatch)

Best Practices​

1. Keep Messages in Separate Assemblies​

For optimal generator performance, keep message types in dedicated assemblies:

MyApp.Messages/ # Message types
MyApp.Handlers/ # Handler implementations
MyApp.Api/ # Application host

2. Measure the Configured Pipeline​

Call-site forwarding does not eliminate middleware delegate allocation. Measure the actual middleware set, handler activation, terminal delegate lifetime and message workload. Include cold construction and steady-state execution, and validate the Native AOT executable separately from managed runs.

3. Verify Generated Output​

During development, enable EmitCompilerGeneratedFiles and inspect the output to understand what's being generated.

4. Combine with Auto-Freeze​

Source generators work best with Auto-Freeze - the generated registrations are frozen into FrozenDictionary at startup for optimal runtime performance.


See Also​