SlimStateMachine 1.3.0

dotnet add package SlimStateMachine --version 1.3.0
                    
NuGet\Install-Package SlimStateMachine -Version 1.3.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="SlimStateMachine" Version="1.3.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="SlimStateMachine" Version="1.3.0" />
                    
Directory.Packages.props
<PackageReference Include="SlimStateMachine" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add SlimStateMachine --version 1.3.0
                    
#r "nuget: SlimStateMachine, 1.3.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package SlimStateMachine@1.3.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=SlimStateMachine&version=1.3.0
                    
Install as a Cake Addin
#tool nuget:?package=SlimStateMachine&version=1.3.0
                    
Install as a Cake Tool

SlimStateMachine

icon

NuGet License

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 Management
public enum InvoiceStatus
{
    Draft,
    Sent,
    Paid,
    Cancelled
}

public class Invoice
{
    public int Id { get; set; }
    public InvoiceStatus Status { get; set; } // The state property
    public decimal TotalAmount { get; set; }
    public decimal AmountPaid { get; set; }
    public decimal RemainingAmount => TotalAmount - AmountPaid;
    public string? Notes { get; set; }

    // You might initialize the status in the constructor or rely on the state machine's initial state
    public Invoice()
    {
        // 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).

using SlimStateMachine;

// --- 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 transitions
        builder.AllowTransition(InvoiceStatus.Draft, InvoiceStatus.Sent);

        // 2c. Transition with a Pre-condition
        builder.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-conditions
        builder.AllowTransition(
            InvoiceStatus.Sent,
            InvoiceStatus.Cancelled,
            preCondition: inv => inv.RemainingAmount > 0,   // Can only cancel if not fully paid
            preConditionExpression: "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 instance
var myInvoice = new Invoice { Id = 101, TotalAmount = 500, AmountPaid = 0 };
// Initial state is implicitly Draft (enum default), matching configured InitialState

// Check if a transition is possible
bool canSend = StateMachine<Invoice, InvoiceStatus>.CanTransition(myInvoice, InvoiceStatus.Sent); // true
bool canPay = 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 states
var possibleStates = StateMachine<Invoice, InvoiceStatus>.GetPossibleTransitions(myInvoice);
// possibleStates will contain [Sent, Cancelled] for the initial Draft state in this config

Console.WriteLine($"Possible next states for invoice {myInvoice.Id}: {string.Join(", ", possibleStates)}");

// Attempt a transition
bool transitionSucceeded = 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 precondition
transitionSucceeded = StateMachine<Invoice, InvoiceStatus>.TryTransition(myInvoice, InvoiceStatus.Paid);
Console.WriteLine($"Tried paying unpaid invoice. Succeeded? {transitionSucceeded}. Status: {myInvoice.Status}"); // false, Status remains Sent

// Simulate payment
myInvoice.AmountPaid = 500;

// Try paying again - now succeeds
canPay = StateMachine<Invoice, InvoiceStatus>.CanTransition(myInvoice, InvoiceStatus.Paid); // true
transitionSucceeded = 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 Paid
Console.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:

var invoice = new Invoice { Id = 1, Status = InvoiceStatus.Sent, TotalAmount = 100, AmountPaid = 50 };

// Try to transition to Paid first, then Cancelled - will transition to first valid target
bool success = StateMachine<Invoice, InvoiceStatus>.TryTransitionAny(
    invoice,
    [InvoiceStatus.Paid, InvoiceStatus.Cancelled],
    out var resultState);

if (success)
{
    Console.WriteLine($"Transitioned to: {resultState}"); // Cancelled (Paid failed pre-condition)
}

// Or try any valid transition from current state
var anotherInvoice = new Invoice { 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)
var allFromDraft = StateMachine<Invoice, InvoiceStatus>.GetDefinedTransitions(InvoiceStatus.Draft);
// Returns: [Sent, Cancelled]

// Check if a specific transition is possible from any state (not just current)
bool canSentToPaid = StateMachine<Invoice, InvoiceStatus>.CanTransition(
    myInvoice,
    fromState: InvoiceStatus.Sent,
    toState: InvoiceStatus.Paid);

// Check if a state is a final state (no outgoing transitions)
bool isPaidFinal = StateMachine<Invoice, InvoiceStatus>.IsFinalState(InvoiceStatus.Paid); // true

// Check if an entity is currently in a final state
bool isInFinal = 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 transitions
StateMachine<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 (var kvp in context.Metadata)
            Console.WriteLine($"  {kvp.Key}: {kvp.Value}");
};

// Transition with reason and metadata
var metadata = new Dictionary<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-condition
bool success = 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 enum
var allStates = StateMachine<Invoice, InvoiceStatus>.GetAllStates();
// Returns: [Draft, Sent, Paid, Cancelled]

// Get complete transition map
var transitions = 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.

string mermaidGraph = 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

5. Generate D2 Graph

Get a string representation of the state machine for visualization in D2 format.

string d2Graph = 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 - InvoiceStatus
direction: down

# Styles
style {
  fill: honeydew
  stroke: limegreen
  stroke-width: 2
  font-size: 14
  shadow: true
}

Start: {
  shape: circle
  style.fill: lightgreen
  style.stroke: green
  width: 40
  height: 40
}

Start -> Draft

# Transitions
Draft -> Sent
Sent -> Paid: Remaining <= 0
Draft -> Cancelled
Sent -> Cancelled: Remaining > 0

5a. Highlight Current State in Graphs

Both Mermaid and D2 graphs support highlighting a specific state:

var invoice = new Invoice { Id = 1, Status = InvoiceStatus.Sent };

// Highlight based on entity's current state
string mermaidWithHighlight = StateMachine<Invoice, InvoiceStatus>.GenerateMermaidGraph(invoice);
string d2WithHighlight = StateMachine<Invoice, InvoiceStatus>.GenerateD2Graph(invoice);

// Or highlight a specific state directly
string mermaidHighlightPaid = StateMachine<Invoice, InvoiceStatus>.GenerateMermaidGraph(InvoiceStatus.Paid);
string d2HighlightPaid = StateMachine<Invoice, InvoiceStatus>.GenerateD2Graph(InvoiceStatus.Paid);

// D2 graphs can optionally exclude styling
string d2NoStyles = 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:

string diagram = 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 model
public class Order
{
    public Guid Id { get; private set; }
    public OrderStatus Status { get; private set; }
    
    // Other domain properties...
    
    // Encapsulated state transition methods
    public bool ProcessOrder()
    {
        return StateMachine<Order, OrderStatus>.TryTransition(this, OrderStatus.Processing);
    }
    
    public bool ShipOrder()
    {
        return StateMachine<Order, OrderStatus>.TryTransition(this, OrderStatus.Shipped);
    }
}

// In your startup code
StateMachine<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.

Product 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 is compatible.  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 is compatible.  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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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
1.3.0 221 12/5/2025
1.2.0 362 5/12/2025
1.1.0 228 5/8/2025
1.0.0 213 5/8/2025