Skip to main content

Native AOT Support

Dispatch provides Native AOT paths through generated handler registration and invocation, together with explicit serialization metadata. Compatibility depends on the handlers, middleware, serializers, and integrations your application selects. Publish and execute your application to verify those paths.

Before You Start​

Overview​

Native AOT compiles your application directly to machine code, eliminating the JIT compiler at runtime. This provides:

BenefitDescription
StartupNo JIT warm-up; measure total application startup on your deployment target
Smaller binariesIL trimming removes unused code
Lower memoryNo JIT compiler loaded in memory
Predictable perfNo JIT-related latency spikes

Dispatch's generators cover supported handler shapes and generate direct invocation paths. Inaccessible or open generic shapes require a supported registration strategy; generated coverage is not a guarantee that every application dependency is AOT compatible. See source generator coverage.

For pipeline validation, run samples/10-aot/Excalibur.Dispatch.Aot.Sample as a published native executable. It asserts middleware execution, explicit handler-interface invocation, scoped asynchronous disposal, and background execution ownership. Middleware backed by asynchronous-only disposable services should use the deferred factory registration; ordinary factory registration probes middleware synchronously during composition.

Quick Start​

1. Enable AOT in Your Project​

<PropertyGroup>
<PublishAot>true</PublishAot>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>

2. Mark Handlers with [AutoRegister]​

using Excalibur.Dispatch;

[AutoRegister]
public class CreateOrderHandler : IActionHandler<CreateOrderCommand>
{
public Task HandleAsync(CreateOrderCommand message, CancellationToken cancellationToken)
=> Task.CompletedTask;
}

3. Register Generated Services​

var services = new ServiceCollection();

// AOT-safe: source-generated handler registration (zero reflection)
services.AddDispatch(dispatch => dispatch.AddDiscoveredHandlers());

// Register source-generated DI services
services.AddGeneratedServices();

4. Register the AOT Handler Activator​

Generated direct dispatch resolves discovered handlers straight from the container, so most messages never reach a handler activator at all. Anything the generator did not cover falls back to IHandlerActivator, and the activator Dispatch registers by default compiles expressions — which Native AOT does not allow, so it throws rather than guessing. Register AotHandlerActivator to close that fallback:

using Excalibur.Dispatch.Delivery.Handlers;

// Before AddDispatch: Dispatch registers its default activator only if none is present.
services.AddSingleton<IHandlerActivator, AotHandlerActivator>();

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

AotHandlerActivator resolves the handler from the service provider, falling back to constructor injection when the handler is not registered, and applies the message context through the IMessageContextAware interface. It reflects over no handler member and compiles no expression.

Two consequences for handlers reached through this path:

  • A handler receives the message context only if it implements IMessageContextAware. Context applied by property injection is not available, because discovering the property needs the reflection Native AOT rules out.
  • A handler must be registered in the container, or expose a public constructor whose parameters the container can supply.

5. Publish​

dotnet publish -c Release

Expected: zero IL2XXX/IL3XXX trimming or AOT warnings.


What Gets Generated​

When you build with Excalibur.Dispatch referenced, its bundled generators produce the following compile-time code:

Handler Factory (PrecompiledHandlerRegistry.g.cs)​

The HandlerRegistrySourceGenerator discovers all handler implementations and generates AOT-safe resolution:

// Generated — switch expression, zero reflection
public static class PrecompiledHandlerRegistry
{
public static void RegisterAll(IHandlerRegistry registry)
{
registry.Register(typeof(CreateOrderCommand), typeof(CreateOrderHandler), false);
}

public static Type? ResolveHandlerType(Type messageType)
{
return messageType switch
{
Type t when t == typeof(global::MyApp.CreateOrderCommand)
=> typeof(global::MyApp.CreateOrderHandler),
_ => null
};
}

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 => 1;
}

ResolveHandlerType uses a compiler-generated switch expression — no Type.GetType(), no reflection, no runtime assembly scanning.

Message Type Metadata (DiscoveredMessageTypeMetadata.g.cs)​

