DEV Community

Cover image for Finite State Machines - the Universal Unit of Work
Trent Best
Trent Best

Posted on

Finite State Machines - the Universal Unit of Work

We've all been there: a simple boolean flag here, a nested if statement there. It seems harmless at first, but soon your codebase is a tangled mess of conditional logic. Your Door class can be opened and closed. Easy, right?

But in the wild, the simple, intuitive code you first wrote for a door quickly starts to smell. The moment we try to add a new feature, such as a lock, our elegant two-state object becomes a mess of conditional logic.

Let's look at what our intuitive code looks like to begin with:

public class Door
{
    public bool IsOpen { get; set; } = false;

    public void Open()
    {
        IsOpen = true;
        Console.WriteLine("The door is now open.");
    }

    public void Close()
    {
        IsOpen = false;
        Console.WriteLine("The door is now closed.");
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, imagine we need to add a "locked" state. To accommodate this, our code is no longer simple. We have to add a new boolean flag, IsLocked, and then we have to modify all of our existing methods to account for this new state. We also have to add new methods, such as Lock and Unlock.

public class Door
{
    public bool IsOpen { get; set; } = false;
    public bool IsLocked = false; // We added this!

    public void Open()
    {
        if (!IsLocked) // We modified this!
        {
            IsOpen = true;
            Console.WriteLine("The door is now open.");
        }
        else
        {
            Console.WriteLine("The door is locked and cannot be opened.");
        }
    }

    public void Close()
    {
        IsOpen = false;
        Console.WriteLine("The door is now closed.");
    }

    public void Lock() // We added this!
    {
        IsLocked = true;
        Console.WriteLine("The door is now locked.");
    }

    public void Unlock() // We added this!
    {
        IsLocked = false;
        Console.WriteLine("The door is now unlocked.");
    }
}
Enter fullscreen mode Exit fullscreen mode

What about a Broken state? Or a Sliding door? Or a RollUp door? We can approximate these with more booleans, but each new addition requires us to go back and add more and more if/else statements to every method. It's not scalable, it's brittle, and it's full of boilerplate.

Here are the pros and cons of using this approach:

Pros Cons
Simple and quick to implement for basic cases. Tight Coupling: Logic is tied to a single object.
No external dependencies or abstractions. Boilerplate: if/else checks for every state change.
Difficult to Scale: Adding new features breaks existing logic.
Difficult to maintain: Code becomes a tangled mess.

The Typical FSM Solution and Its Pitfalls

A typical FSM approach would have you create distinct classes for each state (e.g., DoorOpenState, DoorClosedState, DoorLockedState) that all implement a common IState interface. The Door class would then hold a reference to its current state object. This is what you'll see in most tutorials and books.

public interface IState<T>
{
    void Enter(T context);
    void Update(T context);
    void Exit(T context);
}

public class DoorContext
{
    public bool IsOpen { get; set; } = false;
}

public class DoorClosedState : IState<DoorContext>
{
    public void Enter(DoorContext context)
    {
        context.IsOpen = false;
        Console.WriteLine("The door is now closed.");
    }

    public void Update(DoorContext context) { }
    public void Exit(DoorContext context) { }
}

public class DoorOpenState : IState<DoorContext>
{
    public void Enter(DoorContext context)
    {
        context.IsOpen = true;
        Console.WriteLine("The door is now open.");
    }

    public void Update(DoorContext context) { }
    public void Exit(DoorContext context) { }
}
Enter fullscreen mode Exit fullscreen mode

Now, what does it take to add a "locked" state to this? It's not as simple as adding a boolean. We need to create a new state class, DoorLockedState, and then modify our existing states and the context to allow for transitions to this new state. This still requires a fair amount of boilerplate and modification of existing code.

Here's an overview of the pros and cons of this and other FSM implementations:

Pros Cons
Clean State Separation: Logic for each state is in a distinct class. Boilerplate: Requires a lot of code to set up states, transitions, and the state machine itself.
Enforced Transitions: The model naturally enforces valid transitions. Framework-Dependent: Often tightly coupled to a specific game engine or framework.
Well-Established Pattern: A common and well-understood design pattern. Rigid Design: FSM definition is static and difficult to modify at runtime.
Hierarchical FSMs (HFSMs): Allows for nested states, which can reduce complexity. Hierarchical FSMs (HFSMs): Can introduce complexity and make the state machine harder to understand and debug.
Behavior Trees: A different approach that uses a tree structure to define behavior. Behavior Trees: Can be overkill for simple state management and can be difficult to reason about the overall state of the system.
Hybrid Approaches: Combining FSMs with other patterns like Behavior Trees. Hybrid Approaches: Can be overly complex and lead to "spaghetti code" if not carefully implemented.
State Pattern: Allows a context object to change its behavior depending on its internal state. State Pattern: Can lead to a large number of classes for each state, which can be difficult to manage. Changes to the state machine often require changing multiple classes.

A Better Way: FSM_API and Centralized State

The FSM_API is a new approach to FSMs that is designed to be blazing-fast, software-agnostic, and fully decoupled from your application's logic. It's a declarative, data-driven approach that allows you to define your FSM once and use it everywhere.

The key concept is that the FSM_API operates on Plain Old C# Objects (POCOs) that simply implement the IStateContext interface. This ensures a clean separation between your FSM logic and your application's data.

Here's our door, refactored with the FSM_API.

1. Define the Context

Our Door class is now a simple POCO. All its logic is handled by the FSMs we are about to create.

public class Door : IStateContext
{
    public bool IsOpen { get; set; } = false;
    public bool IsLocked = false;
    public string Name { get; set; } = "FrontDoor";
    public bool IsValid => true; // FSM API will not operate on this if false.
}
Enter fullscreen mode Exit fullscreen mode

2. Multiple FSMs on a Single Context

The true power of FSM_API is its ability to manage multiple, independent behaviors on a single context object. This allows us to separate our "open/close" logic from our "lock/unlock" logic.

First, let's create a simple FSM to handle the door's opening and closing. This is the DoorFSM:

FSM_API.Create.CreateFiniteStateMachine("DoorFSM")
    .State("Closed", onEnter: (ctx) => { ((Door)ctx).IsOpen = false; })
    .State("Open", onEnter: (ctx) => { ((Door)ctx).IsOpen = true; })
    .WithInitialState("Closed")
    .Transition("Closed", "Open", (ctx) => !((Door)ctx).IsLocked)
    .Transition("Open", "Closed")
    .Transition("Closed", "Locked")
    .Transition("Locked", "Closed", (ctx) => !((Door)ctx).IsLocked)
    .Transition("Unlocked", "Closed")
    .BuildDefinition();
Enter fullscreen mode Exit fullscreen mode

Because the FSM_API allows for runtime redefinition of FSMs, we can dynamically add states and transitions to our door object without recompiling. This capability is what makes the FSM_API a powerful choice for highly configurable or live-updating systems.

Here's an overview of the pros and cons of our API:

Pros Cons
Centralized State: FSM logic is separated from data. Learning Curve: New concepts might take time to grasp, especially if you are used to traditional FSM implementations.
Extremely Lightweight: The core FSM_API.dll is a tiny 45kb, and the full NuGet package is only 417.09kb. Minimalism: The API is minimal by design; you will need to rely on the provided documentation and quick-start guides to get started.
Runtime Redefinition: FSMs can be modified on the fly.
Lightweight & Performant: Minimal memory allocations and optimized performance.
Framework Agnostic: No external dependencies or frameworks required.
Thread-Safe: Designed for safe, deferred mutation handling.
Error-Tolerant: Built-in diagnostics prevent runaway logic.

This is just a glimpse of what's possible with the FSM_API. Its features make it a robust choice for any C# application requiring sophisticated state management.

If you'd like to get started, you can find our NuGet package and all the documentation on our GitHub repository:

If you feel this tool is valuable to you, please consider supporting the project. Donate via PayPal

Top comments (0)