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 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)."
);
// 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 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 runTry 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
}// 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);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);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");// 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], ... }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 > 0Both 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);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);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...
}
);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.