The JsonSerializationSourceGenerator produces compile-time metadata for all discovered IDispatchMessage types:

// Generated — compile-time type registry
public static class DiscoveredMessageTypeMetadata
{
public static IReadOnlyList<Type> MessageTypes { get; } = ImmutableArray.Create(new Type[]
{
typeof(CreateOrderCommand),
typeof(GetOrderQuery),
typeof(OrderCreatedEvent),
});

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.

Type filtering: The generator automatically skips abstract types, open generic types, and non-public or nested types.

Result Factory (ResultFactoryRegistry.g.cs)​

The MessageResultExtractorGenerator generates AOT-safe result creation, replacing the reflection-based ResultFactoryCache:

// Generated — no MakeGenericMethod(), no MethodInfo.Invoke()
public static partial class ResultFactoryRegistry
{
internal static Func<object?, RoutingDecision?, object?, IAuthorizationResult?, bool, IMessageResult>?
GetFactory(Type resultType)
{
return _factories.TryGetValue(resultType, out var factory) ? factory : null;
}

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
};
}
}

When publishing with NativeAOT, FinalDispatchHandler.CreateTypedResult() automatically uses ResultFactoryRegistry.GetFactory() instead of reflection.

Service Registration (GeneratedServiceCollectionExtensions.g.cs)​

The ServiceRegistrationSourceGenerator generates DI registrations for [AutoRegister] types:

namespace Microsoft.Extensions.DependencyInjection;

public static class GeneratedServiceCollectionExtensions
{
public static IServiceCollection AddGeneratedServices(this IServiceCollection services)
{
services.AddScoped<global::MyApp.CreateOrderHandler>();
services.AddScoped<global::Excalibur.Dispatch.Delivery.IActionHandler<global::MyApp.CreateOrderCommand>,
global::MyApp.CreateOrderHandler>();
return services;
}
}

Key enhancement: Uses AllInterfaces to discover handler interfaces from base types, so a handler inheriting from BaseHandler<T> that implements IActionHandler<T> will have the interface registration generated automatically.


JSON Serialization​

For AOT, JSON serialization requires JsonSerializerContext instead of runtime reflection. Dispatch provides two framework-level contexts:

ContextCovers
CoreMessageJsonContextFramework message types (MessageResult, MessageContext, etc.)
CloudEventJsonContextCloudEvents envelope types

The DiscoveredMessageTypeMetadata generated class provides the compile-time type registry that these contexts use.

Consumer JSON Context​

For your own DTOs, create a JsonSerializerContext:

[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
UseStringEnumConverter = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(CreateOrderCommand))]
[JsonSerializable(typeof(GetOrderQuery))]
[JsonSerializable(typeof(OrderDto))]
public partial class AppJsonSerializerContext : JsonSerializerContext { }
Wire-shape parity is your responsibility

The three options above (PropertyNamingPolicy = CamelCase, UseStringEnumConverter = true, DefaultIgnoreCondition = WhenWritingNull) are required to match the reflection-based JsonEventSerializer's wire shape — camelCase property names, enums serialized as strings, and nulls omitted. The [JsonSourceGenerationOptions] attribute is plain source-generated JSON config; nothing in the framework validates or enforces your choices here. If your JsonSerializerContext omits or diverges from these options (e.g. leaves the default PascalCase naming, or serializes enums as numbers), events written under one serialization path (source-generated, AOT) and read under the other (reflection-based) will silently mis-read — property names won't match and enum values may not round-trip. Always copy the options block above verbatim unless you have deliberately standardized on a different wire shape across all readers and writers.

Register it:

services.AddSingleton(AppJsonSerializerContext.Default.Options);

Type Resolution​

Dispatch replaces all Type.GetType() calls with AOT-safe TypeResolver.ResolveType():

// AOT-safe — does not use Type.GetType()
var type = TypeResolver.ResolveType(typeName); // returns null if not found
var type = TypeResolver.ResolveTypeRequired(typeName); // throws if not found

