AOT Migration Guide
This guide walks through migrating an existing Excalibur.Dispatch application from reflection-based APIs to Native AOT-safe alternatives. If you are starting a new project, see the Native AOT Guide instead.
Prerequisites
- .NET 8.0+ (recommended: .NET 9 or later for best AOT support)
Excalibur.Dispatch.SourceGeneratorspackage referenced in your entry-point project
dotnet add package Excalibur.Dispatch.SourceGenerators
dotnet add package Excalibur.Dispatch.SourceGenerators.Analyzers
Migration Checklist
| Step | What Changes | Effort |
|---|---|---|
| 1. Enable AOT | Add <PublishAot>true</PublishAot> to project | Trivial |
| 2. Mark handlers | Add [AutoRegister] to handler classes | Low |
| 3. Register generated services | Call services.AddGeneratedServices() | Trivial |
| 4. JSON serialization | Create JsonSerializerContext for your DTOs | Medium |
| 5. FluentValidation (if used) | Call services.AddGeneratedFluentValidationDispatcher() | Low |
| 6. Check package compatibility | Replace incompatible packages or accept annotations | Varies |
| 7. Publish and verify | dotnet publish with zero IL warnings | Low |
Step 1: Enable AOT Publishing
Add to your .csproj:
<PropertyGroup>
<PublishAot>true</PublishAot>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
This enables AOT analysis during build. You will see IL2xxx/IL3xxx warnings for any reflection usage.
Step 2: Mark Handlers with [AutoRegister]
The source generator discovers handlers at compile time. Mark each handler:
using Excalibur.Dispatch.Abstractions;
// Before: handler discovered via assembly scanning (reflection)
public class CreateOrderHandler : IActionHandler<CreateOrderCommand>
{
public Task HandleAsync(CreateOrderCommand message, CancellationToken cancellationToken)
=> Task.CompletedTask;
}
// After: handler discovered at compile time (AOT-safe)
[AutoRegister]
public class CreateOrderHandler : IActionHandler<CreateOrderCommand>
{
public Task HandleAsync(CreateOrderCommand message, CancellationToken cancellationToken)
=> Task.CompletedTask;
}
The source generator produces PrecompiledHandlerRegistry with switch-based resolution -- no Type.GetType(), no assembly scanning.
Step 3: Register Generated Services
var services = new ServiceCollection();
services.AddDispatch(dispatch =>
dispatch.AddHandlersFromAssembly(typeof(Program).Assembly));
// Add this line -- registers source-generated DI services
services.AddGeneratedServices();
AddGeneratedServices() is generated by ServiceRegistrationSourceGenerator and contains explicit services.AddScoped<THandler>() calls for every [AutoRegister] type.
Step 4: AOT-Safe JSON Serialization
Reflection-based JsonSerializer.Serialize(obj) is not AOT-compatible. Create a JsonSerializerContext for your message types:
using System.Text.Json.Serialization;
[JsonSourceGenerationOptions(WriteIndented = false)]
[JsonSerializable(typeof(CreateOrderCommand))]
[JsonSerializable(typeof(GetOrderQuery))]
[JsonSerializable(typeof(OrderDto))]
[JsonSerializable(typeof(OrderCreatedEvent))]
public partial class AppJsonSerializerContext : JsonSerializerContext { }
Register it:
services.AddSingleton(AppJsonSerializerContext.Default.Options);
The framework's AotJsonEventSerializer automatically uses IEventTypeRegistry (populated from source-generated EventStoreTypeMap) and JsonSerializerContext for type-safe serialization.
Event Type Resolution
For event sourcing scenarios, the framework replaces Type.GetType() with AOT-safe TypeResolver:
// Reflection-based (NOT AOT-safe):
var eventType = Type.GetType(eventTypeName);
// AOT-safe (framework handles this internally):
// TypeResolver uses compile-time type registries from source generators.
// No consumer code change needed -- the framework switches automatically
// when RuntimeFeature.IsDynamicCodeSupported is false.
Step 5: FluentValidation (if used)
If you use Excalibur.Dispatch.Validation.FluentValidation, the source generator produces AOT-safe validator dispatch:
// Register the source-generated FluentValidation dispatcher
services.AddGeneratedFluentValidationDispatcher();
This generates a type-switch dispatcher that resolves validators without reflection. The FluentValidationGenerator discovers all AbstractValidator<T> implementations at compile time. Resolved validators are cached per message type using a ConcurrentDictionary, so repeated dispatches avoid redundant DI resolutions.
Step 6: Check Package Compatibility
Not all packages are AOT-compatible. Check the AOT Compatibility Matrix for the full list.
AOT-Safe Alternatives
| Reflection-Based | AOT-Safe Alternative |
|---|---|
JsonEventSerializer (default) | AotJsonEventSerializer (auto-selected in AOT builds) |
| Assembly-scanned handler discovery | [AutoRegister] + PrecompiledHandlerRegistry |
ResultFactoryCache (reflection) | ResultFactoryRegistry (source-generated) |
Type.GetType() for deserialization | TypeResolver + EventStoreTypeMap (source-generated) |
Runtime Expression.Lambda invokers | Source-generated HandlerInvoker dispatch |
Incompatible Packages
These packages have third-party dependencies that use reflection and cannot be made AOT-safe:
| Package | Blocking Dependency | Workaround |
|---|---|---|
Excalibur.Dispatch.Serialization.MessagePack | MessagePack reflection resolvers | Use JSON or MemoryPack serialization |
Excalibur.Dispatch.Serialization.Avro | Apache.Avro runtime code generation | Use JSON or Protobuf |
Excalibur.Dispatch.Serialization.Protobuf | protobuf-net Expression.Compile() | Use JSON serialization |
Excalibur.Dispatch.Transport.Kafka | Confluent.Kafka Activator.CreateInstance | Use RabbitMQ or AWS SQS transport |
Excalibur.Dispatch.Transport.AzureServiceBus | Azure SDK reflection | Use RabbitMQ or AWS SQS transport |
Excalibur.Dispatch.Transport.GooglePubSub | Google Cloud SDK reflection | Use RabbitMQ or AWS SQS transport |
Excalibur.Dispatch.Transport.Grpc | gRPC code generation | Use RabbitMQ or AWS SQS transport |
Excalibur.Dispatch.Validation.FluentValidation | FluentValidation Expression.Compile() | Use source-generated dispatcher (Step 5) |
Annotated Methods
Some methods in AOT-compatible packages carry [RequiresUnreferencedCode] or [RequiresDynamicCode] attributes. These methods are safe to call in JIT builds but will produce warnings in AOT builds:
// If you must call an annotated method in an AOT build and understand the risk:
#pragma warning disable IL2026, IL3050
services.AddKafkaTransport("kafka", builder => { ... });
#pragma warning restore IL2026, IL3050
Only suppress these warnings when you have verified the reflection path is not taken at runtime (e.g., using only built-in strategies).
Step 7: Publish and Verify
dotnet publish -c Release
Expected outcome:
- Zero IL2xxx/IL3xxx warnings
- Functional native binary
- Sub-millisecond startup time
If you see warnings, check:
Excalibur.Dispatch.SourceGeneratorsis referenced in the entry-point project- All handlers have
[AutoRegister] - All custom DTOs are included in your
JsonSerializerContext - You are not using incompatible packages (see Step 6)
Runtime Behavior
The framework uses RuntimeFeature.IsDynamicCodeSupported to branch between reflection and source-generated paths:
- JIT builds (
IsDynamicCodeSupported = true): Existing reflection-based code paths execute unchanged. Zero overhead. - AOT builds (
IsDynamicCodeSupported = false): Source-generated registries, type resolvers, and invokers are used. No reflection.
This means your application works identically under both JIT and AOT -- the same AddDispatch() entry point handles both scenarios transparently.
See Also
- Native AOT Guide -- Full AOT setup from scratch
- AOT Compatibility Matrix -- Per-package status
- Source Generators -- What gets generated and how
- AOT Sample -- Working example project