- Notifications
You must be signed in to change notification settings - Fork790
A simple library for creating state machines in C# code
License
dotnet-state-machine/stateless
Folders and files
Name | Name | Last commit message | Last commit date | |
---|---|---|---|---|
Repository files navigation
Createstate machines and lightweightstate machine-based workflows directly in .NET code:
varphoneCall=newStateMachine<State,Trigger>(State.OffHook);phoneCall.Configure(State.OffHook).Permit(Trigger.CallDialled,State.Ringing);phoneCall.Configure(State.Connected).OnEntry(t=>StartCallTimer()).OnExit(t=>StopCallTimer()).InternalTransition(Trigger.MuteMicrophone, t=>OnMute()).InternalTransition(Trigger.UnmuteMicrophone, t=>OnUnmute()).InternalTransition<int>(_setVolumeTrigger,(volume,t)=>OnSetVolume(volume)).Permit(Trigger.LeftMessage,State.OffHook).Permit(Trigger.PlacedOnHold,State.OnHold);// ...phoneCall.Fire(Trigger.CallDialled);Assert.AreEqual(State.Ringing,phoneCall.State);
This project, as well as the example above, was inspired bySimple State Machine (Archived).
Most standard state machine constructs are supported:
- Generic support for states and triggers of any .NET type (numbers, strings, enums, etc.)
- Hierarchical states
- Entry/exit actions for states
- Guard clauses to support conditional transitions
- Introspection
Some useful extensions are also provided:
- Ability to store state externally (for example, in a property tracked by an ORM)
- Parameterised triggers
- Reentrant states
- Export to DOT graph
- Export to mermaid graph
In the example below, theOnHold
state is a substate of theConnected
state. This means that anOnHold
call is still connected.
phoneCall.Configure(State.OnHold).SubstateOf(State.Connected).Permit(Trigger.TakenOffHold,State.Connected).Permit(Trigger.PhoneHurledAgainstWall,State.PhoneDestroyed);
In addition to theStateMachine.State
property, which will report the precise current state, anIsInState(State)
method is provided.IsInState(State)
will take substates into account, so that if the example above was in theOnHold
state,IsInState(State.Connected)
would also evaluate totrue
.
In the example, theStartCallTimer()
method will be executed when a call is connected. TheStopCallTimer()
will be executed when call completes (by either hanging up or hurling the phone against the wall.)
The call can move between theConnected
andOnHold
states without theStartCallTimer()
andStopCallTimer()
methods being called repeatedly because theOnHold
state is a substate of theConnected
state.
Entry/Exit action handlers can be supplied with a parameter of typeTransition
that describes the trigger, source and destination states.
Sometimes a trigger needs to be handled, but the state shouldn't change. This is an internal transition. UseInternalTransition
for this.
A substate can be marked as initial state. When the state machine enters the super state it will also automatically enter the substate. This can be configured like this:
sm.Configure(State.B).InitialTransition(State.C);sm.Configure(State.C).SubstateOf(State.B);
Due to Stateless' internal structure, it does not know when it is "started". This makes it impossible to handle an initial transition in the traditional way. It is possible to work around this limitation by adding a dummy initial state, and then use Activate() to "start" the state machine.
sm.Configure(InitialState).OnActivate(()=>sm.Fire(LetsGo)).Permit(LetsGo,StateA)
Stateless is designed to be embedded in various application models. For example, some ORMs place requirements upon where mapped data may be stored, and UI frameworks often require state to be stored in special "bindable" properties. To this end, theStateMachine
constructor can accept function arguments that will be used to read and write the state values:
varstateMachine=newStateMachine<State,Trigger>(()=>myState.Value, s=>myState.Value=s);
In this example the state machine will use themyState
object for state storage.
Another example can be found in the JsonExample solution, located in the example folder.
It might be necessary to perform some code before storing the object state, and likewise when restoring the object state. UseDeactivate
andActivate
for this. Activation should only be called once before normal operation starts, and once before state storage.
The state machine can provide a list of the triggers that can be successfully fired within the current state via theStateMachine.PermittedTriggers
property. UseStateMachine.GetInfo()
to retrieve information about the state configuration.
The state machine will choose between multiple transitions based on guard clauses, e.g.:
phoneCall.Configure(State.OffHook).PermitIf(Trigger.CallDialled,State.Ringing,()=>IsValidNumber).PermitIf(Trigger.CallDialled,State.Beeping,()=>!IsValidNumber);
phoneCall.Configure(State.OffHook).PermitIfAsync(Trigger.CallDialled,State.Ringing,async()=>awaitIsValidNumber()).PermitIfAsync(Trigger.CallDialled,State.Beeping,async()=>!awaitIsValidNumber());
Guard clauses within a state must be mutually exclusive (multiple guard clauses cannot be valid at the same time.) Substates can override transitions by respecifying them, however substates cannot disallow transitions that are allowed by the superstate.
The guard clauses will be evaluated whenever a trigger is fired. Guards should therefore be made side effect free.
Strongly-typed parameters can be assigned to triggers:
varassignTrigger=stateMachine.SetTriggerParameters<string>(Trigger.Assign);stateMachine.Configure(State.Assigned).OnEntryFrom(assignTrigger, email=>OnAssigned(email));stateMachine.Fire(assignTrigger,"joe@example.com");
Trigger parameters can be used to dynamically select the destination state using thePermitDynamic()
configuration method.
In Stateless, firing a trigger that does not have an allowed transition associated with it will cause an exception to be thrown. This ensures that all transitions are explicitly defined, preventing unintended state changes.
To ignore triggers within certain states, use theIgnore(TTrigger)
directive:
phoneCall.Configure(State.Connected).Ignore(Trigger.CallDialled);
Alternatively, a state can be marked reentrant. A reentrant state is one that can transition back into itself. In such cases, the state's exit and entry actions will be executed, providing a way to handle events that require the state to reset or reinitialize.
stateMachine.Configure(State.Assigned).PermitReentry(Trigger.Assigned).OnEntry(()=>SendEmailToAssignee());
A state can also have a conditional reentrant, both synchronous and asynchronous.
stateMachine.Configure(State.Assigned).PermitReentryIf(Trigger.Assigned,()=>ShouldSendEmailAgain()).OnEntry(()=>SendEmailToAssignee());
stateMachine.Configure(State.Assigned).PermitReentryIfAsync(Trigger.Assigned,async()=>awaitShouldSendEmailAgain()).OnEntry(()=>SendEmailToAssignee());
By default, triggers must be ignored explicitly. To override Stateless's default behaviour of throwing an exception when an unhandled trigger is fired, configure the state machine using theOnUnhandledTrigger
method:
stateMachine.OnUnhandledTrigger((state,trigger)=>{});
Dynamic state transitions allow the destination state to be determined at runtime based on trigger parameters or other logic.
stateMachine.Configure(State.Start).PermitDynamic(Trigger.CheckScore,()=>score<10?State.LowScore:State.HighScore);
When a dynamic transition results in the same state as the current state, it effectively becomes a reentrant transition, causing the state's exit and entry actions to execute. This can be useful for scenarios where the state needs to refresh or reset based on certain triggers.
stateMachine.Configure(State.Waiting).OnEntry(()=>Console.WriteLine($"Elapsed time:{elapsed} seconds...")).PermitDynamic(Trigger.CheckStatus,()=>ready?State.Done:State.Waiting);
Stateless supports 2 types of state machine events:
- State transition
- State machine transition completed
// SynchronouslystateMachine.OnTransitioned((transition)=>{});// AsynchronouslystateMachine.OnTransitionedAsync((transition)=>{returnTask.FromResult(0);});
This event will be invoked every time the state machine changes state.
// SynchronouslystateMachine.OnTransitionCompleted((transition)=>{});// AsynchronouslystateMachine.OnTransitionCompletedAsync((transition)=>{returnTask.FromResult(0);});
This event will be invoked at the very end of the trigger handling, after the last entry action has been executed.
In addition to this, Stateless also provides you with the ability to unregister from state machine events in 3 ways.
- State transition unregister (sync/async)
- State machine transition completed (sync/async)
- State machine unregister from all (sync and async)
// Keep a reference to the synchronous callback action we want to unregister later.ActiontransitionCallbackAction=(transition)=>{};stateMachine.OnTransitionedUnregister(transitionCallbackAction);
This method will unregister the specified action callback from the transition event.
// Keep a reference to the asynchronous callback function we want to unregister later.Func<Transition,Task> transitionAsyncCallback=>(transition)=>{returnTask.FromResult(0);};stateMachine.OnTransitionedAsyncUnregister(transitionAsyncCallback);
This method will unregister the specified async function callback from the transition event.
// Keep a reference to the synchronous callback action we want to unregister later.ActiontransitionCompletedCallbackAction=(transition)=>{});stateMachine.OnTransitionCompletedUnregister(transitionCompletedCallbackAction);
This method will unregister the specified action callback from the transition completed event.
// Keep a reference to to the asynchronous callback function we want to unregister later.Func<Transition,Task> transitionCompletedAsyncCallback=>(transition)=>{returnTask.FromResult(0);});stateMachine.OnTransitionCompletedAsyncUnregister(transitionCompletedAsyncCallback);
This method will unregister the specified async function callback from the transition completed event.
stateMachine.UnregisterAllCallbacks();
This method will unregister all synchronous and asynchronously registered callbacks from the state machine.
It can be useful to visualize state machines on runtime. With this approach the code is the authoritative source and state diagrams are by-products which are always up to date.
phoneCall.Configure(State.OffHook).PermitIf(Trigger.CallDialled,State.Ringing,IsValidNumber);stringgraph=UmlDotGraph.Format(phoneCall.GetInfo());
TheUmlDotGraph.Format()
method returns a string representation of the state machine in theDOT graph language, e.g.:
digraph { OffHook-> Ringing [label="CallDialled [IsValidNumber]"];}
This can then be rendered by tools that support the DOT graph language, such as thedot command line tool fromgraphviz.org orviz.js. Seehttp://www.webgraphviz.com for instant gratification.Command line example:dot -T pdf -o phoneCall.pdf phoneCall.dot
to generate a PDF file.
Mermaid graphs can also be generated from state machines.
phoneCall.Configure(State.OffHook).PermitIf(Trigger.CallDialled,State.Ringing);stringgraph=MermaidGraph.Format(phoneCall.GetInfo());
TheMermaidGraph.Format()
method returns a string representation of the state machine in theMermaid, e.g.:
stateDiagram-v2 [*] --> OffHook OffHook --> Ringing : CallDialled
This can be rendered by GitHub markdown or an engine such asObsidian.
stateDiagram-v2 [*] --> OffHook OffHook --> Ringing : CallDialled
On platforms that provideTask<T>
, theStateMachine
supportsasync
entry/exit actions and so on:
stateMachine.Configure(State.Assigned).OnEntryAsync(async()=>awaitSendEmailToAssignee());
Asynchronous handlers must be registered using the*Async()
methods in these cases.
To fire a trigger that invokes asynchronous actions, theFireAsync()
method must be used:
awaitstateMachine.FireAsync(Trigger.Assigned);
Note: whileStateMachine
may be usedasynchronously, it remains single-threaded and may not be usedconcurrently by multiple threads.
In specific situations where all handler methods must be invoked with the consumer'sSynchronizationContext
, set theRetainSynchronizationContext
property on creation:
varstateMachine=newStateMachine<State,Trigger>(initialState){RetainSynchronizationContext=true};
Setting this is vital within a Microsoft Orleans Grain for example, which requires theSynchronizationContext
in order to make calls to other Grains.
Stateless runs on .NET runtime version 4+ and practically all modern .NET platforms by targeting .NET Framework 4.6.2, .NET Standard 2.0 and .NET 8.0. Visual Studio 2017 or later is required to build the solution.
We welcome contributions to this project. CheckCONTRIBUTING.md for more info.
This page is an almost-complete description of Stateless, and its explicit aim is to remain minimal.
Please use the issue tracker or the Discussions page if you'd like to report problems or discuss features.
(Why the name? Stateless implements the set of rules regarding state transitions, but, at least when the delegate version of the constructor is used, doesn't maintain any internal state itself.)
About
A simple library for creating state machines in C# code
Resources
License
Contributing
Security policy
Uh oh!
There was an error while loading.Please reload this page.