This affects serialization (SpanEventSerializer, SerializerMigrationService), CloudEvents processing (EnvelopeCloudEventBridge), and poison message handling (PoisonMessageHandler).


How AOT Works Under the Hood​

When you publish with PublishAot=true, the framework automatically switches internal code paths from reflection to source-generated registries. No consumer action is needed beyond referencing the source generators package.

Key behaviors:

  • Enum serialization uses typed JsonStringEnumConverter<TEnum> and source-generated JSON contexts -- zero reflection.
  • Event serialization uses JsonSerializerContext.GetTypeInfo() for type-safe serialization without Type.GetType().
  • Handler resolution, saga coordination, caching, and projection invalidation all use pre-compiled registries instead of MakeGenericType().
  • Runtime branching via RuntimeFeature.IsDynamicCodeSupported ensures JIT builds keep their existing behavior with zero overhead, while AOT builds use the source-generated path.
  • Annotation propagation: the core dispatch implementations (Dispatcher, including its local fast path, and FinalDispatchHandler) carry [RequiresUnreferencedCode] and [RequiresDynamicCode] attributes so the trimmer accurately reports any reflection-dependent call sites. The IDispatcher abstraction itself carries no such attributes. Source-generated alternatives eliminate these warnings when the generators package is referenced.

All of this is transparent to consumers. The same AddDispatch(), AddSaga(), and AddProjection() DI entry points work identically under both JIT and AOT.

How this is kept true​

Every change to the framework is published ahead-of-time with PublishAot=true and rejected if it introduces a new IL2XXX or IL3XXX trim or AOT warning. The only warnings tolerated are a fixed, recorded set originating in third-party dependencies we cannot change; nothing new joins that set silently. So the AOT compatibility described on this page is enforced on every change, not asserted once.


Trimmer Configuration​

TrimmerRoots.xml preserves critical types during IL trimming:

<linker>
<assembly fullname="Excalibur.Dispatch.Abstractions">
<type fullname="Excalibur.Dispatch.IDispatchMessage" preserve="all" />
<type fullname="Excalibur.Dispatch.Delivery.IActionHandler`1" preserve="all" />
<!-- ... -->
</assembly>

<assembly fullname="Excalibur.Dispatch">
<type fullname="Excalibur.Dispatch.Delivery.Handlers.PrecompiledHandlerRegistry" preserve="all" />
<type fullname="Excalibur.Dispatch.Delivery.Handlers.ResultFactoryRegistry" preserve="all" />
<!-- ... -->
</assembly>
</linker>

You typically do not need to modify this file. If you have custom types that must survive trimming, add them to your own TrimmerRoots.xml or use [DynamicDependency] attributes.


Diagnostics​

Source generators report diagnostic messages during compilation:

IDSeverityDescription
HND001InfoHandler discovery count
SRG001InfoService registration count
SRG002Warning[AutoRegister(AsInterfaces=true)] with no discoverable interfaces
JSON001InfoJSON message type count

AOT Sample​

The samples/10-aot/Excalibur.Dispatch.Aot.Sample project demonstrates full AOT compilation:

cd samples/10-aot/Excalibur.Dispatch.Aot.Sample
dotnet publish -c Release

The sample showcases:

  • [AutoRegister] on handlers for compile-time DI registration
  • Source-generated AppJsonSerializerContext for AOT JSON serialization
  • PrecompiledHandlerRegistry with ResolveHandlerType and CreateHandler
  • ResultFactoryRegistry for AOT result creation
  • DiscoveredMessageTypeMetadata for compile-time type registry
  • Full pipeline dispatch via IDispatcher.DispatchAsync() (AOT-safe via source-generated interceptors)

Transport AOT Compatibility​

All 7 transport packages have been classified for AOT compatibility. The builder pattern (Action<IXxxTransportBuilder>) used by all transports avoids generic type parameters on public DI methods, making some transports inherently AOT-safe. Others have third-party SDK dependencies that use reflection.

Transport Status​

