Skip to main content

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.SourceGenerators package referenced in your entry-point project
dotnet add package Excalibur.Dispatch.SourceGenerators
dotnet add package Excalibur.Dispatch.SourceGenerators.Analyzers

Migration Checklist

StepWhat ChangesEffort
1. Enable AOTAdd <PublishAot>true</PublishAot> to projectTrivial
2. Mark handlersAdd [AutoRegister] to handler classesLow
3. Register generated servicesCall services.AddGeneratedServices()Trivial
4. JSON serializationCreate JsonSerializerContext for your DTOsMedium
5. FluentValidation (if used)Call services.AddGeneratedFluentValidationDispatcher()Low
6. Check package compatibilityReplace incompatible packages or accept annotationsVaries
7. Publish and verifydotnet publish with zero IL warningsLow

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-BasedAOT-Safe Alternative
JsonEventSerializer (default)AotJsonEventSerializer (auto-selected in AOT builds)
Assembly-scanned handler discovery[AutoRegister] + PrecompiledHandlerRegistry
ResultFactoryCache (reflection)ResultFactoryRegistry (source-generated)
Type.GetType() for deserializationTypeResolver + EventStoreTypeMap (source-generated)
Runtime Expression.Lambda invokersSource-generated HandlerInvoker dispatch

Incompatible Packages

These packages have third-party dependencies that use reflection and cannot be made AOT-safe:

PackageBlocking DependencyWorkaround
Excalibur.Dispatch.Serialization.MessagePackMessagePack reflection resolversUse JSON or MemoryPack serialization
Excalibur.Dispatch.Serialization.AvroApache.Avro runtime code generationUse JSON or Protobuf
Excalibur.Dispatch.Serialization.Protobufprotobuf-net Expression.Compile()Use JSON serialization
Excalibur.Dispatch.Transport.KafkaConfluent.Kafka Activator.CreateInstanceUse RabbitMQ or AWS SQS transport
Excalibur.Dispatch.Transport.AzureServiceBusAzure SDK reflectionUse RabbitMQ or AWS SQS transport
Excalibur.Dispatch.Transport.GooglePubSubGoogle Cloud SDK reflectionUse RabbitMQ or AWS SQS transport
Excalibur.Dispatch.Transport.GrpcgRPC code generationUse RabbitMQ or AWS SQS transport
Excalibur.Dispatch.Validation.FluentValidationFluentValidation 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:

  1. Excalibur.Dispatch.SourceGenerators is referenced in the entry-point project
  2. All handlers have [AutoRegister]
  3. All custom DTOs are included in your JsonSerializerContext
  4. 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