State machines are so integral to software development that they often seem invisible to developers. They are used so frequently yet abstracted away through APIs and syntax that many developers don’t directly deal with them. However, we’d like to.

At their core, state machines are systems with finite inputs and deterministic pathways. While they can be complex, the basic structure of nodes and vertices makes them more approachable than they may initially seem.

In this post, I’ll guide you through the process of building two state machines using the .NET library Stateless. We’ll also discuss effective strategies for incorporating state machines into your code.

Getting Started with Stateless

To start using Stateless, you’ll need to install the latest version of the package using NuGet.

dotnet add package Stateless

From here, you will use the StateMachine class to define the state object and the triggers that mutate the machine’s state.

The example used in the Stateless documentation is that of a phone.

var phoneCall = new StateMachine<State, Trigger>(State.OffHook);

phoneCall.Configure(State.OffHook)
    .Permit(Trigger.CallDialled, State.Ringing);

phoneCall.Configure(State.Connected)
    .OnEntry(t => StartCallTimer())
    .OnExit(t => StopCallTimer())
    .InternalTransition(Trigger.MuteMicrophone, t => OnMute())
    .InternalTransition(Trigger.UnmuteMicrophone, t => OnUnmute())
    .InternalTransition<int>(_setVolumeTrigger, (volume, t) => OnSetVolume(volume))
    .Permit(Trigger.LeftMessage, State.OffHook)
    .Permit(Trigger.PlacedOnHold, State.OnHold);

// ...

phoneCall.Fire(Trigger.CallDialled);

You’ll notice that the StateMachine has two generic arguments: one for the state and the other for the trigger. These types can be any .NET type, but they’re enum types in this sample.

Let’s build something closer to how I recommend using Stateless.

The Light Switch State Machine Example

This example shows a Widget class that always has a deterministic state of being On or Off. The trigger for our Widget is to Press. While we could implement the state machine as we did previously, I recommend encapsulating state machines within a class.

public class Widget
{
    private readonly StateMachine<State, Trigger> workflow = new(State.Off);
    public string Name { get; }

    enum State
    {
        On,
        Off
    }
    enum Trigger
    {
        Press
    }

    public Widget(string name)
    {
        Name = name;
        
        workflow.Configure(State.On)
            .Permit(Trigger.Press, State.Off)
            .OnEntry(() => Console.WriteLine($"🟢 {Name} is {workflow.State}"));
        
        workflow.Configure(State.Off)
            .Permit(Trigger.Press, State.On)
            .OnEntry(() => Console.WriteLine($"🔴 {Name} is {workflow.State}"));
    }

    public void Toggle()
    {
        workflow.Fire(Trigger.Press);
    }
}

Why? It’s much easier to consume these class instances and have the machine’s state exposed through properties and interactions to exist as a method call. In the “real world,” we typically don’t understand the internal behavior of the abstractions we interact with; we only observe the outcome of our interactions.

In the case of this implementation, Stateless is managing the state of our Widget, and we know it will be consistent with the behavior we define in the constructor.

var widget = new Widget("Lightbulb 💡");

Console.WriteLine($"Press any key to toggle {widget.Name}");
while (true) {
    _ = Console.ReadKey();
    widget.Toggle();
}

Let’s see it in action!

Press any key to toggle Lightbulb 💡
🟢 Lightbulb 💡 is On
🔴 Lightbulb 💡 is Off
🟢 Lightbulb 💡 is On
🔴 Lightbulb 💡 is Off
🟢 Lightbulb 💡 is On

This is a neat technique, but admittedly simple. Let’s go to space next.

Space Travel with State Machines

A challenging concept when working with state machines is managing the state. Luckily, with Stateless, extracting, storing, and rehydrating state is straightforward.

Let’s create a state machine to travel through our solar system.

public class SpaceTravel
{
    public record Planet(String Name, int DistanceFromSunInMillionsOfMiles)
    {
        public static readonly Planet Sun = new(nameof(Sun), 0);
        public static readonly Planet Mercury = new(nameof(Mercury), 36);
        public static readonly Planet Venus = new(nameof(Venus), 67);
        public static readonly Planet Earth = new(nameof(Earth), 93);
        public static readonly Planet Mars = new(nameof(Mars), 142);
        public static readonly Planet Jupiter = new(nameof(Jupiter), 484);
        public static readonly Planet Saturn = new(nameof(Saturn), 886);
        public static readonly Planet Uranus = new(nameof(Uranus), 1784);
        public static readonly Planet Neptune = new(nameof(Neptune), 2793);
        public static readonly Planet Pluto = new(nameof(Pluto), 3670);
        
        public static readonly List<Planet> All = [
            Sun, Mercury, Venus,
            Earth, Mars, Jupiter,
            Saturn, Uranus, Neptune,
            Pluto
        ];

        public override string ToString()
        {
            return $"{Name}";
        }
    }

    enum Actions
    {
        In,
        Out
    }

    private readonly StateMachine<Planet, Actions> machine;
    
