Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

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:

  1. 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.

  2. 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.

Diagram

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);

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))

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();

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);

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();

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:

Diagram

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}");
    }
);

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))

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))

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))

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);

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);

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); // true

Methods:

  • AddChild(parent, child, isInitial) — define hierarchy
  • IsInState(current, query) — checks current or any ancestor
  • TryGetParent(state, out parent) — navigate up
  • TryGetLCA(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);

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;

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();

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 transitions

Mermaid & Graphviz Export

Generate visual diagrams from the graph:

string mermaid = MermaidExporter.Export(graph);

Produces:

Diagram

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);
}

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 { ... }

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);
}

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);

Integration with FrenchExDev

The FSM library is designed to compose with the broader FrenchExDev ecosystem:

  • Result<T>FireAsync returns Result<Transition<TState>>, composable with Map, Bind, Recover
  • BuilderBuild() returns Result<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.

Source: FrenchExDev.Net.FiniteStateMachine