TransportAOT StatusNotes
RabbitMQAOT-safeBuilder pattern only, no reflection
AWS SQSAOT-safeBuilder pattern only, no reflection
Azure Service BusNot compatibleAzure SDK dependency uses reflection internally
Google Pub/SubNot compatibleGoogle Cloud SDK dependency uses reflection internally
KafkaNot compatibleConfluent.Kafka SchemaRegistry uses Activator.CreateInstance
gRPCNot compatiblegRPC code generation not AOT-safe

See the AOT Compatibility Matrix for the full per-package table.

Kafka SchemaRegistry Warning​

When using Kafka with custom subject name strategies, consumers will see AOT warnings because CreateSubjectNameStrategy() uses Activator.CreateInstance:

// Both AddKafkaTransport() overloads carry these attributes:
[RequiresUnreferencedCode("Schema Registry uses Activator.CreateInstance for custom subject name strategy types.")]
[RequiresDynamicCode("Schema Registry uses Activator.CreateInstance for custom subject name strategy types.")]
public static IServiceCollection AddKafkaTransport(
this IServiceCollection services,
string name,
Action<IKafkaTransportBuilder> configure)

If you use only the built-in TopicName or RecordName strategies (not custom types), the reflection path is not taken at runtime.

To suppress the warning when you know your strategy is safe:

#pragma warning disable IL2026, IL3050
services.AddKafkaTransport("kafka", builder => { ... });
#pragma warning restore IL2026, IL3050

Scope and Limitations​

AOT Coverage​

PackageAOT Status
Excalibur.DispatchAnnotated ([RequiresUnreferencedCode] on reflection paths, source-gen alternative)
Excalibur.Dispatch.AbstractionsFully AOT-compatible
Excalibur.Dispatch.Transport.RabbitMQAOT-safe
Excalibur.Dispatch.Transport.AwsSqsAOT-safe
Excalibur.Dispatch.Transport.KafkaNot compatible (SchemaRegistry reflection)
Excalibur.Dispatch.Transport.AzureServiceBusNot compatible (Azure SDK reflection)
Excalibur.Dispatch.Transport.GooglePubSubNot compatible (Google Cloud SDK reflection)
Excalibur.Dispatch.Transport.GrpcNot compatible (gRPC code generation)

Known Constraints​

ConstraintDetail
Source generators target netstandard2.0Limited API surface (Roslyn requirement)
IValidationResult static abstractWorked around with object? + as cast
Consumer JSON DTOsMust create own JsonSerializerContext for custom types
Generated code always emitsEven with 0 discovered types (ensures compile safety)
Kafka SchemaRegistryCustom strategies require Activator.CreateInstance — AOT warning

Troubleshooting​

IL2XXX/IL3XXX Warnings​

If you see trimming warnings:

  1. Ensure Excalibur.Dispatch is referenced
  2. Check that handlers have [AutoRegister] attribute
  3. Add [DynamicallyAccessedMembers] to custom reflection-heavy code
  4. Verify TrimmerRoots.xml is included in your project

Handlers Not Discovered​

  1. Handlers must be public, non-abstract classes
  2. Handlers must implement IActionHandler<T>, IEventHandler<T>, or IDocumentHandler<T>
  3. Handler interfaces must be from Excalibur.Dispatch.Delivery namespace
  4. Clean and rebuild after adding new handlers

"HandlerActivator cannot activate handler ... does not support runtime code generation"​

A message reached the default expression-compiling activator in a build where dynamic code is unavailable. Register AotHandlerActivator before AddDispatch, as shown in Register the AOT Handler Activator. If the handler should have been covered by generated dispatch instead, check that it is discovered — see Handlers Not Discovered.

Result Factory Not Working Under AOT​

The ResultFactoryRegistry is generated from IDispatchAction<T> and IActionHandler<TAction, TResult> discovery. If your result types aren't discovered:

  1. Ensure your action/query types implement IDispatchAction<TResult> with concrete result types
  2. Verify the generator is active: check for ResultFactoryRegistry.g.cs in generated output
  3. The RuntimeFeature.IsDynamicCodeSupported branch in FinalDispatchHandler automatically selects the AOT path when publishing with NativeAOT

See Also​