Nalu.SharpState
A compile-time, AOT-friendly state machine for .NET built on a Roslyn source generator. You declare states and triggers with attributes, describe transitions with a strongly-typed fluent API, and the generator emits a ready-to-use IActor surface with typed trigger methods.
Why SharpState?
Classic state machine libraries rely on reflection, dictionaries keyed by strings/enums at runtime, and object[] parameter bags. That costs boxing, breaks AOT, and pushes errors from compile time to the first user interaction.
Nalu.SharpState takes the opposite route:
- Declarative: states and triggers are C# constructs (a
static partialmethod is a trigger, astaticproperty is a state). - Strongly typed: trigger parameters become method parameters on the generated actor. Guards and actions see the exact types you declared.
- Compile-time validated: duplicate names, unreachable hierarchies, and misconfigured sub-machines become build errors via dedicated
NSS001–NSS011diagnostics. - AOT / trim friendly: zero reflection at runtime. The generator emits the registration tables at compile time.
- Hierarchical: composite states are modeled as nested
[SubStateMachine]partial classes with strict scoping rules. - Sync-first: generated actors stay synchronous, with optional fire-and-forget
ReactAsync(...)callbacks for post-transition work. - Lightweight on CPU and memory: tables are emitted at compile time and dispatch is direct, so transitions spend less time on the hot path and allocate far less than typical reflection- or dictionary-heavy approaches. The Benchmarks section compares
Nalu.SharpStateto Stateless on the same scenarios.
Installation
dotnet add package Nalu.SharpState
The package bundles the source generator, so no additional setup or UseXxx(...) call is required.
Anatomy of a machine
A machine lives in a single static partial class (for example public static partial class MyMachine) marked with [StateMachineDefinition]. It is made of three building blocks:
| Building block | Declared as | Role |
|---|---|---|
| Context | Any class you own | Carries data into every guard and action. Passed as a type argument to [StateMachineDefinition]. |
| Triggers | [StateTriggerDefinition] static partial void methods |
Inputs to the machine. Their parameter list becomes the dispatch signature. At most three parameters; group additional values in a record struct, a named tuple, or similar and pass that as one parameter (see NSS011). |
| States | [StateDefinition] static IStateConfiguration properties |
Nodes of the machine. The property body configures outgoing transitions. |
Here is a minimal door:
using Nalu.SharpState;
public class DoorContext
{
public int OpenCount { get; set; }
public string? LastReason { get; set; }
}
[StateMachineDefinition(typeof(DoorContext))]
public static partial class DoorMachine
{
[StateTriggerDefinition] static partial void Open(string reason);
[StateTriggerDefinition] static partial void Close();
[StateDefinition(Initial = true)]
private static IStateConfiguration Closed { get; } = ConfigureState()
.OnOpen(t => t
.Target(State.Opened)
.Invoke((ctx, reason) =>
{
ctx.OpenCount++;
ctx.LastReason = reason;
}));
[StateDefinition]
private static IStateConfiguration Opened { get; } = ConfigureState()
.OnClose(t => t.Target(State.Closed));
}
The generator produces:
- A
Stateenum with the valuesClosed, Opened. - A
Triggerenum with the valuesOpen, Close. - A nested
public interface IActorexposingCanOpen(string),Open(string),CanClose(),Close(),CurrentState,Context,IsIn(...),StateChanged,ReactionFailed, andOnUnhandled. - Nested
public delegate IActor CreateActorFactory(DoorContext context)andpublic delegate IActor CreateActorWithStateFactory(DoorContext context, State state)for dependency injection and tests. - A
public static State GetInitialState()helper for the root machine. - A
public static string ToDot()helper that renders the machine as a Graphviz DOT graph. - A
public static IActor CreateActor(DoorContext context)helper that starts from the root initial state. - A
public static IActor CreateActorWithState(DoorContext context, State state)helper for a non-default starting state.
Usage:
var door = DoorMachine.CreateActor(new DoorContext());
door.Open("delivery");
Console.WriteLine(door.CurrentState); // Opened
Console.WriteLine(door.Context.OpenCount); // 1
Describing transitions
Inside each [StateDefinition] property body you call ConfigureState() and chain one On<TriggerName>(t => ...) per trigger the state reacts to. The builder is split into two phases:
| Method | Purpose |
|---|---|
Target(State s) |
Move to s when the trigger fires. If s is composite, its initial-child chain is resolved to a leaf. |
Target((ctx, args...) => State.X) |
Compute the target at fire time from the context and trigger arguments. |
Stay() |
Run the action but keep the current state (internal transition). StateChanged is not raised. |
Ignore() |
Syntax sugar for Stay() with no action, useful when a trigger should be accepted but do nothing. This ends the fluent chain. |
When(predicate) |
Available on the trigger builder before Target(...) or Stay(). Guards the transition. The predicate receives the context and trigger arguments. Repeated calls are combined with logical AND in declaration order. |
When(predicate, "label") |
Same as When(predicate), but also records the label for DOT graph rendering (only non-null labels are stored). If the transition has a guard but no stored labels, the graph uses a single Unnamed guard 1 placeholder in the trigger node; multiple stored labels are joined with &. |
Invoke(action) |
Available after Target(...) or Stay(). Runs side effects before the state commits. Repeated calls run in declaration order. |
ReactAsync(action) |
Available after Target(...) or Stay(). Schedules fire-and-forget work after the transition commits and after StateChanged fires. Repeated calls run sequentially in declaration order. |
If a dynamic Target(...) resolves to the current leaf state for a specific fire, SharpState treats that fire like an internal transition: no exit hooks, no state commit, no entry hooks, and no StateChanged.
You can register multiple transitions for the same trigger in the same state; the first one whose guard passes (or has no guard) wins:
[StateDefinition]
private static IStateConfiguration Idle { get; } = ConfigureState()
.OnStart(t => t
.When((ctx, user) => user.IsAdmin)
.Target(State.AdminDashboard))
.OnStart(t => t
.Target(State.UserDashboard));
If no transition matches, the OnUnhandled callback fires (see below).
Entry and exit hooks
States can also react to external transitions with WhenEntering(...) and WhenExiting(...):
[StateDefinition]
private static IStateConfiguration Running { get; } = ConfigureState()
.WhenEntering(ctx => ctx.Log.Add("entered running"))
.WhenExiting(ctx => ctx.Log.Add("leaving running"))
.OnStop(t => t.Target(State.Stopped));
Hooks run only for external transitions:
- Exit hooks run from the current leaf upward until the lowest common ancestor with the destination.
- Entry hooks run from that ancestor's child down to the new leaf.
- Internal transitions (
Stay()/Ignore()) do not fire entry or exit hooks.
Interacting with the actor
The generated IActor exposes everything you need at runtime:
var door = DoorMachine.CreateActor(new DoorContext());
door.StateChanged += (from, to, trigger, args) =>
Console.WriteLine($"{from} -> {to} via {trigger}");
door.OnUnhandled = (current, trigger, args) =>
logger.LogWarning("{Trigger} ignored while in {State}", trigger, current);
door.Open("delivery");
Console.WriteLine(door.CurrentState); // Opened
Console.WriteLine(door.IsIn(DoorMachine.State.Opened)); // true
| Member | Description |
|---|---|
CurrentState |
Current leaf state. When the machine is in a nested composite it always reports the leaf. |
Context |
The context instance supplied to CreateActor / CreateActorWithState. |
IsIn(State s) |
true if CurrentState equals s or is a descendant of s. Use this to query composites. |
StateChanged |
StateChangedHandler<State, Trigger> raised after a non-internal transition commits. |
ReactionFailed |
Raised when a background ReactAsync(...) callback throws after the transition already completed. |
OnUnhandled |
Invoked when a trigger has no matching transition on the leaf nor on any ancestor. |
Can<Trigger>(...) |
Returns whether the corresponding trigger currently has a matching transition for the supplied arguments. |
<Trigger>(...) |
One strongly-typed void method per trigger. |
Benchmarks
Outperform the industry standard (Stateless) with 4x to 8x faster execution and 7x to 12x lower memory overhead depending on the usage.
| Method | StateChanges | Mean | Error | StdDev | Gen0 | Gen1 | Allocated |
|---|---|---|---|---|---|---|---|
| SingletonActor | 100 | 10.32 us | 0.029 us | 0.025 us | 4.3945 | - | 35.94 KB |
| SingletonStateless | 100 | 41.63 us | 0.484 us | 0.404 us | 30.0293 | - | 245.31 KB |
| TransientActor | 100 | 11.27 us | 0.027 us | 0.023 us | 5.9204 | - | 48.44 KB |
| TransientStateless | 100 | 89.98 us | 1.224 us | 1.022 us | 75.0732 | 1.3428 | 614.08 KB |
| SingletonActor | 10000 | 1,020.74 us | 6.633 us | 5.539 us | 439.4531 | - | 3593.75 KB |
| SingletonStateless | 10000 | 3,956.54 us | 41.182 us | 38.521 us | 2953.1250 | - | 24140.63 KB |
| TransientActor | 10000 | 1,120.78 us | 6.764 us | 5.648 us | 591.7969 | - | 4843.75 KB |
| TransientStateless | 10000 | 8,699.77 us | 87.558 us | 77.618 us | 7468.7500 | 140.6250 | 61016.85 KB |
See the benchmarks for more details.
Graphviz export
Every generated machine also exposes ToDot(), which returns a Graphviz DOT string. Pass it to the dot tool (e.g. dot -Tpng -o door.png) or any compatible viewer to visualize transitions, guard labels, and hierarchy.
var dot = DoorMachine.ToDot();
Console.WriteLine(dot);
The first example on this page is the door sample (DoorMachine / DoorContext). The DOT below is what DoorMachine.ToDot() emits; the image is the same file rendered with dot -Tpng.
digraph G {
label = "DoorMachine";
labelloc = t;
compound = true;
start [shape=Mdiamond,label="Closed"];
state_1 [shape=rectangle,label="Opened"];
trigger_0 [shape=ellipse,label="Close"];
state_1 -> trigger_0;
trigger_1 [shape=ellipse,label="Open\n[Not spying]"];
start -> trigger_1;
trigger_0 -> start;
trigger_1 -> state_1;
}
|
|
More generally, the export uses rectangles for states, ellipses for triggers, and appends When(..., "label") text inside the trigger label (for example Open\n[Not spying]), as in the transition table above. Cluster subgraphs represent nested regions; start and end mark the root initial state and terminal states when your machine has them.
Testability
For application code, prefer injecting the generated factory delegates instead of calling the static CreateActor / CreateActorWithState methods from many places. Most apps register CreateActorFactory, which matches CreateActor(DoorContext) and starts the actor at the machine’s initial state. Use CreateActorWithStateFactory when callers must pass an explicit State (rehydration, tests, or non-default start):
services.AddSingleton<DoorMachine.CreateActorFactory>(DoorMachine.CreateActor);
public sealed class DoorWorkflow
{
private readonly DoorMachine.CreateActorFactory _createDoor;
public DoorWorkflow(DoorMachine.CreateActorFactory createDoor) => _createDoor = createDoor;
public DoorMachine.IActor Start(DoorContext context) => _createDoor(context);
}
If you need a specific starting State instead of the root initial state:
services.AddSingleton<DoorMachine.CreateActorWithStateFactory>(DoorMachine.CreateActorWithState);
// ... inject CreateActorWithStateFactory and call: _createDoor(context, DoorMachine.State.Closed);
This keeps actor creation DI-friendly and unit-testable:
- tests can replace
CreateActorFactory(orCreateActorWithStateFactory) with a lambda - the lambda can return a mocked or fake
DoorMachine.IActor - production code can still use
DoorMachine.CreateActororDoorMachine.CreateActorWithStateas the implementation behind the delegate
Unit testing
With NSubstitute, a unit test can stub both the factory and the generated actor:
var actor = Substitute.For<DoorMachine.IActor>();
var factory = Substitute.For<DoorMachine.CreateActorFactory>();
factory(Arg.Any<DoorContext>()).Returns(actor);
var workflow = new DoorWorkflow(factory);
var result = workflow.Start(new DoorContext());
result.Should().BeSameAs(actor);
And if your application code calls triggers on the actor, you can verify those too:
var actor = Substitute.For<DoorMachine.IActor>();
actor.Open("delivery");
actor.Received().Open("delivery");
Unhandled triggers
OnUnhandled defaults to a handler that throws InvalidOperationException with the current state and trigger in the message. This surfaces programming mistakes early (e.g. firing Close while the door is already closed and no .OnClose is configured for that state).
You have three options:
// 1) Default: throws on unhandled
door.Open("again"); // InvalidOperationException if not configured
// 2) Custom handler (logging, metrics, retries...)
door.OnUnhandled = (state, trigger, args) =>
telemetry.Track("UnhandledTrigger", state, trigger);
// 3) Opt out: silent no-op
door.OnUnhandled = null;
If a trigger should be accepted silently from a given state, prefer modeling that directly:
[StateDefinition]
private static IStateConfiguration Running { get; } = ConfigureState()
.OnHeartbeat(t => t.Ignore());
StateChanged
StateChanged fires once per committed transition, with the original leaf, the new leaf, the trigger, and a TriggerArgs value carrying the trigger payload.
- Use
args.Get<T>(index)to read the nth argument with the type you expect (the sameTthe trigger was fired with at that position). - Use
args.ToArray()when you need a conventionalobject?[](for example logging every value without knowing arity up front). Up to three arguments are stored inline; you normally do not need the array in application code.
door.StateChanged += (from, to, trigger, args) => { /* log, react, ... */ };
It is not raised when:
- The transition is internal (
.Stay()). - A dynamic
Target(...)resolves to the current leaf, so that fire collapses into internal behavior. - The trigger was unhandled (
OnUnhandledis raised instead).
Guards and actions
Guards and actions both receive the context followed by the trigger's parameters — exactly the parameters you declared on the partial void method:
[StateTriggerDefinition] static partial void Withdraw(decimal amount, string note);
[StateDefinition]
private static IStateConfiguration Open { get; } = ConfigureState()
.OnWithdraw(t => t
.When((ctx, amount, _) => amount <= ctx.Balance)
.Target(State.Open)
.Invoke((ctx, amount, note) =>
{
ctx.Balance -= amount;
ctx.Ledger.Add(($"-{amount}", note));
}))
.OnWithdraw(t => t
.Stay()
.Invoke((ctx, amount, note) =>
ctx.Ledger.Add((note, $"insufficient for {amount}"))));
Guards are pure predicates — keep them free of side effects. Invoke(...) runs inline during dispatch and any exception it throws propagates out of Fire(...) before the new state is committed.
ReactAsync
Use ReactAsync(...) when the transition should commit immediately but you still want to kick off asynchronous follow-up work:
[StateDefinition]
private static IStateConfiguration Pending { get; } = ConfigureState()
.OnRequestApproval(t => t
.Target(State.Approving)
.ReactAsync(async (actor, ctx, id) =>
{
try {
await ctx.ApproveService.ApproveAsync(id);
actor.Approve();
} catch {
actor.Reject();
}
}));
For external transitions, the order is:
WhenExiting(...)Invoke(...)- state commit
WhenEntering(...)StateChangedReactAsync(...)
ReactAsync(...) captures the current SynchronizationContext when the trigger is fired. The callback receives the generated actor instance first, so it can trigger additional transitions after the awaited work completes. If the background reaction throws, the exception is surfaced through ReactionFailed.
What next?
- Hierarchical State Machines — nested composite states via
[SubStateMachine]. - Post-Transition Reactions — background
ReactAsync(...)work andReactionFailed. - Diagnostics & Troubleshooting — generator errors (
NSS001–NSS011) and common pitfalls.