This guide helps you diagnose and fix common issues when using Functional State Machine.
- Build Errors
- Runtime Errors
- Configuration Issues
- Performance Issues
- Command Execution Issues
- Diagram Generation Issues
Error:
InvalidOperationException: Initial state must be set using StartWith()
Cause: You didn't call StartWith() before Build().
Fix:
var machine = StateMachine<MyState, MyTrigger, MyCommand>.Create()
.StartWith(MyState.Initial) // ← Add this
.Build();Error:
InvalidOperationException: State 'Active' is referenced in transitions but never configured with For()
Cause: You used a state in TransitionTo() but never called .For(state).
Fix:
.For(MyState.Initial)
.On<StartTrigger>()
.TransitionTo(MyState.Active) // Active is referenced
.For(MyState.Active) // ← Must configure Active too
.On<StopTrigger>()
.TransitionTo(MyState.Initial)Error:
InvalidOperationException: Unguarded transition on 'ProcessTrigger' in state 'Active'
makes subsequent guarded transitions unreachable
Cause: An unguarded transition comes before guarded ones, making them unreachable.
Problem:
.For(MyState.Active)
.On<ProcessTrigger>()
.TransitionTo(MyState.Done) // ← No guard, always matches
.On<ProcessTrigger>()
.Guard(() => condition)
.TransitionTo(MyState.Pending) // ← Never reached!Fix: Put guarded transitions first:
.For(MyState.Active)
.On<ProcessTrigger>()
.Guard(() => condition)
.TransitionTo(MyState.Pending) // ← Checked first
.On<ProcessTrigger>()
.TransitionTo(MyState.Done) // ← FallbackError:
InvalidOperationException: Circular immediate transition chain detected: A → B → C → A
Cause: Immediate transitions form an infinite loop.
Problem:
.For(StateA)
.Immediately(() => true)
.TransitionTo(StateB)
.For(StateB)
.Immediately(() => true)
.TransitionTo(StateC)
.For(StateC)
.Immediately(() => true)
.TransitionTo(StateA) // ← Back to A!Fix: Break the cycle or add guards:
.For(StateC)
.Immediately((data) => data.ShouldLoop) // ← Add condition
.TransitionTo(StateA)Error:
InvalidOperationException: No transition found for trigger 'PaymentFailed' in state 'Processing'
Cause: You fired a trigger that the current state doesn't handle.
Debugging:
// Check if state has the transition
var canHandle = machine.CanFire<PaymentFailed>(currentState);
if (!canHandle)
{
// Handle unhandled trigger
}Fix Option 1: Add the transition:
.For(MyState.Processing)
.On<PaymentFailed>() // ← Add missing transition
.TransitionTo(MyState.Failed)Fix Option 2: Use OnUnhandled:
.For(MyState.Processing)
.OnUnhandled((trigger) => new LogWarning($"Unhandled: {trigger}"))Fix Option 3: Use parent state (hierarchical):
.For(ParentState)
.On<PaymentFailed>() // ← Handles for all children
.TransitionTo(MyState.Failed)
.For(MyState.Processing)
.SubStateOf(ParentState) // ← Inherits parent transitionsWhen detected: Build time (static analysis)
Error:
InvalidOperationException: State 'Idle' has an unguarded transition for trigger 'Start' at position 1, making subsequent transitions unreachable.
Cause: Two or more unguarded transitions for same trigger/state. The first-match semantics mean only the first transition executes, making others unreachable.
Problem:
.For(MyState.Idle)
.On<StartTrigger>()
.TransitionTo(MyState.Active)
.On<StartTrigger>() // ← Duplicate! Unreachable
.TransitionTo(MyState.Pending)Fix: Use guards to disambiguate:
.For(MyState.Idle)
.On<StartTrigger>()
.Guard((data) => data.IsReady)
.TransitionTo(MyState.Active)
.On<StartTrigger>()
.Guard((data) => !data.IsReady)
.TransitionTo(MyState.Pending)Error:
InvalidOperationException: Guard evaluation threw an exception
Inner: NullReferenceException
Cause: Guard condition threw an exception during evaluation.
Problem:
.Guard((data) => data.User.IsActive) // ← data.User might be nullFix: Use null-safe guards:
.Guard((data) => data.User?.IsActive == true)Or handle it explicitly:
.Guard((data) => {
try {
return data.User.IsActive;
}
catch {
return false; // Default to false on error
}
})Problem: Guards always evaluate to false or true unexpectedly.
Common Mistakes:
// ❌ Wrong: Capturing variable from outer scope
var currentTime = DateTime.Now;
.Guard(() => currentTime.Hour > 9) // Captures currentTime at build, never changes
// ✅ Right: Evaluates fresh on each Fire()
.Guard(() => DateTime.Now.Hour > 9)
// ❌ Wrong: Capturing data from outer scope
var capturedData = data;
.Guard(() => capturedData.IsReady) // Uses stale captured reference
// ✅ Right: Use lambda parameter
.Guard((data) => data.IsReady) // Receives current data on Fire()Debugging Guards:
.Guard((data) => {
var result = data.IsReady && data.Count > 0;
Console.WriteLine($"Guard evaluated: {result}");
return result;
})Problem: State changes but data stays the same.
Cause: Forgot to use ModifyData().
// ❌ Wrong: No data modification
.On<IncrementTrigger>()
.TransitionTo(MyState.Active)
// Data is unchanged!
// ✅ Right: Modify data
.On<IncrementTrigger>()
.ModifyData(data => data with { Count = data.Count + 1 })
.TransitionTo(MyState.Active)Problem: OnEntry() or OnExit() commands aren't in the result.
Cause: Multiple possible issues:
- Forgot to configure entry/exit:
.For(MyState.Active)
.OnEntry(() => new LogEntry("Entered Active")) // ← Need this- Using internal transition:
// No entry/exit for internal transitions!
.On<SelfTrigger>()
// No TransitionTo() = internal transition- Wrong state:
// Entry/exit is on StateA, but transitioning to StateBProblem: .Build() takes several seconds.
Causes:
- Static analysis overhead: Rare, but possible with very large state machines
- Too many states/transitions: 1000+ states
Solutions:
// Skip analysis if you're confident
.SkipAnalysis()
.Build();
// Or optimize state machine design:
// - Use data instead of state explosion
// - Use hierarchical states to reduce complexityProblem: Fire() is slower than expected.
Benchmarking:
var sw = Stopwatch.StartNew();
var (newState, newData, commands) = machine.Fire(trigger, state, data);
sw.Stop();
Console.WriteLine($"Fire took: {sw.Elapsed.TotalMilliseconds}ms");Common Causes:
- Expensive guards:
// ❌ Slow guard
.Guard(() => Database.CheckSomething()) // I/O in guard!
// ✅ Fast guard
.Guard((data) => data.IsApproved) // Just check data- Too many commands:
// ❌ Many commands
.ExecuteSteps(() => Enumerable.Range(1, 1000).Select(i => new Command(i)))
// ✅ Batch operations
.Execute(() => new BatchCommand(1, 1000))- Data modification overhead:
// ❌ Complex modification
.ModifyData(data => ExpensiveOperation(data))
// ✅ Simple modification
.ModifyData(data => data with { Counter = data.Counter + 1 })Error:
InvalidOperationException: No command runner registered for command 'MyCommand'
Fix:
// 1. Create the runner
public class MyCommandRunner : ICommandRunner<MyCommand>
{
public Task RunAsync(MyCommand command) { ... }
}
// 2. Register it
services.AddCommandRunners<MyCommand>();Problem: Commands execute out of order.
Cause: Async execution without proper ordering.
Fix:
// ❌ Wrong: Parallel execution
foreach (var command in commands)
{
_ = Task.Run(() => dispatcher.DispatchAsync(command));
}
// ✅ Right: Sequential execution
foreach (var command in commands)
{
await dispatcher.DispatchAsync(command);
}Problem: Command fails but state machine already moved to next state.
This is by design: State machine is pure and doesn't know about execution failure.
Solution: Implement compensation:
var (newState, newData, commands) = machine.Fire(trigger, currentState, currentData);
try
{
await ExecuteCommandsAsync(commands);
// Success: save new state
await SaveStateAsync(newState, newData);
}
catch (Exception ex)
{
// Failure: keep old state
await SaveStateAsync(currentState, currentData);
// Optionally fire compensation trigger
var (compensatedState, compensatedData, _) =
machine.Fire(new CompensationTrigger(ex), currentState, currentData);
}Cause: Unreachable states aren't shown by default.
Fix: Ensure all states are reachable or configure them anyway:
.For(UnreachableState) // Configure it even if unreachableProblem: Generated Mermaid diagram is unreadable.
Solutions:
- Break into sub-machines: Split large state machine into multiple smaller ones
- Use subgraphs: Leverage hierarchical states for better organization
- Simplify: Combine similar states using data instead
If you're still stuck:
- Check the docs: docs/
- Review samples: samples/
- Search issues: GitHub Issues
- Ask for help: Open a new issue with:
- Your state machine configuration
- Steps to reproduce
- Expected vs actual behavior
- .NET and library versions
public async Task HandleTriggerSafely<T>(T trigger) where T : MyTrigger
{
try
{
var (newState, newData, commands) = machine.Fire(trigger, currentState, currentData);
await ExecuteCommandsAsync(commands);
await SaveStateAsync(newState, newData);
}
catch (InvalidOperationException ex) when (ex.Message.Contains("No transition"))
{
// Unhandled trigger
logger.LogWarning($"Unhandled trigger: {trigger}");
}
}public void ValidateState()
{
// Load state from storage
var (state, data) = LoadState();
// Validate before using
if (!Enum.IsDefined(typeof(MyState), state))
{
throw new InvalidStateException($"Invalid state: {state}");
}
// Optionally validate data
if (data == null || data.IsInvalid())
{
throw new InvalidDataException("State data is invalid");
}
}Can't find your issue? Open a GitHub issue and we'll help!