This document provides a technical deep-dive into the Functional State Machine architecture, design decisions, and implementation details.
- Core Principles
- Type System
- State Machine Lifecycle
- Builder Pattern
- Transition Execution
- Hierarchical States
- Static Analysis
- Command Dispatching
- Performance Considerations
The state machine's Fire() method is a pure function:
(TState newState, TData newData, IReadOnlyList<TCommand> commands) =
machine.Fire(trigger, currentState, currentData);Input: Trigger, current state, current data
Output: New state, new data, commands to execute
No Side Effects: No I/O, no mutations, deterministic
This enables:
- Testability: Assert on returned values without mocks
- Determinism: Same inputs always produce same outputs
- Replayability: Store events and replay transitions
- Debugging: Step through logic without side effects
All state machine components are immutable:
- State Machine: Built once, never modified
- State Data: Use
withexpressions for updates - Commands: Immutable records describing intent
- Triggers: Immutable records carrying data
┌─────────────────┐
│ State Machine │ ← Logical rules & transitions
└────────┬────────┘
│ returns
▼
┌──────────┐
│ Commands │ ← What should happen
└────┬─────┘
│ dispatched to
▼
┌─────────────────┐
│ Command Runners │ ← How to execute
└─────────────────┘
The state machine defines business logic.
Command runners handle infrastructure.
StateMachine<TState, TTrigger, TData, TCommand>| Type | Purpose | Typical Structure |
|---|---|---|
TState |
State identifier | enum or record |
TTrigger |
Trigger hierarchy | abstract record with sealed subtypes |
TData |
State-associated data | sealed record |
TCommand |
Command hierarchy | abstract record with sealed subtypes |
public abstract record PaymentTrigger
{
public sealed record StartPayment(decimal Amount) : PaymentTrigger;
public sealed record CardAuthorized(string TransactionId) : PaymentTrigger;
public sealed record PaymentFailed(string Reason) : PaymentTrigger;
}Benefits:
- Type-safe trigger matching with
On<TTrigger>() - Compile-time verification of trigger types
- Data can be carried with triggers
- Pattern matching for trigger handling
public abstract record PaymentCommand
{
public sealed record ChargeCard(decimal Amount) : PaymentCommand;
public sealed record SendReceipt(string Email) : PaymentCommand;
public sealed record LogError(string Message) : PaymentCommand;
}Same benefits as triggers, plus:
- Clear intent of what should happen
- Testable without executing effects
- Can be persisted for audit trails
var builder = StateMachine<TState, TTrigger, TData, TCommand>.Create()
.StartWith(initialState)
.For(state)
.On<SomeTrigger>()
.TransitionTo(nextState)
.Execute(() => command)
// ... more configuration
.Build(); // ← Validation happens hereBuild Process:
- Collect state configurations
- Validate completeness
- Run static analysis
- Generate internal lookup structures
- Return immutable state machine
var (newState, newData, commands) =
machine.Fire(trigger, currentState, currentData);Fire Process:
- Find matching transition for (state, trigger)
- Evaluate guards if present
- Execute exit commands for current state
- Modify data if specified
- Execute transition commands
- Execute entry commands for new state
- Process immediate transitions
- Return tuple of (state, data, commands)
The builder uses a sophisticated type system to provide IntelliSense guidance:
public class StateConfiguration<TState, TTrigger, TData, TCommand>
{
public TransitionConfiguration<...> On<T>() where T : TTrigger;
public StateConfiguration<...> OnEntry(...);
public StateConfiguration<...> OnExit(...);
public StateConfiguration<...> Immediately(...);
public StateConfiguration<...> WithInitialSubState(...);
}
public class TransitionConfiguration<...>
{
public TransitionConfiguration<...> TransitionTo(TState state);
public TransitionConfiguration<...> ModifyData(Func<TData, TData> modifier);
public TransitionConfiguration<...> Execute(Func<TCommand> command);
public TransitionConfiguration<...> Guard(Func<bool> condition);
public ConditionalConfiguration<...> If(Func<bool> condition);
}Each configuration method returns a specific type that exposes only valid next operations.
.For(State)
.On<Trigger>() // Start transition configuration
.Guard(() => ...) // Guards before transition
.If(() => ...) // Conditional steps
.Execute(...) // Commands in branch
.TransitionTo() // Transition in branch
.ElseIf(() => ...)
.Execute(...)
.Else()
.Execute(...)
.Done() // End conditional block
.ModifyData(...) // Data modification
.Execute(...) // Unconditional commands
.TransitionTo(...) // Final transitionOrder Constraints:
- Guards must come before conditionals
- Conditionals must be closed with
Done() TransitionTo()can appear in conditionals or at endModifyData()affects subsequent commands
1. Guards (evaluate conditions)
↓
2. Conditional Steps (if guards pass)
↓
3. Data Modification
↓
4. Transition Commands
↓
5. Exit Commands (current state)
↓
6. Entry Commands (new state)
↓
7. Immediate Transitions (if configured)
Guards follow first-match semantics:
.For(State)
.On<Trigger>()
.Guard(() => condition1) // ← Evaluated first
.TransitionTo(StateA)
.On<Trigger>()
.Guard(() => condition2) // ← Only if condition1 is false
.TransitionTo(StateB)
.On<Trigger>() // ← Catch-all (no guard)
.TransitionTo(StateC)Important: Order matters! The first matching transition wins.
Commands are collected in order:
.For(State)
.OnExit(() => new ExitCommand())
.On<Trigger>()
.Execute(() => new TransitionCommand1())
.Execute(() => new TransitionCommand2())
.TransitionTo(NextState)
.For(NextState)
.OnEntry(() => new EntryCommand())Results in: [TransitionCommand1, TransitionCommand2, ExitCommand, EntryCommand]
.For(ParentState)
.WithInitialSubState(ChildState1)
.On<SomeGlobalTrigger>()
.TransitionTo(AnotherState)
.For(ChildState1)
.SubStateOf(ParentState)
.On<ChildSpecificTrigger>()
.TransitionTo(ChildState2)When a trigger fires in a child state:
- Check child state for matching transition
- If not found, check parent state
- If not found, propagate to parent's parent
- If still not found, trigger is unhandled
Transition: ChildA → ChildB (different parents)
1. Exit ChildA
2. Exit ParentA
3. Enter ParentB
4. Enter ChildB
The library automatically manages entry/exit order.
The .Build() method performs several checks internally to catch configuration errors early:
Detects states that can never be reached from the initial state using breadth-first search. The build will warn about unreachable states that may indicate missing transitions or configuration errors.
What you'll see: Warning about unreachable states in your state machine configuration.
Detects immediate transition cycles where states transition to themselves or form circular chains without any trigger, which would cause infinite loops.
What you'll see: Error indicating a circular immediate transition chain was detected.
Checks for multiple unguarded transitions on the same trigger from the same state, which would make the behavior ambiguous.
What you'll see: Error indicating multiple unguarded transitions exist for the same trigger in a state.
Warns if unguarded transitions appear before guarded transitions on the same trigger, which makes the guarded transitions unreachable due to first-match semantics.
What you'll see: Error indicating an unguarded transition makes subsequent guarded transitions unreachable.
.SkipAnalysis() // Disable all static analysis checksvar (_, _, commands) = machine.Fire(trigger, state, data);
foreach (var command in commands)
{
switch (command)
{
case ChargeCard cmd:
await paymentService.ChargeAsync(cmd.Amount);
break;
// ... more cases
}
}// 1. Define runners
public class ChargeCardRunner : ICommandRunner<ChargeCard>
{
public async Task RunAsync(ChargeCard command)
{
await paymentService.ChargeAsync(command.Amount);
}
}
// 2. Register runners
services.AddCommandRunners<PaymentCommand>();
// 3. Dispatch automatically
var dispatcher = serviceProvider.GetRequiredService<ICommandDispatcher<PaymentCommand>>();
await dispatcher.DispatchAsync(commands);The source generator creates:
// Generated code
public class CommandDispatcher : ICommandDispatcher<PaymentCommand>
{
public async Task DispatchAsync(IEnumerable<PaymentCommand> commands)
{
foreach (var command in commands)
{
switch (command)
{
case ChargeCard cmd:
await _chargeCardRunner.RunAsync(cmd);
break;
// ... generated cases
}
}
}
}Zero Allocation After Build:
- State machine structure is built once
Fire()only allocates for the command list- Uses
structinternally where possible
Data Efficiency:
- Record types use structural equality
withexpressions minimize allocations- Commands are lightweight records
Transition Lookup: O(1)
// Internal structure
Dictionary<(TState, Type), TransitionDefinition>State Configuration: O(1)
Dictionary<TState, StateDefinition>- When: Only during
.Build() - Cost: < 1ms per state machine (typical)
- Trade-off: Upfront cost for runtime safety
| Method | States | Transitions | Time | Allocated |
|-----------------|--------|-------------|---------|-----------|
| Build | 10 | 20 | 150 μs | 15 KB |
| Fire (simple) | - | - | 50 ns | 80 B |
| Fire (complex) | - | - | 150 ns | 240 B |
Triggers and Commands:
- Immutable by default
- Structural equality for free
- Concise syntax
- Pattern matching support
- Performance: Reflection is slow
- AOT Compatible: Works with native AOT
- Type Safety: Compile-time checks
Instead, we use:
- Generic type parameters
- Source generation (for dispatchers)
- Static analysis at build time
Alternative Approach (side effects in transitions):
.On<PaymentTrigger>()
.Execute(() => database.Save(...)) // ❌ Side effectOur Approach (commands returned):
.On<PaymentTrigger>()
.Execute(() => new SaveToDatabase(...)) // ✅ PureBenefits:
- Testability: Assert on commands without database
- Replayability: Store and replay transitions
- Flexibility: Choose when/how to execute
- Transactionality: Batch commands in a transaction
Compatibility over Features:
- Supports .NET Framework 4.6.1+
- Works in Unity, Xamarin, UWP
- Maximum reach for a library
Polyfills via PolySharp:
- Modern C# features (records, init)
- No runtime dependency
- Compile-time transformation
public record CustomState(string Name, int Priority);
var machine = StateMachine<CustomState, ...>.Create()
.StartWith(new CustomState("Initial", 0))
// ...Last Updated: February 2026