Skip to content

AOT and Trim Compatibility

github-actions[bot] edited this page Feb 26, 2026 · 2 revisions

The net10.0 build of FunctionalStateMachine.Core and FunctionalStateMachine.CommandRunner is fully compatible with:

  • NativeAOT (PublishAot=true) — compiled ahead of time to a self-contained native binary
  • Trimming (PublishTrimmed=true) — unused code removed at publish time to reduce binary size
  • Single-file publishing (PublishSingleFile=true)

Status

Package IsAotCompatible Reflection-free Trim-safe
FunctionalStateMachine.Core ✅ (net10.0)
FunctionalStateMachine.CommandRunner ✅ (net10.0)
FunctionalStateMachine.Diagrams N/A (build-time only) N/A N/A
FunctionalStateMachine.Core.Generator N/A (build-time only) N/A N/A
FunctionalStateMachine.CommandRunner.Generator N/A (build-time only) N/A N/A

The two generators (Core.Generator and CommandRunner.Generator) are Roslyn analyzers that run at compile time inside the compiler process, not in your application. They are never published as part of your app binary.

Enabling publishing with trimming

Executable projects

Add <PublishTrimmed>true</PublishTrimmed> to your .csproj:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <PublishTrimmed>true</PublishTrimmed>
  </PropertyGroup>
</Project>

The source generator is bundled inside the FunctionalStateMachine.Core NuGet package and applied automatically — no extra package reference needed.

Then publish:

dotnet publish -c Release -r linux-x64 --sc true

NativeAOT

<PropertyGroup>
  <PublishAot>true</PublishAot>
</PropertyGroup>
dotnet publish -c Release -r linux-x64

How it works: no reflection at runtime

The library was originally written to warn about unused trigger types at state machine build time. This required knowing all defined trigger subtypes — which was initially discovered via Assembly.GetTypes() and Type.GetProperty() (reflection).

Both reflection APIs are incompatible with AOT/trimming because the trimmer may remove types it doesn't see referenced, and Assembly.GetTypes() only returns what survives trimming.

The source generator approach

The FunctionalStateMachine.Core.Generator Roslyn source generator solves this at compile time:

  1. It detects all StateMachine<…, TTrigger, …>.Create() call sites in your compilation.
  2. For each unique TTrigger, it finds all non-abstract concrete derived types using the Roslyn symbol API (no runtime reflection).
  3. It generates a [ModuleInitializer] that registers those types in TriggerTypeRegistry before any app code runs.
// Example of generated output (in your bin/obj folder):
// <auto-generated />
[ModuleInitializer]
internal static void Initialize()
{
    TriggerTypeRegistry.Register<global::MyApp.OrderTrigger>(new[]
    {
        typeof(global::MyApp.OrderTrigger.Process),
        typeof(global::MyApp.OrderTrigger.Cancel),
        typeof(global::MyApp.OrderTrigger.Complete),
    });
}

At runtime, AnalyzeUnusedTriggers reads from this registry — no assembly scanning, no reflection. If the registry has not been populated (e.g. the generator wasn't active for that trigger type), the check is silently skipped rather than throwing.

CommandRunner dispatcher

FunctionalStateMachine.CommandRunner also uses a source generator (CommandRunner.Generator) to produce a type-switching dispatcher at compile time. The dispatcher uses a switch on concrete command types rather than any reflection-based dispatch, making it fully AOT- and trim-safe.

Multiple state machines sharing the same trigger type

When several state machines in the same project share the same TTrigger, the generator registers the trigger types once (deduplicated by trigger base type). Each machine's unused-trigger analysis runs independently at .Build() time:

// Both machines share OrderTrigger; generator registers its types once.
// machineA may use all triggers; machineB may only use a subset.
// Each machine independently warns about its own unused triggers.

var machineA = StateMachine<StateA, OrderTrigger, DataA, CmdA>.Create()
    .For(StateA.Idle)
        .On<OrderTrigger.Process>()   // uses Process
            .TransitionTo(StateA.Done)
    // ⚠️ warning: Cancel and Complete unused in this machine
    .Build();

var machineB = StateMachine<StateB, OrderTrigger, DataB, CmdB>.Create()
    .For(StateB.Idle)
        .On<OrderTrigger.Cancel>()    // uses Cancel
            .TransitionTo(StateB.Cancelled)
    // ⚠️ warning: Process and Complete unused in this machine
    .Build();

Both machines build successfully — unused-trigger warnings are informational and never block construction.

Related pages

Clone this wiki locally