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). - 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).
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 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
}
}
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)."
);
}
);
// --- End Configuration ---
// 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
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
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
You can also use the generic diagram generator to create diagrams in either format:
string diagram = StateMachine<Invoice, InvoiceStatus>.GenerateDiagram(format: "Mermaid"); Console.WriteLine("\n--- Diagram ---"); Console.WriteLine(diagram);
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...
}
);
InvalidOperationException
is thrown if you try to use the state machine before callingConfigure
or if you callConfigure
more than once for the sameTEntity
/TEnum
pair.StateMachineException
is thrown for configuration errors (e.g., missing initial state) or if aPostAction
throws an exception duringTryTransition
.ArgumentException
/ArgumentNullException
may be thrown during configuration if invalid parameters (like the property accessor) are provided.
- Added support for generating D2 graph format for state machine visualization.
- 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.