FiniteStateMachine: Three Tiers, One Engine, Zero Exceptions
A fully async state machine framework for .NET with three complementary tiers — Dynamic (string-based), Typed (enum-based), and Rich (OOP-based) — unified by a single engine. Guards, entry/exit actions, hierarchical states, deferred events, parallel regions, timers, graph introspection, Mermaid export, and Result-based error handling. No exceptions for invalid transitions. Ever.
The Problem
State machines are everywhere in business software — order workflows, approval pipelines, document lifecycles, IoT device states, game logic. Yet most .NET implementations fall into one of two traps:
Too simple: A dictionary of transitions and a current state. No guards, no actions, no lifecycle hooks, no thread safety, no error handling. Works for a tutorial, breaks in production.
Too heavy: Full-blown workflow engines with XML configuration, persistence layers, and runtime reflection. You wanted a state machine, not a BPM platform.
What I needed was something in between: compile-time safety, async-first design, explicit error handling via Result<T>, and enough advanced features (hierarchies, deferred events, parallel regions) to handle real-world complexity — without the overhead of a framework you have to fight.
The Architecture: Three Tiers, One Engine
The key insight: different use cases need different levels of type safety. A data-driven workflow loaded from a database needs string states. A well-known domain process needs enum states. A complex aggregate with computed transitions needs OOP states. But the runtime behavior — guard evaluation, lifecycle actions, listener notification — is identical.
All three tiers produce an IStateMachineDefinition<TState, TEvent> — an immutable specification that can create multiple IStateMachine<TState, TEvent> instances. The definition is built once, the machines run many times.
Tier 1: Typed — Enum-Based State Machines
The most common tier. States and events are enums. Transitions are O(1) via a pre-computed TransitionTable indexed by enum values.
enum OrderState { Created, Submitted, Approved, Shipped, Delivered, Cancelled }
enum OrderEvent { Submit, Approve, Ship, Deliver, Cancel }
var result = new TypedStateMachineBuilder<OrderState, OrderEvent>()
.InitialState(OrderState.Created)
.When(OrderState.Created)
.On(OrderEvent.Submit).TransitionTo(OrderState.Submitted)
.WithGuard((from, evt, to, ct) => ValidateOrderAsync(ct))
.WithAction((from, evt, to, ct) => SendConfirmationAsync(ct))
.On(OrderEvent.Cancel).TransitionTo(OrderState.Cancelled)
.When(OrderState.Submitted)
.On(OrderEvent.Approve).TransitionTo(OrderState.Approved)
.On(OrderEvent.Cancel).TransitionTo(OrderState.Cancelled)
.When(OrderState.Approved)
.On(OrderEvent.Ship).TransitionTo(OrderState.Shipped)
.When(OrderState.Shipped)
.On(OrderEvent.Deliver).TransitionTo(OrderState.Delivered)
.FinalState(OrderState.Delivered)
.FinalState(OrderState.Cancelled)
.Build();
// Build() returns Result<TypedStateMachineDefinition<...>>
var definition = result.Value!;
var machine = definition.CreateMachine(ConcurrencyMode.Semaphore);enum OrderState { Created, Submitted, Approved, Shipped, Delivered, Cancelled }
enum OrderEvent { Submit, Approve, Ship, Deliver, Cancel }
var result = new TypedStateMachineBuilder<OrderState, OrderEvent>()
.InitialState(OrderState.Created)
.When(OrderState.Created)
.On(OrderEvent.Submit).TransitionTo(OrderState.Submitted)
.WithGuard((from, evt, to, ct) => ValidateOrderAsync(ct))
.WithAction((from, evt, to, ct) => SendConfirmationAsync(ct))
.On(OrderEvent.Cancel).TransitionTo(OrderState.Cancelled)
.When(OrderState.Submitted)
.On(OrderEvent.Approve).TransitionTo(OrderState.Approved)
.On(OrderEvent.Cancel).TransitionTo(OrderState.Cancelled)
.When(OrderState.Approved)
.On(OrderEvent.Ship).TransitionTo(OrderState.Shipped)
.When(OrderState.Shipped)
.On(OrderEvent.Deliver).TransitionTo(OrderState.Delivered)
.FinalState(OrderState.Delivered)
.FinalState(OrderState.Cancelled)
.Build();
// Build() returns Result<TypedStateMachineDefinition<...>>
var definition = result.Value!;
var machine = definition.CreateMachine(ConcurrencyMode.Semaphore);Fluent Builder Chain
The builder enforces a natural reading order: When(state).On(event).TransitionTo(target). Guards and actions chain after the target:
.When(OrderState.Created)
.On(OrderEvent.Submit)
.TransitionTo(OrderState.Submitted)
.WithGuard((from, evt, to, ct) => HasItemsAsync(ct))
.WithGuard((from, evt, to, ct) => HasPaymentAsync(ct)) // AND logic
.WithAction((from, evt, to, ct) => NotifyWarehouseAsync(ct)).When(OrderState.Created)
.On(OrderEvent.Submit)
.TransitionTo(OrderState.Submitted)
.WithGuard((from, evt, to, ct) => HasItemsAsync(ct))
.WithGuard((from, evt, to, ct) => HasPaymentAsync(ct)) // AND logic
.WithAction((from, evt, to, ct) => NotifyWarehouseAsync(ct))Multiple guards per transition use AND logic — all must pass. Multiple actions execute sequentially.
O(1) Transition Lookup
The TransitionTable<TState, TEvent> uses the enum's underlying integer values as array indices. No dictionary hashing, no string comparison — just table[stateIndex, eventIndex]. This matters when you're firing thousands of events per second.
Tier 2: Dynamic — String-Based State Machines
For data-driven workflows where states aren't known at compile time — loaded from a database, defined by configuration, or received from an API.
var definition = new DynamicStateMachineBuilder()
.InitialState("Draft")
.When("Draft")
.On("Submit").TransitionTo("PendingReview")
.On("Delete").TransitionTo("Deleted")
.When("PendingReview")
.On("Approve").TransitionTo("Published")
.On("Reject").TransitionTo("Draft")
.FinalState("Published")
.FinalState("Deleted")
.Build();var definition = new DynamicStateMachineBuilder()
.InitialState("Draft")
.When("Draft")
.On("Submit").TransitionTo("PendingReview")
.On("Delete").TransitionTo("Deleted")
.When("PendingReview")
.On("Approve").TransitionTo("Published")
.On("Reject").TransitionTo("Draft")
.FinalState("Published")
.FinalState("Deleted")
.Build();The Dynamic tier uses dictionary-based lookup (vs. the Typed tier's array-based TransitionTable), but the engine behavior is identical: guards, actions, listeners, Result-based errors.
JSON Serialization
Dynamic definitions can be serialized and deserialized:
var json = DynamicStateMachineSerializer.Serialize(definition.Value!);
var restored = DynamicStateMachineSerializer.Deserialize(json);var json = DynamicStateMachineSerializer.Serialize(definition.Value!);
var restored = DynamicStateMachineSerializer.Deserialize(json);This means you can store workflow definitions in a database and load them at runtime — no recompilation needed.
Tier 3: Rich — OOP State Machines
For complex domains where states carry data and transitions compute their target from event payloads. States and events are interfaces with concrete record implementations.
// States carry domain data
public interface IOrderState : IState { }
public record CreatedState() : IOrderState
{
public string Name => "Created";
}
public record ShippedState(string TrackingNumber, DateTime ShippedAt) : IOrderState
{
public string Name => "Shipped";
}
// Events carry payloads
public interface IOrderEvent : IEvent { }
public record SubmitEvent(List<string> Items, string CustomerId) : IOrderEvent
{
public string Name => "Submit";
}
public record ShipEvent(string TrackingNumber) : IOrderEvent
{
public string Name => "Ship";
}
var definition = new RichStateMachineBuilder<IOrderState, IOrderEvent>()
.InitialState(new CreatedState())
.When<CreatedState>()
.On<SubmitEvent>()
.TransitionTo<ShippedState>((evt, state) =>
new ShippedState(evt.TrackingNumber, DateTime.UtcNow))
.WithGuard((evt, state) => Task.FromResult(evt.Items.Count > 0))
.Build();// States carry domain data
public interface IOrderState : IState { }
public record CreatedState() : IOrderState
{
public string Name => "Created";
}
public record ShippedState(string TrackingNumber, DateTime ShippedAt) : IOrderState
{
public string Name => "Shipped";
}
// Events carry payloads
public interface IOrderEvent : IEvent { }
public record SubmitEvent(List<string> Items, string CustomerId) : IOrderEvent
{
public string Name => "Submit";
}
public record ShipEvent(string TrackingNumber) : IOrderEvent
{
public string Name => "Ship";
}
var definition = new RichStateMachineBuilder<IOrderState, IOrderEvent>()
.InitialState(new CreatedState())
.When<CreatedState>()
.On<SubmitEvent>()
.TransitionTo<ShippedState>((evt, state) =>
new ShippedState(evt.TrackingNumber, DateTime.UtcNow))
.WithGuard((evt, state) => Task.FromResult(evt.Items.Count > 0))
.Build();The key difference: computed target states. The lambda (evt, state) => new ShippedState(...) creates the next state from the event payload and current state. This is impossible with enums — the target state is a value, not just a label.
Type-discriminated matching (When<CreatedState>().On<SubmitEvent>()) means each combination of state type and event type can have its own transition logic, guards, and target factory.
The FireAsync Lifecycle
Every tier runs the same lifecycle when firing an event:
The order is strict: Guard → Exit → Transition Action → Enter → Notify. If a guard denies, nothing else runs. If an exception occurs during an action, it's caught and surfaced through OnErrorAsync on listeners.
Result-Based Error Handling
Invalid transitions don't throw. They return a failure Result with a clear reason:
var result = await machine.FireAsync(OrderEvent.Ship);
result.Match(
success: transition =>
{
Console.WriteLine($"Moved from {transition.From} to {transition.To}");
if (transition.IsReentrant)
Console.WriteLine("(same state — internal transition)");
},
failure: errors =>
{
// "No transition from 'Created' on event 'Ship'"
// "All guards denied transition from 'Created' on event 'Submit'"
foreach (var error in errors)
Console.WriteLine($"Denied: {error.Message}");
}
);var result = await machine.FireAsync(OrderEvent.Ship);
result.Match(
success: transition =>
{
Console.WriteLine($"Moved from {transition.From} to {transition.To}");
if (transition.IsReentrant)
Console.WriteLine("(same state — internal transition)");
},
failure: errors =>
{
// "No transition from 'Created' on event 'Ship'"
// "All guards denied transition from 'Created' on event 'Submit'"
foreach (var error in errors)
Console.WriteLine($"Denied: {error.Message}");
}
);This integrates naturally with the Result pattern used across the FrenchExDev ecosystem. No try/catch for control flow. The type system tells you what can happen.
Guards: Async Preconditions
Guards are async functions that evaluate whether a transition is allowed. All guards must pass (AND logic):
// Inline lambda guard
.WithGuard((from, evt, to, ct) => HasSufficientInventoryAsync(ct))
// Interface-based guard (for complex logic or DI)
public class FraudCheckGuard : IGuard<OrderState, OrderEvent>
{
private readonly IFraudService _fraud;
public FraudCheckGuard(IFraudService fraud) => _fraud = fraud;
public async Task<bool> EvaluateAsync(
OrderState from, OrderEvent @event, OrderState to, CancellationToken ct)
{
var score = await _fraud.CheckAsync(ct);
return score < 0.7;
}
}
// Register
.WithGuard(new FraudCheckGuard(fraudService))// Inline lambda guard
.WithGuard((from, evt, to, ct) => HasSufficientInventoryAsync(ct))
// Interface-based guard (for complex logic or DI)
public class FraudCheckGuard : IGuard<OrderState, OrderEvent>
{
private readonly IFraudService _fraud;
public FraudCheckGuard(IFraudService fraud) => _fraud = fraud;
public async Task<bool> EvaluateAsync(
OrderState from, OrderEvent @event, OrderState to, CancellationToken ct)
{
var score = await _fraud.CheckAsync(ct);
return score < 0.7;
}
}
// Register
.WithGuard(new FraudCheckGuard(fraudService))Guards receive the full context: source state, event, target state, and cancellation token. They can call external services, check databases, validate business rules — all async.
Entry, Exit, and Transition Actions
Three types of actions fire at different points in the lifecycle:
// Transition action: fires during a specific transition
.When(OrderState.Approved)
.On(OrderEvent.Ship)
.TransitionTo(OrderState.Shipped)
.WithAction((from, evt, to, ct) => UpdateInventoryAsync(ct))// Transition action: fires during a specific transition
.When(OrderState.Approved)
.On(OrderEvent.Ship)
.TransitionTo(OrderState.Shipped)
.WithAction((from, evt, to, ct) => UpdateInventoryAsync(ct))Internal transitions skip entry/exit actions — useful for self-transitions that should execute an action without re-triggering state lifecycle:
.When(OrderState.Processing)
.On(OrderEvent.Heartbeat)
.InternalTransition() // No exit/entry, just the action
.WithAction((from, evt, to, ct) => UpdateTimestampAsync(ct)).When(OrderState.Processing)
.On(OrderEvent.Heartbeat)
.InternalTransition() // No exit/entry, just the action
.WithAction((from, evt, to, ct) => UpdateTimestampAsync(ct))Listeners: The Observer Pattern
Listeners receive notifications for every lifecycle event. They're ideal for audit logging, metrics, debugging, and integration:
public class AuditListener : StateMachineListenerBase<OrderState, OrderEvent>
{
private readonly List<TransitionRecord<OrderState, OrderEvent>> _history = new();
public override Task OnTransitionedAsync(
OrderState from, OrderEvent @event, OrderState to, CancellationToken ct)
{
_history.Add(new TransitionRecord<OrderState, OrderEvent>(from, @event, to));
return Task.CompletedTask;
}
public override Task OnTransitionDeniedAsync(
OrderState from, OrderEvent @event, string reason, CancellationToken ct)
{
Console.WriteLine($"Denied: {from} --{@event}--> ? ({reason})");
return Task.CompletedTask;
}
public IReadOnlyList<TransitionRecord<OrderState, OrderEvent>> History => _history;
}
var listener = new AuditListener();
machine.AddListener(listener);public class AuditListener : StateMachineListenerBase<OrderState, OrderEvent>
{
private readonly List<TransitionRecord<OrderState, OrderEvent>> _history = new();
public override Task OnTransitionedAsync(
OrderState from, OrderEvent @event, OrderState to, CancellationToken ct)
{
_history.Add(new TransitionRecord<OrderState, OrderEvent>(from, @event, to));
return Task.CompletedTask;
}
public override Task OnTransitionDeniedAsync(
OrderState from, OrderEvent @event, string reason, CancellationToken ct)
{
Console.WriteLine($"Denied: {from} --{@event}--> ? ({reason})");
return Task.CompletedTask;
}
public IReadOnlyList<TransitionRecord<OrderState, OrderEvent>> History => _history;
}
var listener = new AuditListener();
machine.AddListener(listener);Thread Safety: ConcurrencyMode
Two modes, chosen at machine creation:
// Single-threaded (caller responsible for serialization)
var machine = definition.CreateMachine(ConcurrencyMode.None);
// Async-safe (SemaphoreSlim internally)
var machine = definition.CreateMachine(ConcurrencyMode.Semaphore);// Single-threaded (caller responsible for serialization)
var machine = definition.CreateMachine(ConcurrencyMode.None);
// Async-safe (SemaphoreSlim internally)
var machine = definition.CreateMachine(ConcurrencyMode.Semaphore);ConcurrencyMode.Semaphore wraps FireAsync in a SemaphoreSlim(1, 1) — only one transition executes at a time. No deadlock risk (single semaphore, no nesting). Listeners are called inside the lock, so they see consistent state.
Advanced: Hierarchical States
Parent-child state relationships, inspired by UML statecharts:
var hierarchy = new StateHierarchy<OrderState>();
hierarchy.AddChild(OrderState.Processing, OrderState.Validating, isInitial: true);
hierarchy.AddChild(OrderState.Processing, OrderState.Charging);
// When in Validating or Charging, a Cancel event on Processing fires
// (falls back to parent transition if child has none)
hierarchy.IsInState(OrderState.Validating, OrderState.Processing); // truevar hierarchy = new StateHierarchy<OrderState>();
hierarchy.AddChild(OrderState.Processing, OrderState.Validating, isInitial: true);
hierarchy.AddChild(OrderState.Processing, OrderState.Charging);
// When in Validating or Charging, a Cancel event on Processing fires
// (falls back to parent transition if child has none)
hierarchy.IsInState(OrderState.Validating, OrderState.Processing); // trueMethods:
AddChild(parent, child, isInitial)— define hierarchyIsInState(current, query)— checks current or any ancestorTryGetParent(state, out parent)— navigate upTryGetLCA(a, b, out lca)— Least Common Ancestor (for transition optimization)
Advanced: Deferred Event Queue
Events received in the wrong state can be queued and replayed later:
var queue = new DeferredEventQueue<OrderState, OrderEvent>();
queue.Defer(OrderState.WaitingForPayment, OrderEvent.Ship);
// When Ship arrives while in WaitingForPayment: queued
if (queue.IsDeferred(machine.CurrentState, @event))
queue.Enqueue(@event);
// When transitioning to ReadyToShip: replay queued events
int replayed = await queue.ReplayAsync(machine, ct);var queue = new DeferredEventQueue<OrderState, OrderEvent>();
queue.Defer(OrderState.WaitingForPayment, OrderEvent.Ship);
// When Ship arrives while in WaitingForPayment: queued
if (queue.IsDeferred(machine.CurrentState, @event))
queue.Enqueue(@event);
// When transitioning to ReadyToShip: replay queued events
int replayed = await queue.ReplayAsync(machine, ct);Max replay depth (default 100) prevents infinite loops. FIFO ordering preserves event semantics.
Advanced: Composite / Parallel Regions
Multiple independent state machines running concurrently — UML orthogonal regions:
var composite = new CompositeStateMachine<State, Event>(
new[]
{
("PaymentRegion", paymentMachine),
("ShippingRegion", shippingMachine),
},
isTerminal: state => state == State.Complete
);
// Broadcast event to all regions
var results = await composite.FireAllAsync(Event.Cancel, ct);
// Check if all regions reached terminal state
bool allDone = composite.AllRegionsTerminal;
// Query individual region
var paymentState = composite.GetRegion("PaymentRegion").CurrentState;var composite = new CompositeStateMachine<State, Event>(
new[]
{
("PaymentRegion", paymentMachine),
("ShippingRegion", shippingMachine),
},
isTerminal: state => state == State.Complete
);
// Broadcast event to all regions
var results = await composite.FireAllAsync(Event.Cancel, ct);
// Check if all regions reached terminal state
bool allDone = composite.AllRegionsTerminal;
// Query individual region
var paymentState = composite.GetRegion("PaymentRegion").CurrentState;Advanced: Timer-Based Transitions
Automatic timeout transitions:
var timer = new TimerTransition<OrderState, OrderEvent>(
machine,
OrderEvent.Timeout,
TimeSpan.FromMinutes(30)
);
// Start in entry action for a time-sensitive state
timer.Start();
// Cancel in exit action (or Dispose)
timer.Cancel();var timer = new TimerTransition<OrderState, OrderEvent>(
machine,
OrderEvent.Timeout,
TimeSpan.FromMinutes(30)
);
// Start in entry action for a time-sensitive state
timer.Start();
// Cancel in exit action (or Dispose)
timer.Cancel();After the timeout elapses, FireAsync(OrderEvent.Timeout) is called automatically.
Graph Introspection
Static analysis of the state graph without running the machine:
var graph = new StateGraph<OrderState, OrderEvent>(OrderState.Created);
graph.AddTransition(OrderState.Created, OrderEvent.Submit, OrderState.Submitted);
graph.AddTransition(OrderState.Submitted, OrderEvent.Approve, OrderState.Approved);
// ... add all transitions
var reachable = graph.ReachableStates; // BFS from initial
var unreachable = graph.UnreachableStates; // Dead code detection
var deadEnds = graph.DeadEndStates; // States with no outgoing transitionsvar graph = new StateGraph<OrderState, OrderEvent>(OrderState.Created);
graph.AddTransition(OrderState.Created, OrderEvent.Submit, OrderState.Submitted);
graph.AddTransition(OrderState.Submitted, OrderEvent.Approve, OrderState.Approved);
// ... add all transitions
var reachable = graph.ReachableStates; // BFS from initial
var unreachable = graph.UnreachableStates; // Dead code detection
var deadEnds = graph.DeadEndStates; // States with no outgoing transitionsMermaid & Graphviz Export
Generate visual diagrams from the graph:
string mermaid = MermaidExporter.Export(graph);string mermaid = MermaidExporter.Export(graph);Produces:
DotExporter.Export(graph) generates Graphviz DOT format for more advanced visualization.
Source Generation: Attribute-Based Definitions
For the ultimate compile-time safety, define transitions via attributes and let a Roslyn source generator build the machine:
[StateMachine(typeof(DoorState), typeof(DoorEvent))]
public partial class DoorMachine
{
public DoorMachine() => DefineTransitions();
[Transition(DoorState.Closed, DoorEvent.Open, DoorState.Open, Guard = nameof(IsUnlocked))]
[Transition(DoorState.Open, DoorEvent.Close, DoorState.Closed)]
[Transition(DoorState.Closed, DoorEvent.Lock, DoorState.Locked)]
private partial void DefineTransitions();
private Task<bool> IsUnlocked(
DoorState from, DoorEvent @event, DoorState to, CancellationToken ct)
=> Task.FromResult(_unlocked);
}[StateMachine(typeof(DoorState), typeof(DoorEvent))]
public partial class DoorMachine
{
public DoorMachine() => DefineTransitions();
[Transition(DoorState.Closed, DoorEvent.Open, DoorState.Open, Guard = nameof(IsUnlocked))]
[Transition(DoorState.Open, DoorEvent.Close, DoorState.Closed)]
[Transition(DoorState.Closed, DoorEvent.Lock, DoorState.Locked)]
private partial void DefineTransitions();
private Task<bool> IsUnlocked(
DoorState from, DoorEvent @event, DoorState to, CancellationToken ct)
=> Task.FromResult(_unlocked);
}The generator emits the builder calls, transition table, and type-safe factory. Guards are resolved by nameof() — no strings, no reflection. If you rename a guard method, the compiler catches it.
Rich FSMs have their own attributes:
[RichStateMachine(InitialState = typeof(CreatedState))]
public partial interface IOrderState : IState { }
[State(Terminal = false)]
public partial record CreatedState : IOrderState { ... }
[Event]
[RichTransition(typeof(CreatedState), typeof(ShippedState))]
public partial record ShipEvent(string Tracking) : IOrderEvent { ... }[RichStateMachine(InitialState = typeof(CreatedState))]
public partial interface IOrderState : IState { }
[State(Terminal = false)]
public partial record CreatedState : IOrderState { ... }
[Event]
[RichTransition(typeof(CreatedState), typeof(ShippedState))]
public partial record ShipEvent(string Tracking) : IOrderEvent { ... }Testing: Model-Based Path Generation
The testing library generates all possible paths through the state machine for exhaustive testing:
var paths = StateMachinePathGenerator.AllPaths(definition, maxDepth: 50);
foreach (var path in paths)
{
var machine = definition.CreateMachine();
foreach (var step in path.Steps)
{
var result = await machine.FireAsync(step.Event);
Assert.True(result.IsSuccess);
}
Assert.Equal(path.FinalState, machine.CurrentState);
}var paths = StateMachinePathGenerator.AllPaths(definition, maxDepth: 50);
foreach (var path in paths)
{
var machine = definition.CreateMachine();
foreach (var step in path.Steps)
{
var result = await machine.FireAsync(step.Event);
Assert.True(result.IsSuccess);
}
Assert.Equal(path.FinalState, machine.CurrentState);
}Combined with assertion helpers:
// Assert a specific transition
await StateMachineAssert.TransitionsToAsync(machine, OrderEvent.Submit, OrderState.Submitted);
// Assert a full path reaches a target
await StateMachineAssert.PathReachesAsync(
machine,
new[] { OrderEvent.Submit, OrderEvent.Approve, OrderEvent.Ship },
OrderState.Shipped);
// Assert a transition is denied
await StateMachineAssert.IsDeniedAsync(machine, OrderEvent.Ship);// Assert a specific transition
await StateMachineAssert.TransitionsToAsync(machine, OrderEvent.Submit, OrderState.Submitted);
// Assert a full path reaches a target
await StateMachineAssert.PathReachesAsync(
machine,
new[] { OrderEvent.Submit, OrderEvent.Approve, OrderEvent.Ship },
OrderState.Shipped);
// Assert a transition is denied
await StateMachineAssert.IsDeniedAsync(machine, OrderEvent.Ship);Integration with FrenchExDev
The FSM library is designed to compose with the broader FrenchExDev ecosystem:
- Result<T> —
FireAsyncreturnsResult<Transition<TState>>, composable withMap,Bind,Recover - Builder —
Build()returnsResult<Definition>, validation errors accumulate - DDD DSL — aggregate state machines use the Typed tier with domain events
- Requirements DSL — workflow state machines for requirement lifecycle (Draft → Proposed → Approved → Done)
- Diem CMF — the Workflow DSL uses the Rich tier for editorial pipelines with per-locale state tracking
Design Decisions
Why three tiers instead of one? Because the right abstraction depends on the problem. A simple on/off switch doesn't need OOP states. A complex approval workflow with computed transitions does. One engine, three APIs.
Why Result<T> instead of exceptions? Because "the transition was denied" is not exceptional — it's a normal outcome. Guard failures, missing transitions, concurrent access — these are expected situations that the caller must handle. Result types make this explicit.
Why async-first?
Because guards and actions almost always need I/O: database checks, API calls, message publishing. Sync wrappers hide async complexity behind .Result and risk deadlocks. Async-first with CancellationToken throughout is the honest design.
Why immutable definitions? Because a state machine specification shouldn't change after construction. Build it once, validate it once, create as many machine instances as you need. Thread-safe by construction.
The Takeaway
Most FSM libraries make you choose: simple and limited, or powerful and complex. This one gives you three tiers that share a single async engine:
- Dynamic for data-driven workflows loaded at runtime
- Typed for well-known domain processes with O(1) transitions
- Rich for complex aggregates with computed states and event payloads
Plus the features that production systems actually need: hierarchical states, deferred events, parallel regions, timers, graph introspection, Mermaid export, model-based test generation, and source-generated definitions.
All unified by Result<T> error handling and the Builder construction pattern. No exceptions for control flow. No reflection at runtime. No XML configuration.
Just a state machine that does what state machines should do — manage state transitions safely, explicitly, and fast.