A lightweight C# library for defining and managing state machines based on an entity class and an enum property representing its state.
- Generic: Define state machines for any entity (
TEntity) and its status enum (TEnum). - Fluent Configuration: Use a builder pattern to define the initial state and allowed transitions.
- Static Access: Interact with the state machine using static methods (
StateMachine<TEntity, TEnum>.CanTransition(...), etc.). - Cached Configuration: State machine definitions are cached for performance after the initial configuration.
- Transition Information: Query possible transitions from the current state or any given state.
- Final State Detection: Check if a state is a final state (no outgoing transitions) or if an entity is currently in a final state.
- Pre-conditions: Define conditions (
Func<TEntity, bool>) that must be met for a transition to occur. - Post-conditions (Actions): Define actions (
Action<TEntity>) to be executed after a successful transition (before the state property is updated). - OnEntry/OnExit Actions: Define actions to execute when entering or exiting specific states, regardless of which transition is taken.
- Global Transition Event: Subscribe to
OnTransitionevent for logging, auditing, or custom behavior on any state change. - Transition Context: Pass reason and metadata with transitions, available in the
OnTransitionevent. - Force Transitions: Bypass pre-conditions for administrative or recovery scenarios using
ForceTransition. - Automatic State Update: The
TryTransitionmethod automatically updates the entity's status property upon successful transition. - Mermaid Graph Generation: Generate a Mermaid.js graph definition string to visualize the state machine, including pre-condition descriptions.
- D2 Graph Generation: Generate a D2 graph definition string to visualize the state machine, including pre-condition descriptions.
- Thread-Safe: Configuration is thread-safe. Runtime access (checking/performing transitions) assumes the entity instance is handled appropriately by the calling code (e.g., not mutated concurrently during a transition check).
Install the package via NuGet Package Manager:
Install-Package SlimStateMachine
Or using .NET CLI:
dotnet add package SlimStateMachine
- .NET 9.0
- .NET 8.0
- .NET Standard 2.0
// Example: Invoice ManagementpublicenumInvoiceStatus{Draft,Sent,Paid,Cancelled}publicclassInvoice{publicintId{get;set;}publicInvoiceStatusStatus{get;set;}// The state propertypublicdecimalTotalAmount{get;set;}publicdecimalAmountPaid{get;set;}publicdecimalRemainingAmount=>TotalAmount-AmountPaid;publicstring?Notes{get;set;}// You might initialize the status in the constructor or rely on the state machine's initial statepublicInvoice(){// Status defaults to 'Draft' (enum default) which matches our example initial state}}This should typically be done once during application startup (e.g., in Program.cs or a static constructor).
usingSlimStateMachine;// --- Configuration (Do this once at startup) ---StateMachine<Invoice,InvoiceStatus>.Configure(// 1. Specify the property holding the state
invoice =>invoice.Status,// 2. Use the builder to define the state machine rules
builder =>{// 2a. Set the initial state for new entities (if not set explicitly)builder.SetInitialState(InvoiceStatus.Draft);// 2b. Define allowed transitionsbuilder.AllowTransition(InvoiceStatus.Draft,InvoiceStatus.Sent);// 2c. Transition with a Pre-conditionbuilder.AllowTransition(InvoiceStatus.Sent,InvoiceStatus.Paid,preCondition: inv =>inv.RemainingAmount<=0,// Func<Invoice, bool>preConditionExpression:"Remaining <= 0"// String for Mermaid graph);// 2d. Transition with a Post-condition (Action)builder.AllowTransition(InvoiceStatus.Draft,InvoiceStatus.Cancelled,postAction: inv =>inv.Notes="Cancelled while in Draft."// Action<Invoice>);// 2e. Transition with both Pre- and Post-conditionsbuilder.AllowTransition(InvoiceStatus.Sent,InvoiceStatus.Cancelled,preCondition: inv =>inv.RemainingAmount>0,// Can only cancel if not fully paidpreConditionExpression:"Remaining > 0",postAction: inv =>inv.Notes="Cancelled after sending (partially paid).");// 2f. OnEntry action - executed when entering a state (after state change)builder.OnEntry(InvoiceStatus.Paid, inv =>Console.WriteLine($"Invoice {inv.Id} has been paid!"));// 2g. OnExit action - executed when leaving a state (before state change)builder.OnExit(InvoiceStatus.Draft, inv =>Console.WriteLine($"Invoice {inv.Id} is no longer a draft."));});// --- End Configuration ---// Create an entity instancevarmyInvoice=newInvoice{Id=101,TotalAmount=500,AmountPaid=0};// Initial state is implicitly Draft (enum default), matching configured InitialState// Check if a transition is possibleboolcanSend=StateMachine<Invoice,InvoiceStatus>.CanTransition(myInvoice,InvoiceStatus.Sent);// trueboolcanPay=StateMachine<Invoice,InvoiceStatus>.CanTransition(myInvoice,InvoiceStatus.Paid);// false (Remaining > 0)Console.WriteLine($"Can send invoice {myInvoice.Id}? {canSend}");Console.WriteLine($"Can pay invoice {myInvoice.Id}? {canPay}");// Get possible next statesvarpossibleStates=StateMachine<Invoice,InvoiceStatus>.GetPossibleTransitions(myInvoice);// possibleStates will contain [Sent, Cancelled] for the initial Draft state in this configConsole.WriteLine($"Possible next states for invoice {myInvoice.Id}: {string.Join(", ",possibleStates)}");// Attempt a transitionbooltransitionSucceeded=StateMachine<Invoice,InvoiceStatus>.TryTransition(myInvoice,InvoiceStatus.Sent);if(transitionSucceeded){Console.WriteLine($"Invoice {myInvoice.Id} transitioned to: {myInvoice.Status}");// Status is now Sent}// Now try to pay - still fails preconditiontransitionSucceeded=StateMachine<Invoice,InvoiceStatus>.TryTransition(myInvoice,InvoiceStatus.Paid);Console.WriteLine($"Tried paying unpaid invoice. Succeeded? {transitionSucceeded}. Status: {myInvoice.Status}");// false, Status remains Sent// Simulate paymentmyInvoice.AmountPaid=500;// Try paying again - now succeedscanPay=StateMachine<Invoice,InvoiceStatus>.CanTransition(myInvoice,InvoiceStatus.Paid);// truetransitionSucceeded=StateMachine<Invoice,InvoiceStatus>.TryTransition(myInvoice,InvoiceStatus.Paid);Console.WriteLine($"Tried paying fully paid invoice. Succeeded? {transitionSucceeded}. Status: {myInvoice.Status}");// true, Status is now Paid// Try cancelling - fails precondition (Remaining <= 0)transitionSucceeded=StateMachine<Invoice,InvoiceStatus>.TryTransition(myInvoice,InvoiceStatus.Cancelled);Console.WriteLine($"Tried cancelling paid invoice. Succeeded? {transitionSucceeded}. Status: {myInvoice.Status}");// false, Status remains PaidConsole.WriteLine($"Notes: {myInvoice.Notes}");// Post-action didn't runTry multiple target states in order, transitioning to the first valid one:
varinvoice=newInvoice{Id=1,Status=InvoiceStatus.Sent,TotalAmount=100,AmountPaid=50};// Try to transition to Paid first, then Cancelled - will transition to first valid targetboolsuccess=StateMachine<Invoice,InvoiceStatus>.TryTransitionAny(invoice,[InvoiceStatus.Paid,InvoiceStatus.Cancelled],outvarresultState);if(success){Console.WriteLine($"Transitioned to: {resultState}");// Cancelled (Paid failed pre-condition)}// Or try any valid transition from current statevaranotherInvoice=newInvoice{Id=2,Status=InvoiceStatus.Draft};if(StateMachine<Invoice,InvoiceStatus>.TryTransitionAny(anotherInvoice)){Console.WriteLine($"Transitioned to: {anotherInvoice.Status}");// First valid transition}// Get all defined transitions from a state (ignoring pre-conditions)varallFromDraft=StateMachine<Invoice,InvoiceStatus>.GetDefinedTransitions(InvoiceStatus.Draft);// Returns: [Sent, Cancelled]// Check if a specific transition is possible from any state (not just current)boolcanSentToPaid=StateMachine<Invoice,InvoiceStatus>.CanTransition(myInvoice,fromState:InvoiceStatus.Sent,toState:InvoiceStatus.Paid);// Check if a state is a final state (no outgoing transitions)boolisPaidFinal=StateMachine<Invoice,InvoiceStatus>.IsFinalState(InvoiceStatus.Paid);// true// Check if an entity is currently in a final stateboolisInFinal=StateMachine<Invoice,InvoiceStatus>.IsInFinalState(myInvoice);Subscribe to be notified of all state transitions for logging, auditing, or metrics:
// Subscribe to all transitionsStateMachine<Invoice,InvoiceStatus>.OnTransition+= context =>{Console.WriteLine($"Invoice {context.Entity.Id} transitioned from {context.FromState} to {context.ToState}");if(context.Reason!=null)Console.WriteLine($" Reason: {context.Reason}");if(context.WasForced)Console.WriteLine($" WARNING: Transition was forced!");if(context.Metadata!=null)foreach(varkvpincontext.Metadata)Console.WriteLine($" {kvp.Key}: {kvp.Value}");};// Transition with reason and metadatavarmetadata=newDictionary<string,object>{["UserId"]=123,["Source"]="API"};StateMachine<Invoice,InvoiceStatus>.TryTransition(invoice,InvoiceStatus.Sent,"Customer requested",metadata);Bypass pre-conditions for administrative or recovery scenarios:
// Normal transition fails due to pre-conditionboolsuccess=StateMachine<Invoice,InvoiceStatus>.TryTransition(invoice,InvoiceStatus.Paid);// false// Force transition bypasses pre-conditions (but transition must still be defined)success=StateMachine<Invoice,InvoiceStatus>.ForceTransition(invoice,InvoiceStatus.Paid,reason:"Admin override - payment confirmed manually");// Get all states defined in the enumvarallStates=StateMachine<Invoice,InvoiceStatus>.GetAllStates();// Returns: [Draft, Sent, Paid, Cancelled]// Get complete transition mapvartransitions=StateMachine<Invoice,InvoiceStatus>.GetAllTransitions();// Returns dictionary: { Draft: [Sent, Cancelled], Sent: [Paid, Cancelled, Draft], ... }Get a string representation of the state machine for visualization.
stringmermaidGraph=StateMachine<Invoice,InvoiceStatus>.GenerateMermaidGraph();Console.WriteLine("\n--- Mermaid Graph ---");Console.WriteLine(mermaidGraph);You can paste the output into tools or Markdown environments that support Mermaid (like GitLab, GitHub, Obsidian, online editors https://mermaid.live/):
graph TD
Start((⚪)) --> Draft
Draft --> Sent
Sent -- "Remaining <= 0" --> Paid
Draft --> Cancelled
Sent -- "Remaining > 0" --> Cancelled
Get a string representation of the state machine for visualization in D2 format.
stringd2Graph=StateMachine<Invoice,InvoiceStatus>.GenerateD2Graph();Console.WriteLine("\n--- D2 Graph ---");Console.WriteLine(d2Graph);You can paste the output into tools or Markdown environments that support D2 (like Obsidian, online editors https://play.d2lang.com/):
# State Machine: Invoice - InvoiceStatusdirection: down# Stylesstyle{fill: honeydewstroke: limegreenstroke-width: 2font-size: 14shadow: true}Start: {
shape: circlestyle.fill: lightgreenstyle.stroke: greenwidth: 40height: 40
}
Start -> Draft# TransitionsDraft -> SentSent -> Paid: Remaining <= 0Draft -> CancelledSent -> Cancelled: Remaining > 0Both Mermaid and D2 graphs support highlighting a specific state:
varinvoice=newInvoice{Id=1,Status=InvoiceStatus.Sent};// Highlight based on entity's current statestringmermaidWithHighlight=StateMachine<Invoice,InvoiceStatus>.GenerateMermaidGraph(invoice);stringd2WithHighlight=StateMachine<Invoice,InvoiceStatus>.GenerateD2Graph(invoice);// Or highlight a specific state directlystringmermaidHighlightPaid=StateMachine<Invoice,InvoiceStatus>.GenerateMermaidGraph(InvoiceStatus.Paid);stringd2HighlightPaid=StateMachine<Invoice,InvoiceStatus>.GenerateD2Graph(InvoiceStatus.Paid);// D2 graphs can optionally exclude stylingstringd2NoStyles=StateMachine<Invoice,InvoiceStatus>.GenerateD2Graph(includeStyles:false);You can also use the generic diagram generator to create diagrams in either format:
stringdiagram=StateMachine<Invoice,InvoiceStatus>.GenerateDiagram(StateMachine<Invoice,InvoiceStatus>.DiagramType.Mermaid);Console.WriteLine("\n--- Diagram ---");Console.WriteLine(diagram);SlimStateMachine works well with ASP.NET Core applications and domain-driven design approaches:
// In your domain modelpublicclassOrder{publicGuidId{get;privateset;}publicOrderStatusStatus{get;privateset;}// Other domain properties...// Encapsulated state transition methodspublicboolProcessOrder(){returnStateMachine<Order,OrderStatus>.TryTransition(this,OrderStatus.Processing);}publicboolShipOrder(){returnStateMachine<Order,OrderStatus>.TryTransition(this,OrderStatus.Shipped);}}// In your startup codeStateMachine<Order,OrderStatus>.Configure(
order =>order.Status,
builder =>{builder.SetInitialState(OrderStatus.Created);builder.AllowTransition(OrderStatus.Created,OrderStatus.Processing,preCondition: o =>o.Items.Count>0,preConditionExpression:"Has items");// More transitions...});InvalidOperationExceptionis thrown if you try to use the state machine before callingConfigureor if you callConfiguremore than once for the sameTEntity/TEnumpair.StateMachineExceptionis thrown for configuration errors (e.g., missing initial state) or if aPostActionthrows an exception duringTryTransition.ArgumentException/ArgumentNullExceptionmay be thrown during configuration if invalid parameters (like the property accessor) are provided.
- Added
OnEntryandOnExitbuilder methods for state-specific actions. - Added
OnTransitionstatic event for global transition notifications. - Added
TransitionContext<TEntity, TEnum>class with reason, metadata, andWasForcedflag. - Added
ForceTransitionmethod to bypass pre-conditions. - Added
GetAllStatesandGetAllTransitionsquery methods. - Added
TryTransitionoverload accepting reason and metadata parameters.
- Added
TryTransitionAnymethods to attempt multiple transitions in order. - Added
IsFinalStateandIsInFinalStateto detect terminal states. - Added
GetDefinedTransitionsto query transitions without entity context. - Added
CanTransitionoverload with explicitfromStateparameter. - Added state highlighting support in Mermaid and D2 graph generation.
- Performance improvements using frozen collections internally.
- Added support for generating D2 graph format for state machine visualization.
- Added
GenerateDiagramwithDiagramTypeenum for format selection. - Fixed minor bugs in Mermaid graph generation.
- Basic state machine functionality
- Pre-conditions and post-action support
- Mermaid graph generation
- Thread-safe configuration
This project is licensed under the MIT License - see the LICENSE file for details.