Skip to content

Repository files navigation

SlimStateMachine

icon

NuGetLicense

A lightweight C# library for defining and managing state machines based on an entity class and an enum property representing its state.

Features

  • 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 OnTransition event for logging, auditing, or custom behavior on any state change.
  • Transition Context: Pass reason and metadata with transitions, available in the OnTransition event.
  • Force Transitions: Bypass pre-conditions for administrative or recovery scenarios using ForceTransition.
  • Automatic State Update: The TryTransition method 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).

Installation

Install the package via NuGet Package Manager:

Install-Package SlimStateMachine

Or using .NET CLI:

dotnet add package SlimStateMachine

Supported Platforms

  • .NET 9.0
  • .NET 8.0
  • .NET Standard 2.0

Usage

1. Define Your Entity and Enum

// 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}}

2. Configure the State Machine

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 ---

3. Interact with the State Machine

// 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 run

3a. Batch Transitions with TryTransitionAny

Try 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}

3b. Query Transitions and Final States

// 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);

3c. Global Transition Event

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);

3d. Force Transition

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");

3e. Query All States and Transitions

// 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], ... }

4. Generate Mermaid Graph

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
Loading

5. Generate D2 Graph

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 > 0

5a. Highlight Current State in Graphs

Both 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);

6. Generate Diagram in Either Format

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);

Integration with ASP.NET Core and Domain-Driven Design

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...});

Error Handling

  • InvalidOperationException is thrown if you try to use the state machine before calling Configure or if you call Configure more than once for the same TEntity/TEnum pair.
  • StateMachineException is thrown for configuration errors (e.g., missing initial state) or if a PostAction throws an exception during TryTransition.
  • ArgumentException / ArgumentNullException may be thrown during configuration if invalid parameters (like the property accessor) are provided.

Version History

1.3.0 (Events, Hooks & Force Transitions)

  • Added OnEntry and OnExit builder methods for state-specific actions.
  • Added OnTransition static event for global transition notifications.
  • Added TransitionContext<TEntity, TEnum> class with reason, metadata, and WasForced flag.
  • Added ForceTransition method to bypass pre-conditions.
  • Added GetAllStates and GetAllTransitions query methods.
  • Added TryTransition overload accepting reason and metadata parameters.

1.2.0 (Batch Transitions & Final States)

  • Added TryTransitionAny methods to attempt multiple transitions in order.
  • Added IsFinalState and IsInFinalState to detect terminal states.
  • Added GetDefinedTransitions to query transitions without entity context.
  • Added CanTransition overload with explicit fromState parameter.
  • Added state highlighting support in Mermaid and D2 graph generation.
  • Performance improvements using frozen collections internally.

1.1.0 (D2 Graph Support)

  • Added support for generating D2 graph format for state machine visualization.
  • Added GenerateDiagram with DiagramType enum for format selection.
  • Fixed minor bugs in Mermaid graph generation.

1.0.0 (Initial Release)

  • Basic state machine functionality
  • Pre-conditions and post-action support
  • Mermaid graph generation
  • Thread-safe configuration

License

This project is licensed under the MIT License - see the LICENSE file for details.

About

A lightweight C# library for defining and managing state machines based on an entity class and an enum property representing its state.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Used by

Contributors

Languages