Jhanmn.Statement
0.1.2
dotnet add package Jhanmn.Statement --version 0.1.2
NuGet\Install-Package Jhanmn.Statement -Version 0.1.2
<PackageReference Include="Jhanmn.Statement" Version="0.1.2" />
<PackageVersion Include="Jhanmn.Statement" Version="0.1.2" />
<PackageReference Include="Jhanmn.Statement" />
paket add Jhanmn.Statement --version 0.1.2
#r "nuget: Jhanmn.Statement, 0.1.2"
#:package Jhanmn.Statement@0.1.2
#addin nuget:?package=Jhanmn.Statement&version=0.1.2
#tool nuget:?package=Jhanmn.Statement&version=0.1.2
Statement
A lightweight, type-driven state machine library for .NET.
In Statement, each state is its own class. Transitions are expressed by switching the machine's current state type, and entry/exit behavior lives either on the state itself (via IStatement) or on the machine through a fluent builder API. Optional base-type constraints let you guarantee at compile time that every state in a machine implements a common interface or inherits from a common base class.
Features
- States as first-class types — no string identifiers, no enums.
- Fluent
StateMachineBuilderAPI withOnEntry/OnExit/CanTransitionTo/CannotTransitionTorules. - Optional typed machines (
StateMachineBuilder.For<TBase>()) for compile-time safety. - Register states by type (auto-instantiated) or by pre-built instance (for states with constructor arguments).
- Built-in
IStatementinterface for states that prefer to own their own entry/exit logic. - Global transition callbacks via
AddOnStateChangedCallbackfor cross-cutting concerns like logging. - Mandatory initial state —
StartIn<TState>()must be configured beforeBuild(), otherwise aMachineSetupExceptionis thrown. This guarantees the machine is never observed in a null state. - Trigger-based transitions — declare
On<TTrigger>().GoTo<TTarget>()per state and drive the machine viamachine.Fire(trigger). Supports marker types, enums, strings, or any value. Guards (If), payload-receiving side-effects (Do), and internal transitions (Ignore) are first-class. - Typed transition payloads —
Fire(trigger, payload)/SetCurrentState<T>(payload)deliver data to entry and exit callbacks viaOnEntryWith<TPayload>/OnExitWith<TPayload>, and to global callbacks viaTransitionInformation.Payload. Payload-aware guards (If<TPayload>(p => …)) decide per-edge whether a payload is acceptable.
Quick start
Install the project as a reference (NuGet package coming later) and define your states:
// States are just plain classes — implementing IStatement is optional.
public class Idle { }
// Implement IStatement only if the state wants to own its entry/exit logic.
public class Running : IStatement
{
public void OnEntry() => Console.WriteLine("started");
public void OnExit() => Console.WriteLine("stopped");
}
Build a machine and drive it by type:
using Statement.Fluent.Api;
var machine = StateMachineBuilder.New()
.AddState<Idle>()
.AddState<Running>(s => s
.CanTransitionTo<Idle>() // allow-list: only these targets
.CanTransitionTo<Faulted>())
.AddState<Faulted>()
.StartIn<Idle>()
.Build();
machine.SetCurrentState<Running>(); // fires Running.OnEntry
var current = machine.GetCurrentState(); // returns the Running instance
Transition rules: allow-list and forbidden
By default, all transitions are allowed. Use CanTransitionTo<T>() to define an explicit allow-list — when called, only the specified targets are legal from that state:
var machine = StateMachineBuilder.New()
.AddState<Idle>(s => s
.CanTransitionTo<Running>()
.CanTransitionTo<Shutdown>())
.AddState<Running>()
.AddState<Shutdown>()
.StartIn<Idle>()
.Build();
machine.SetCurrentState<Running>(); // OK
machine.SetCurrentState<Shutdown>(); // OK (after being in Idle)
Alternatively, forbid specific targets using CannotTransitionTo<T>():
var machine = StateMachineBuilder.New()
.AddState<Running>(s => s
.CannotTransitionTo<Idle>()
.CannotTransitionTo<Faulted>())
.AddState<Idle>()
.AddState<Faulted>()
.StartIn<Running>()
.Build();
When both allow-list and forbidden rules exist on the same state, forbidden takes precedence. Attempts to switch into any blocked target are silently ignored by default. See the next section to change that.
Handling failed transitions
By default, transitions blocked by a CannotTransitionTo rule are silently ignored. Configure a different policy via OnTransitionFailure:
using Statement.Failures;
// Throw on blocked transitions
var machine = StateMachineBuilder.New()
.OnTransitionFailure(TransitionFailurePolicy.Throw)
.AddState<Running>(s => s.CannotTransitionTo<Idle>())
.AddState<Idle>()
.StartIn<Running>()
.Build();
// Or run a custom callback
var machine2 = StateMachineBuilder.New()
.OnTransitionFailure(TransitionFailurePolicy.Invoke(info =>
Console.WriteLine($"blocked: {info.From?.Name} -> {info.To.Name}")))
.AddState<Running>(s => s.CannotTransitionTo<Idle>())
.AddState<Idle>()
.StartIn<Running>()
.Build();
Attempting to switch to a state that was never registered always throws InvalidOperationException, regardless of the configured policy.
Observing every transition
Register a global callback to be notified whenever the machine moves to a new state. The callback runs after the previous state's OnExit and the current-state commit, but before the new state's OnEntry. Exceptions thrown from callbacks are swallowed so they cannot crash the machine.
var machine = StateMachineBuilder.New()
.AddOnStateChangedCallback(info =>
Console.WriteLine($"{info.FromType?.Name} -> {info.ToType?.Name}"))
.AddState<Idle>()
.AddState<Running>()
.StartIn<Idle>()
.Build();
AddOnStateChangedCallback can be called multiple times to register more than one observer.
Typed machine with a shared base type
var machine = StateMachineBuilder.For<IMyState>()
.AddState<Connecting>()
.AddState<Connected>()
.StartIn<Connecting>()
.BuildTyped(); // StateMachine<IMyState>
IMyState state = machine.GetCurrentState<IMyState>();
Pre-built state instances
For states that need constructor arguments or dependencies:
var configured = new WithConfig("hello");
var machine = StateMachineBuilder.New()
.AddState<WithConfig>(configured)
.StartIn<WithConfig>()
.Build();
Trigger-driven transitions
Instead of (or alongside) calling SetCurrentState<T>(), you can declare named triggers per state and Fire() them. The current state owns the routing, so the same trigger can mean different things in different states.
public sealed record Open;
public sealed record Close;
public sealed record Lock(string KeyId);
public class Closed { }
public class Opened { }
public class Locked { }
var machine = StateMachineBuilder.New()
.AddState<Closed>(s => s
.On<Open>().GoTo<Opened>()
.On<Lock>().If(() => hasKey).Do(t => Audit(t.KeyId)).GoTo<Locked>())
.AddState<Opened>(s => s
.On<Close>().GoTo<Closed>())
.AddState<Locked>(s => s
.On<Open>().Ignore()) // valid here, but does nothing
.StartIn<Closed>()
.Build();
machine.Fire(new Open()); // Closed -> Opened
machine.Fire(new Close()); // Opened -> Closed
machine.Fire(new Lock("k1")); // Closed -> Locked (guard checks `hasKey`)
machine.Fire(new Open()); // ignored — no callbacks fire
The TriggerBuilder fragment supports:
| Call | Effect |
|---|---|
.GoTo<TTarget>() |
Transition to the target state. |
.Ignore() |
Internal transition: consume the trigger without firing OnExit / OnEntry. |
.If(Func<bool>) |
Guard the transition. Failed guards route through TriggerFailurePolicy. |
.If<TPayload>(Func<TPayload, bool>) |
Payload-aware guard. Fails (as GuardFailed) if the payload from Fire(trigger, payload) isn't a TPayload or the predicate returns false. |
.Do(Action<TTrigger>) |
Side-effect that runs after the guard passes, before OnExit. Receives the trigger value (payload). |
Triggers can be any non-null object: marker-type instances (new Open()), enum values (On(DoorTrigger.Open)), strings (On("open")), or even a Type. Use whatever fits your domain.
When Fire(...) finds no handler on the current state or a guard returns false, the configured TriggerFailurePolicy decides what happens — Silent (default), Throw (raises TriggerFailedException), or Invoke(callback) to receive a TriggerFailureInfo:
var machine = StateMachineBuilder.New()
.OnTriggerFailure(TriggerFailurePolicy.Throw)
.AddState<Closed>(s => s.On<Open>().GoTo<Opened>())
.AddState<Opened>()
.StartIn<Closed>()
.Build();
machine.Fire(new Close()); // throws TriggerFailedException (NoHandler)
Transition payload
Both Fire(...) and SetCurrentState<T>() accept an optional object? payload. The target state can read it through a typed OnEntryWith<TPayload> callback. The payload also lands on TransitionInformation.Payload for global observers.
public sealed record FileData(string Path);
public class Idle { }
public class Loaded
{
public string? Path { get; private set; }
public void Load(FileData data) => Path = data.Path;
}
public sealed record Open;
var machine = StateMachineBuilder.New()
.AddState<Idle>(s => s.On<Open>().GoTo<Loaded>())
.AddState<Loaded>(s => s.OnEntryWith<FileData>((state, data) => state.Load(data)))
.StartIn<Idle>()
.Build();
machine.Fire(new Open(), new FileData("readme.md")); // Loaded.Path == "readme.md"
machine.SetCurrentState<Loaded>(new FileData("a.txt")); // works the same way
OnEntryWith<TPayload> and OnExitWith<TPayload> only fire when the supplied payload is assignable to TPayload. If the payload is missing or the wrong type, the callback is silently skipped — the transition itself still proceeds. Use the parameterless OnEntry(...) / OnExit(...) for behavior that must always run regardless of payload.
Both callbacks come in two flavors:
// OnEntryWith
.OnEntryWith<FileData>((state, data) => state.Load(data)) // state instance + payload
.OnEntryWith<FileData>(data => Console.WriteLine(data.Path)) // payload only
// OnExitWith
.OnExitWith<FileData>((state, data) => state.Cleanup(data)) // state instance + payload
.OnExitWith<FileData>(data => Console.WriteLine(data.Path)) // payload only
The full execution order for a transition is:
OnExitWith<TPayload>/OnExiton the leaving state- State commit (current state pointer is updated)
- Global
AddOnStateChangedCallbackobservers OnEntryWith<TPayload>/OnEntryon the entering state
To reject a transition when the payload doesn't fit, use If<TPayload> on the trigger — it routes wrong-type or failing-predicate payloads through TriggerFailurePolicy as GuardFailed. When the guard fails, neither OnExitWith nor OnEntryWith fires:
.AddState<Idle>(s => s
.On<Open>()
.If<FileData>(p => p.Path.EndsWith(".txt")) // only proceed for .txt files
.GoTo<Loaded>())
Do(Action<TTrigger>) and OnEntryWith<TPayload> / OnExitWith<TPayload> are complementary, not redundant: Do reads the trigger value (and runs before OnExit), while the With callbacks read the payload as part of entry/exit.
Initial state is required
Every machine must declare its initial state at build time via StartIn<TState>(). Calling Build() or BuildTyped() without it throws MachineSetupException:
using Statement.Failures;
try
{
var machine = StateMachineBuilder.New()
.AddState<Idle>()
.Build(); // throws: missing StartIn<T>()
}
catch (MachineSetupException ex)
{
Console.WriteLine(ex.Message);
}
This makes the "no current state" case impossible at runtime — GetCurrentState() will never return null on a freshly built machine, and the configured OnEntry of the initial state runs as part of Build().
Examples
For more usage patterns — entry/exit callbacks, transition rules, typed machines, pre-built instances, and IStatement states — see the unit tests under tests/Statement.Tests.
License
See License.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 was computed. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 was computed. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.1.2 | 94 | 5/25/2026 |
| 0.1.1 | 93 | 5/18/2026 |
| 0.1.0 | 94 | 5/17/2026 |
| 0.0.0-alpha.0.33 | 53 | 5/18/2026 |
| 0.0.0-alpha.0.28 | 47 | 5/17/2026 |