<< Back to all Blogs
Your code already has state, you just aren't managing it

Your code already has state, you just aren't managing it

Richard Bowden

Programming is fundamentally about moving data around and tracking state. Every variable assignment, every database record, every user interaction changes state. Your application is always in some state, and every action transitions it to another state.

Most developers handle state implicitly. We write conditionals, check booleans, and hope we've covered all the edge cases. This works until it doesn't. Then you get bugs such as users completing checkout without payment, or workflows skipping required approval steps.

The state machine is the formal model of this reality. It defines what states exist, which transitions are valid, and which are impossible.

Formally, this concept is called a Finite State Automaton (FSA). You'll see that term more in formal language theory and compiler design, but it means the same thing as "finite state machine." There are two kinds.

TermDefinition
Deterministic Finite Automaton (DFA)Each state-event pair has exactly one outcome. Given the same state and the same event, you always end up in the same place.
Non-deterministic Finite Automaton (NFA)The same event from the same state could lead to multiple possible states.

We're building a DFA. One event, one state, one outcome, always.

State is everywhere

Consider the software you use daily. Your email has states: draft, sent, read, archived. Your pull requests move through: open, review requested, approved or merged. Video games track player states: idle, walking, jumping or dead. Your smart home devices are: off, on, dimming or away mode.

These aren't just labels. Each state allows specific actions, and prohibits others. You can't merge an unapproved pull request. You can't jump while already jumping. You can't withdraw from a bank account that's already overdrawn.

The problem with informal state

Here's typical state management code:

func ProcessOrder(order *Order) error {
    if order.Status == "pending" {
        order.Status = "processing"
        // Start processing
    }
    // Later...
    order.Status = "shipped"  // Whoops, did we actually ship it?
    return nil
}

Nothing stops you from setting order.Status = "complete" on a pending order. Nothing prevents skipping the processing step, nothing documents what transitions are valid; you're relying entirely on developer discipline and code review to catch these bugs.

This is like using strings for everything instead of types. It works, but you've thrown away the compiler's ability to help you.

State machines encode the rules

A state machine makes the rules explicit:

sm := NewStateMachine[OrderState, OrderEvent]()

sm.AddTransitions([]Transition[OrderState, OrderEvent]{
    {OrderStatePending, OrderEventConfirm, OrderStateProcessing},
    {OrderStateProcessing, OrderEventShip, OrderStateShipped},
    {OrderStateShipped, OrderEventDeliver, OrderStateDelivered},
})

Now try to skip a step:

newState, err := sm.Transition(OrderStatePending, OrderEventShip)
// Error: invalid transition

The state machine prevents invalid transitions at runtime so your rules are encoded in data structures and not scattered across if statements. You've moved from implicit state management to explicit state management.

Beyond truth tables

You might think of this as a truth table where inputs map to outputs. But state machines are different in a few crucial ways:

  1. Current state of an object or process (this is the primary mechanism)
  2. The current state is chosen precisely because it encapsulates the relevant history of inputs that led there (limited to the last state change)

State machines can be more advanced over and above tracking single state transitions, they can store additional meta data or basic history of transitions. Why store history? Well, storing x number of state transitions enables a primitive undo system or forms the core of a transactional system (which guarantees the state of the system is always known, can also provided undo or the ability to replay system state like a video timeline) which are common place in block chains, ecommerce and truly distributed microservice applications (not a distributed monolith). Databases are a form of state machines, but that along with undo and transaction systems are a topic for another day.

A truth table is pure function. Given inputs A and B, you always get the same output. State machines are stateful. The same event produces different results depending on current state:

sm.Transition(Pending, Ship)  // Error: can't ship pending orders
sm.Transition(Processing, Ship) // Success: ships the order

The Ship event has two different outcomes for the same input. This is what makes state machines more powerful than truth tables they encode not just logic, but also valid sequences of operations.

Think of them as directed graphs where nodes are states and edges are allowed transitions.

A truth table is a lookup. A state machine is a map of valid paths through your system.

How to build a state machine

The core data structure is simple a map of maps. For each state, store which events lead to which new states:

type StateMachine[S State, E Event] struct {
    transitions map[S]map[E]S
}

This gives you O(1) lookups for "can I transition from state X via event Y?" The implementation is roughly 150 lines of Go with generics, including validation and path checking.

You define your domain types:

type UserState string
const (
    UserStateInitial     UserState = "Initial"
    UserStateVerified    UserState = "Verified"
    UserStateComplete    UserState = "Complete"
)

type UserEvent string
const (
    UserEventVerify   UserEvent = "Verify"
    UserEventComplete UserEvent = "Complete"
)

Configure the machine:

sm := NewStateMachine[UserState, UserEvent]()
sm.AddTransition(UserStateInitial, UserEventVerify, UserStateVerified)
sm.AddTransition(UserStateVerified, UserEventComplete, UserStateComplete)

Use it:

newState, err := sm.Transition(user.State, UserEventComplete)
if err != nil {
    return fmt.Errorf("invalid operation: %w", err)
}
user.State = newState

That's it. Your state transitions are now validated, documented and testable.

What happens once you've built a state machine

Once you've encoded your state machine, several things become trivial.

  • You can't make invalid transitions. The machine enforces your rules.
  • New developers can read the transition rules and understand your workflow. The code is the diagram.
  • Test valid paths succeed. Test invalid paths fail. Test that terminal states are actually terminal.
  • Want to add a new state? Add it to the machine. The compiler will tell you everywhere you need to handle it.
  • Store state transitions in your database and you have a complete audit trail of how an entity progressed through your system.

When to use state machines

Use state machines when you have distinct phases with rules about progression such as user onboarding flows, order processing, document approval, job scheduling or connection lifecycle management.

Don't use them for simple flags. If your state is just "active or inactive," a boolean is fine. If you have three states with no transition rules, an enum is enough.

The sweet spot is 3 to 10 states with meaningful transition rules. They're complex enough that implicit management causes bugs but simple enough that you don't need a full workflow engine.

Finding state machines in your process

State machines often emerge naturally from process mapping exercises. When your team does example mapping to understand business requirements, you're essentially discovering states and transitions. Each example represents a path through the state machine. When you're mapping value streams to identify bottlenecks and handoffs, those handoff points are state transitions.

A value stream map shows work moving between stages waiting for approval, in review and being processed. Each stage is a state. Each handoff is a transition that requires specific conditions to be met. The state machine is the formal model of that flow so if your process map shows that documents can skip the review stage under certain conditions, your state machine should encode exactly when that's allowed and when it's not.

This is why state machines feel natural once you build one. You're not inventing new constraints but you're making explicit the rules that already exist in your business process.

What is the cost of an informal state?

Every production bug I've investigated in state heavy systems came down to invalid state transitions that were technically possible but semantically wrong. A payment processed twice. A document published without approval. A user who bypassed verification.

These bugs are subtle: they pass code review, work in testing, break in production under edge cases you didn't anticipate. State machines eliminate this entire class of bugs by making the implicit explicit encoding what everyone already knows into something the computer can enforce.

Once configured, they're boring. They do their job silently, preventing the impossible and allowing the valid. That's exactly what you want from infrastructure code.

Mechanical Rock specialises in software architecture and product development. We have embraced Go for product development, so if this piques your interest do not hesitate to get in touch.