    public SpaceTravel(Planet? start = null)
    {
        start ??= Planet.Earth;

        if (!Planet.All.Contains(start))
        {
            throw new Exception("Starting planet must be in our solar system");
        }

        machine = new(start);
        
        machine.OnTransitionCompleted((transition =>
        {
            var (source, destination, direction) = transition;
            var distance = Math.Abs(source.DistanceFromSunInMillionsOfMiles - destination.DistanceFromSunInMillionsOfMiles);
            var dir = direction == Actions.In ? "⬇" : "⬆";
            Console.WriteLine($"{dir} 🚀 You traveled {distance} million miles from {source.Name} to {destination.Name}.");
        }));
        
        machine.Configure(Planet.Sun).Ignore(Actions.In).Permit(Actions.Out, Planet.Mercury);
        machine.Configure(Planet.Mercury).Permit(Actions.In, Planet.Sun).Permit(Actions.Out, Planet.Venus);
        machine.Configure(Planet.Venus).Permit(Actions.In, Planet.Mercury).Permit(Actions.Out, Planet.Earth);
        machine.Configure(Planet.Earth).Permit(Actions.In, Planet.Venus).Permit(Actions.Out, Planet.Mars);
        machine.Configure(Planet.Mars).Permit(Actions.In, Planet.Earth).Permit(Actions.Out, Planet.Jupiter);
        machine.Configure(Planet.Jupiter).Permit(Actions.In, Planet.Mars).Permit(Actions.Out, Planet.Saturn);
        machine.Configure(Planet.Saturn).Permit(Actions.In, Planet.Jupiter).Permit(Actions.Out, Planet.Uranus);
        machine.Configure(Planet.Uranus).Permit(Actions.In, Planet.Saturn).Permit(Actions.Out, Planet.Neptune);
        machine.Configure(Planet.Neptune).Permit(Actions.In, Planet.Uranus).Permit(Actions.Out, Planet.Pluto);
        machine.Configure(Planet.Pluto).Ignore(Actions.Out).Permit(Actions.In, Planet.Neptune);
    }

    public Planet Current => machine.State;

    public void In()
    {
        machine.Fire(Actions.In);
    }

    public void Out()
    {
        machine.Fire(Actions.Out);
    }
}

internal static class StateMachineExtensions {
    public static void Deconstruct<TState, TTrigger>(this StateMachine<TState, TTrigger>.Transition transition,
        out TState source, out TState destination, out TTrigger direction)
    {
        source = transition.Source;
        destination = transition.Destination;
        direction = transition.Trigger;
    }
}

We’ve done a few things with this state machine that are more complex than our Widget example.

  1. The initial state of our StateMachine can be set at instantiation time through the constructor.
  2. There are two triggers of In and Out, both of which can be fired through matching methods.
  3. The constructor defines how travel can occur from one planet to another, with the Sun and Pluto being re-entry nodes. (You can’t leave the solar system).
  4. Each transition calculates the distance between two bodies, regardless of the state change.

Let’s do some space travel!

var system = new SpaceTravel(SpaceTravel.Planet.Earth);
Console.WriteLine("🔭 Use O(ut) or ⬆ and I(n) and ⬇ keys to move through our solar system.");

while (true)
{
    var key = Console.ReadKey(true);
    switch (key)
    {
        case { Key: ConsoleKey.O or ConsoleKey.UpArrow }:
            system.Out();
            break;
        case { Key: ConsoleKey.I or ConsoleKey.DownArrow }:
            system.In();
            break;
        default:
            var miles = system.Current.DistanceFromSunInMillionsOfMiles;
            Console.WriteLine($"🛰️ Currently at {system.Current.Name} ({miles} million miles from the ☀️).");
            break;
    }
}

Running the application, we get the sample output.

🔭 Use O(ut) or ⬆ and I(n) and ⬇ keys to move through our solar system.
⬆ 🚀 You traveled 49 million miles from Earth to Mars.
⬆ 🚀 You traveled 342 million miles from Mars to Jupiter.
⬆ 🚀 You traveled 402 million miles from Jupiter to Saturn.
⬆ 🚀 You traveled 898 million miles from Saturn to Uranus.
⬆ 🚀 You traveled 1009 million miles from Uranus to Neptune.
⬆ 🚀 You traveled 877 million miles from Neptune to Pluto.
🛰️ Currently at Pluto (3670 million miles from the ☀️).

Neat! What about seeing our travel path?

Graphing your State Machines

At any point in time, you can use the UmlDotGraph static class in the Stateless library to output the graph to a string value.

var graph = UmlDotGraph.Format(workflow.GetInfo());

Our light switch sample produces the following DotGraph that can be turned into an SVG.

digraph {
compound=true;
node [shape=Mrecord]
rankdir="LR"
"On" [label="On|entry / Function"];
"Off" [label="Off|entry / Function"];

"On" -> "Off" [style="solid", label="Press"];
"Off" -> "On" [style="solid", label="Press"];
 init [label="", shape=point];
 init -> "Off"[style = "solid"]
}

These visualizations can be helpful for documentation or diagnosing issues when exceptions occur in the state machine.

Conclusion

Stateless is a very cool library worth checking out. It can add the necessary structure to otherwise complex workflows. I highly recommend encapsulating the StateMachine class within a container class; otherwise, you’ll be dealing with some unwieldy APIs.

A slew of APIs not covered in this post can help you determine if an action can be executed, what next steps are permitted, and error handling for unhandled triggers. It is a well-thought-out library, and I hope you try it.

As always, thanks for reading and sharing my posts. Cheers.