A generic, asynchronous state machine library for .NET with guard conditions, dynamic transitions, entry/exit actions, and a fluent configuration API.
$ dotnet add package DSeries.DStateMachine.CoreDStateMachine is a powerful and flexible asynchronous state machine library for .NET, designed with a clean, fluent API and production-ready architecture. It supports dynamic transitions, guard conditions, entry/exit hooks, and internal transitions, making it ideal for complex stateful workflows.
string, int, enum).var sm = new DStateMachine<string, string>("A");
sm.ForState("A")
.OnEntry(() => Console.WriteLine("Entering A"))
.OnExit(() => Console.WriteLine("Exiting A"))
.OnTrigger("toB", tb => tb.ChangeState("B"));
sm.ForState("B").OnEntry(() => Console.WriteLine("Entered B"));
await sm.TriggerAsync("toB");
Console.WriteLine(sm.CurrentState); // Output: B
// Configure a trigger for multiple states at once
sm.ForStates("A", "B").OnTrigger("reset", tb => tb.ChangeState("A"));
var sm = new DStateMachine<int, int>(0);
sm.ForState(0).OnTrigger(1, tb => tb.ChangeState(2));
sm.Trigger(1);
Console.WriteLine(sm.CurrentState); // Output: 2
var sm = new DStateMachine<string, string>("Init");
bool entered = false, exited = false;
sm.ForState("Init")
.OnEntry(() => { entered = true; return Task.CompletedTask; })
.OnExit(() => { exited = true; return Task.CompletedTask; })
.OnTrigger("go", tb => tb.ChangeState("Done"));
sm.ForState("Done").OnEntry(() => Task.CompletedTask);
sm.Trigger("go");
Console.WriteLine($"Entered: {entered}, Exited: {exited}"); // Output: Entered: False, Exited: True
var sm = new DStateMachine<string, string>("A");
sm.ForState("A")
.OnTrigger("toB", tb => tb.ChangeState("B").If(() => false));
sm.OnUnhandledTrigger((trigger, machine) => {
Console.WriteLine("Blocked by guard");
return Task.CompletedTask;
});
sm.Trigger("toB"); // Output: Blocked by guard
var sm = new DStateMachine<string, string>("Start");
sm.ForState("Start")
.OnTrigger("load", tb => tb.ChangeStateAsync(async () => {
await Task.Delay(100);
return "Loaded";
}));
sm.ForState("Loaded").OnEntry(() => Task.CompletedTask);
await sm.TriggerAsync("load");
Console.WriteLine(sm.CurrentState); // Output: Loaded
var sm = new DStateMachine<string, string>("A");
sm.ForState("A")
.OnTrigger("toNext", tb => tb.ChangeState(() => DateTime.Now.Second % 2 == 0 ? "Even" : "Odd"));
sm.ForState("Even").OnEntry(() => Task.CompletedTask);
sm.ForState("Odd").OnEntry(() => Task.CompletedTask);
sm.Trigger("toNext");
Console.WriteLine(sm.CurrentState); // Output: "Even" or "Odd"
var sm = new DStateMachine<string, string>("Idle");
bool logged = false;
sm.ForState("Idle")
.OnTrigger("ping", tb => tb.ExecuteAction(() => logged = true));
await sm.TriggerAsync("ping");
Console.WriteLine($"State: {sm.CurrentState}, Logged: {logged}");
// Output: State: Idle, Logged: True
var sm = new DStateMachine<string, string>("X");
sm.ForState("X")
.OnTrigger("a", tb => tb.ChangeState("A"))
.OnTrigger("b", tb => tb.ChangeState("B"));
Console.WriteLine(sm.ForState("X").Machine == sm); // Output: True
var sm = new DStateMachine<string, string>("Start");
sm.ForState("Start").OnTrigger("toEnd", tb => tb.ChangeState("End"));
sm.ForState("End").OnEntry(() => Task.CompletedTask);
string dot = sm.ExportToDot();
Console.WriteLine(dot);
// Output: DOT-format string of the state machine
new DStateMachine<TTrigger, TState>(initialState)..ForState(state) or .ForStates(state1, state2, ...) and chain OnEntry, OnExit, and OnTrigger.Trigger(trigger) or await TriggerAsync(trigger).OnTrigger and Transition StatesThe OnTrigger method is part of the fluent API provided by DStateMachine. It configures state transitions based on triggers. Each transition may specify synchronous or asynchronous destination states, guard conditions, or internal actions.
public StateConfiguration<TTrigger, TState> OnTrigger(TTrigger trigger, Action<TransitionBuilder<TTrigger, TState>> config)
trigger: The trigger causing the transition.config: Configuration action for defining the transitions.TransitionBuilder<TTrigger, TState>The TransitionBuilder class provides a fluent interface to define transitions for a given trigger. It supports:
ChangeStatepublic TransitionBuilder<TTrigger, TState> ChangeState(TState destination)
Transitions to a specific state.
ChangeState(Func<TState> destinationSelector)public TransitionBuilder<TTrigger, TState> ChangeState(Func<TState> destinationSelector)
Transitions dynamically based on the provided function.
ChangeStateAsync(Func<Task<TState>> destinationSelector)public TransitionBuilder<TTrigger, TState> ChangeStateAsync(Func<Task<TState>> destinationSelector)
Asynchronously determines the destination state.
If(Func<bool> guard)public TransitionBuilder<TTrigger, TState> If(Func<bool> guard)
Adds a synchronous guard to the most recent transition.
IfAsync(Func<Task<bool>> asyncGuard)public TransitionBuilder<TTrigger, TState> IfAsync(Func<Task<bool>> asyncGuard)
Adds an asynchronous guard.
ExecuteAction(Action action = null)public TransitionBuilder<TTrigger, TState> ExecuteAction(Action action = null)
Defines an internal transition with a synchronous side-effect.
ExecuteActionAsync(Func<Task> actionAsync = null)public TransitionBuilder<TTrigger, TState> ExecuteActionAsync(Func<Task> actionAsync = null)
Defines an internal transition with an asynchronous side-effect.
ChangeState.OnTrigger(Triggers.Start, t => t.ChangeState(States.Running))
ChangeState (dynamic).OnTrigger(Triggers.Restart, t => t.ChangeState(() => ComputeNextState()))
ChangeStateAsync.OnTrigger(Triggers.Refresh, t => t.ChangeStateAsync(async () => await GetNextStateAsync()))
If.OnTrigger(Triggers.Start, t => t.ChangeState(States.Running).If(() => IsReady))
IfAsync.OnTrigger(Triggers.Start, t => t.ChangeStateAsync(GetRunningState).IfAsync(IsReadyAsync))
ExecuteAction.OnTrigger(Triggers.Ping, t => t.ExecuteAction(() => Console.WriteLine("Pinged!")))
ExecuteActionAsync.OnTrigger(Triggers.Ping, t => t.ExecuteActionAsync(async () => await LogPingAsync()))
enum States { Idle, Running, Stopped }
enum Triggers { Start, Stop, Pause, Ping }
var stateMachine = new DStateMachine<Triggers, States>(States.Idle);
stateMachine.ForState(States.Idle)
.OnEntry(() => Console.WriteLine("Entering Idle"))
.OnExit(() => Console.WriteLine("Exiting Idle"))
.OnTrigger(Triggers.Start, t => t.ChangeState(States.Running).If(() => CanStart()))
.OnTrigger(Triggers.Pause, t => t.ExecuteActionAsync(async () => await LogPauseAttempt()))
.OnTrigger(Triggers.Ping, t => t.ExecuteAction(() => Console.WriteLine("Ping from Idle")));
stateMachine.ForState(States.Running)
.OnTrigger(Triggers.Stop, t => t.ChangeStateAsync(async () => await DetermineStopState()))
.OnTrigger(Triggers.Ping, t => t.ExecuteAction(() => Console.WriteLine("Ping from Running")));
await stateMachine.TriggerAsync(Triggers.Start);
You can define a handler for triggers without defined transitions:
stateMachine.OnUnhandledTrigger(async (trigger, machine) =>
{
await LogAsync($"Unhandled trigger {trigger} in state {machine.CurrentState}");
});
public StateConfiguration<TTrigger, TState> OnEntry(Action<DStateMachine<TTrigger, TState>> action)
public StateConfiguration<TTrigger, TState> OnEntry(Func<Task> asyncAction)
public StateConfiguration<TTrigger, TState> OnExit(Action<DStateMachine<TTrigger, TState>> action)
public StateConfiguration<TTrigger, TState> OnExit(Func<Task> asyncAction)
OnEntry: Defines an action that runs after the state is entered.OnExit: Defines an action that runs before the state is exited.stateMachine.ForState(States.Idle)
.OnEntry(sm => Console.WriteLine("Now in Idle"))
.OnExit(sm => Console.WriteLine("Leaving Idle"));
stateMachine.ForState(States.Running)
.OnEntry(async () => await LogAsync("Entered Running"))
.OnExit(async () => await LogAsync("Exited Running"));
You may define multiple entry or exit actions per state—they will be executed in the order they are registered.
The DStateMachine<TTrigger, TState> class supports defining global entry and exit actions that apply to all states. These are called Default Entry and Default Exit actions.
They are executed in addition to any state-specific entry/exit actions and are useful for:
Default entry actions are executed every time any state is entered.
stateMachine.DefaultOnEntry(sm =>
{
Console.WriteLine($"[ENTRY] Entering state: {sm.CurrentState}");
});
You can also use an asynchronous version:
stateMachine.DefaultOnEntry(async sm =>
{
await logService.LogAsync($"Entered state: {sm.CurrentState}");
});
Default exit actions are executed every time any state is exited.
stateMachine.DefaultOnExit(sm =>
{
Console.WriteLine($"[EXIT] Exiting state: {sm.CurrentState}");
});
Or asynchronously:
stateMachine.DefaultOnExit(async sm =>
{
await telemetry.TrackStateExitAsync(sm.CurrentState);
});
When a state transition occurs, the actions are executed in the following order:
This ensures shared logic is applied after specific logic during exit, and before specific logic during entry.
| Action Type | Scope | Supports Async | Receives State Machine |
|---|---|---|---|
| DefaultOnEntry | All States | ✅ Yes | ✅ Yes |
| DefaultOnExit | All States | ✅ Yes | ✅ Yes |
These hooks make your state machine more powerful and extensible without cluttering individual state definitions.
In DStateMachine, global default actions can be defined for every state transition:
However, there are cases where you may want to disable these default actions for specific states. This feature allows for more precise control over individual state behaviors.
When configuring a specific state, you can explicitly tell the state machine to skip the default entry and/or exit actions using the following fluent API:
stateMachine.ForState(State.SomeState)
.IgnoreDefaultEntry()
.IgnoreDefaultExit();
IgnoreDefaultEntry()Prevents the global default entry actions from running when this state is entered.
IgnoreDefaultExit()Prevents the global default exit actions from running when this state is exited.
var sm = new DStateMachine<string, MyState>(MyState.Idle);
sm.DefaultOnEntry(sm => Console.WriteLine("[Default] Entered state."));
sm.DefaultOnExit(sm => Console.WriteLine("[Default] Exited state."));
sm.ForState(MyState.Processing)
.OnEntry(sm => Console.WriteLine("[State] Entering Processing"))
.OnExit(sm => Console.WriteLine("[State] Exiting Processing"))
.IgnoreDefaultEntry() // Skips default entry action
.IgnoreDefaultExit(); // Skips default exit action
sm.ForState(MyState.Idle)
.OnTrigger("Start", t => t.ChangeState(MyState.Processing));
await sm.TriggerAsync("Start");
Output:
[State] Entering Processing
In this example, the state-specific entry action runs, but the default actions are ignored for Processing.
For more, see the DefaultOnEntry and DefaultOnExit registration methods in DStateMachine.
ExecuteAction, ExecuteActionAsync) for logging, notifications, or other side effects.A feature-by-feature
| Feature | DStateMachine |
|---|---|
| Asynchronous Support | ✅ Built-in async/await throughout |
| Fluent API | ✅ Clean, chainable DSL |
| Generic State & Trigger Types | ✅ Full support (string, enum, etc.) |
| Entry/Exit Hooks | ✅ Both sync & async, with access to state machine |
| Global Entry/Exit Actions | ✅ Via DefaultOnEntry/Exit |
| Per-State Default Ignore | ✅ Can opt out of default entry/exit per state |
| Internal Transitions | ✅ Explicit via ExecuteAction (sync/async) |
| Dynamic Transitions | ✅ Sync & async destination selectors supported |
| Guard Clauses | ✅ Sync & async guard support |
| Unhandled Trigger Handling | ✅ Configurable async handler |
| Visualization (Graph Export) | ✅ DOT export + Tree text visualization |
| Testability | ✅ Built-in xUnit tests, clean test support |
| State Hierarchies / Substates | ❌ Not yet supported |
| Licensing | MIT |
| Maturity / Community | 🆕 Emerging, modern design |
Pull requests and issues are welcome! If you'd like to contribute improvements or new features, feel free to fork and open a PR.
This project is licensed under the MIT License.