Skip to content

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

License

Notifications You must be signed in to change notification settings

stevehansen/SlimStateMachine

Repository files navigation

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
Loading

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.

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

License

Stars

Watchers

Forks

Contributors 3

  •  
  •  
  •  

Languages