DEV Community

Cover image for Finite State Machines: A Complete Guide from Fundamentals to Production
Abanoub Kerols
Abanoub Kerols

Posted on

Finite State Machines: A Complete Guide from Fundamentals to Production

Finite State Machines (FSMs) are one of the most useful concepts in computer science and software engineering.

They provide a structured way to model systems that behave differently depending on their current state and the events they receive.

You can find state machines everywhere:

  • Authentication systems
  • Payment processing
  • Order management
  • HTTP requests
  • WebSocket connections
  • Retry mechanisms
  • Background jobs
  • Video players
  • Game engines
  • Traffic lights
  • Elevators
  • Vending machines
  • Embedded systems
  • UI components
  • Distributed systems
  • Event-driven architectures

Despite being a relatively simple concept, State Machines become extremely powerful when used to model complex business workflows.

This article starts from the fundamentals and gradually moves toward advanced concepts, implementation techniques, software architecture, and finally a production-oriented Node.js + TypeScript project.


Table of Contents

  1. Part 1 — Fundamentals
  2. Part 2 — Finite State Machines
  3. Part 3 — FSM Design
  4. Part 4 — FSM Implementation
  5. Part 5 — Advanced FSM
  6. Part 6 — Real-World Applications
  7. Part 7 — FSM + Modern Software Architecture
  8. Part 8 — Production Project
  9. Conclusion

Part 1 — Fundamentals

What Is a State Machine?

A State Machine is a mathematical and software model used to represent a system that can exist in different states and change between those states when events occur.

The basic idea is:

Current State
      +
    Event
      |
      v
  Transition
      |
      v
   New State
Enter fullscreen mode Exit fullscreen mode

For example, imagine an online order.

An order might have the following states:

PENDING
PAID
PROCESSING
SHIPPED
DELIVERED
CANCELLED
Enter fullscreen mode Exit fullscreen mode

The order changes state when events occur.

For example:

PENDING + PAY
        ↓
       PAID

PAID + START_PROCESSING
        ↓
    PROCESSING

PROCESSING + SHIP
        ↓
     SHIPPED

SHIPPED + DELIVER
        ↓
    DELIVERED
Enter fullscreen mode Exit fullscreen mode

The important idea is that the system's behavior depends on its current state.


Why Do We Need State Machines?

Without a State Machine, developers often implement workflows using large collections of:

if
else if
else
Enter fullscreen mode Exit fullscreen mode

For example:

if (order.status === "pending") {
    if (event === "pay") {
        ...
    }
}

if (order.status === "paid") {
    if (event === "ship") {
        ...
    }
}
Enter fullscreen mode Exit fullscreen mode

As the system grows, this can become difficult to understand.

Eventually we may end up with:

if
 ├── if
 │    ├── if
 │    └── if
 ├── else if
 │    ├── if
 │    └── if
 └── else if
      ├── if
      └── if
Enter fullscreen mode Exit fullscreen mode

State Machines provide explicit structure.

Instead of asking:

"What should this giant function do?"

we ask:

"What states exist, what events can occur, and which transitions are allowed?"

This makes the system easier to:

  • Understand
  • Test
  • Debug
  • Maintain
  • Document
  • Extend
  • Validate

The Concept of State

A State represents the current condition of a system.

For example:

Order:
PENDING
Enter fullscreen mode Exit fullscreen mode

means the order has been created but has not been paid.

Another state:

PAID
Enter fullscreen mode Exit fullscreen mode

means payment has successfully completed.

Another:

SHIPPED
Enter fullscreen mode Exit fullscreen mode

means the order has been handed over to the shipping process.

A state is therefore a snapshot of the system's current mode of behavior.


The Concept of Event / Input

An Event is something that happens and may cause a state transition.

Examples:

PAY
CANCEL
SHIP
DELIVER
TIMEOUT
RETRY
LOGIN
LOGOUT
DISCONNECT
CONNECT
Enter fullscreen mode Exit fullscreen mode

For example:

State: PENDING

Event: PAY
Enter fullscreen mode Exit fullscreen mode

The machine may transition to:

PAID
Enter fullscreen mode Exit fullscreen mode

An event can come from many sources:

  • HTTP requests
  • User actions
  • Database events
  • Message queues
  • Timers
  • WebSocket messages
  • External services
  • Internal application logic

The Concept of Transition

A Transition describes how the machine moves from one state to another.

Conceptually:

FROM STATE
     |
   EVENT
     |
     v
TO STATE
Enter fullscreen mode Exit fullscreen mode

Example:

PENDING --PAY--> PAID
Enter fullscreen mode Exit fullscreen mode

Another example:

PAID --START_PROCESSING--> PROCESSING
Enter fullscreen mode Exit fullscreen mode

A transition can also have conditions and actions.

PENDING
   |
   | PAY
   | [payment successful]
   v
PAID
Enter fullscreen mode Exit fullscreen mode

The Concept of Action

An Action is something the system performs when a transition occurs.

For example:

PENDING --PAY--> PAID
Enter fullscreen mode Exit fullscreen mode

The transition may execute:

sendPaymentConfirmation();
Enter fullscreen mode Exit fullscreen mode

or:

createInvoice();
Enter fullscreen mode Exit fullscreen mode

or:

publishOrderPaidEvent();
Enter fullscreen mode Exit fullscreen mode

Actions are usually side effects.

Examples:

  • Send an email
  • Write to a database
  • Publish an event
  • Call an API
  • Create a log
  • Start a job

A useful distinction is:

State       = Where am I?
Event       = What happened?
Transition  = Where can I go?
Guard       = Am I allowed to go there?
Action      = What should I do?
Enter fullscreen mode Exit fullscreen mode

State Diagram

A State Diagram visually represents the State Machine.

For example:

             PAY
   +------------------------+
   |                        v
+---------+             +--------+
| PENDING | ------------> | PAID |
+---------+             +--------+
    |                       |
    | CANCEL                | SHIP
    v                       v
+-----------+          +----------+
| CANCELLED |          |  SHIPPED |
+-----------+          +----------+
                             |
                             | DELIVER
                             v
                        +-----------+
                        | DELIVERED |
                        +-----------+
Enter fullscreen mode Exit fullscreen mode

A state diagram makes the workflow much easier to understand.


A Simple Practical Example

Let's build a simple light switch.

The light can have two states:

OFF
ON
Enter fullscreen mode Exit fullscreen mode

Events:

TURN_ON
TURN_OFF
Enter fullscreen mode Exit fullscreen mode

Transitions:

OFF --TURN_ON--> ON
ON  --TURN_OFF--> OFF
Enter fullscreen mode Exit fullscreen mode

Diagram:

       TURN_ON
 OFF ------------> ON
  ^                 |
  |                 |
  +--- TURN_OFF ----+
Enter fullscreen mode Exit fullscreen mode

Implementation:

let state = "OFF";

function dispatch(event) {
    if (state === "OFF" && event === "TURN_ON") {
        state = "ON";
        return;
    }

    if (state === "ON" && event === "TURN_OFF") {
        state = "OFF";
        return;
    }

    throw new Error(`Invalid transition: ${state} + ${event}`);
}
Enter fullscreen mode Exit fullscreen mode

Usage:

dispatch("TURN_ON");

console.log(state);
// ON

dispatch("TURN_OFF");

console.log(state);
// OFF
Enter fullscreen mode Exit fullscreen mode

Even this tiny example demonstrates the fundamental concept.


Part 2 — Finite State Machines

Definition of a Finite State Machine

A Finite State Machine is a mathematical model consisting of a finite number of states and rules describing how the machine moves between those states based on input symbols.

A classical FSM can be represented as:

FSM = (Q, Σ, δ, q₀, F)
Enter fullscreen mode Exit fullscreen mode

Where:

Q  = Set of states
Σ  = Set of input symbols
δ  = Transition function
q₀ = Initial state
F  = Set of accepting/final states
Enter fullscreen mode Exit fullscreen mode

Let's understand each component.


Components of an FSM

Consider:

States:
PENDING
PAID
SHIPPED
DELIVERED
Enter fullscreen mode Exit fullscreen mode

Then:

Q = {
    PENDING,
    PAID,
    SHIPPED,
    DELIVERED
}
Enter fullscreen mode Exit fullscreen mode

Events form the input alphabet:

Σ = {
    PAY,
    SHIP,
    DELIVER
}
Enter fullscreen mode Exit fullscreen mode

The transition function determines where the machine goes.

For example:

δ(PENDING, PAY) = PAID
δ(PAID, SHIP) = SHIPPED
δ(SHIPPED, DELIVER) = DELIVERED
Enter fullscreen mode Exit fullscreen mode

The initial state is:

q₀ = PENDING
Enter fullscreen mode Exit fullscreen mode

Accepting states might be:

F = {
    DELIVERED
}
Enter fullscreen mode Exit fullscreen mode

Transition Function

The transition function is one of the most important concepts in FSMs.

It can be expressed as:

δ(currentState, event) = nextState
Enter fullscreen mode Exit fullscreen mode

Example:

δ(PENDING, PAY) = PAID
Enter fullscreen mode Exit fullscreen mode

Meaning:

If the current state is PENDING and the PAY event occurs, transition to PAID.

Another:

δ(PAID, SHIP) = SHIPPED
Enter fullscreen mode Exit fullscreen mode

Initial State

The Initial State is where the machine starts.

For an order:

PENDING
Enter fullscreen mode Exit fullscreen mode

For authentication:

LOGGED_OUT
Enter fullscreen mode Exit fullscreen mode

For a WebSocket:

DISCONNECTED
Enter fullscreen mode Exit fullscreen mode

For a payment:

CREATED
Enter fullscreen mode Exit fullscreen mode

The initial state is usually represented using an arrow:

        +---------+
        | PENDING |
        +---------+
             ^
             |
           START
Enter fullscreen mode Exit fullscreen mode

Final / Accepting States

A final state represents a state where the process can be considered complete.

For example:

ORDER_CREATED
     |
     v
PAID
     |
     v
SHIPPED
     |
     v
DELIVERED
Enter fullscreen mode Exit fullscreen mode

Here:

DELIVERED
Enter fullscreen mode Exit fullscreen mode

could be an accepting state.

However, in software engineering, not every FSM necessarily needs a final state.

For example, a WebSocket connection can continuously move between:

CONNECTED
DISCONNECTED
RECONNECTING
Enter fullscreen mode Exit fullscreen mode

without ever reaching a permanent final state.


Deterministic Finite Automaton — DFA

A Deterministic Finite Automaton has exactly one possible transition for a given:

State + Input
Enter fullscreen mode Exit fullscreen mode

That means:

δ(state, input) = exactly one state
Enter fullscreen mode Exit fullscreen mode

For example:

PENDING + PAY → PAID
Enter fullscreen mode Exit fullscreen mode

There cannot be two different outcomes for the same input.

This makes DFA behavior predictable.


Non-Deterministic Finite Automaton — NFA

A Non-Deterministic Finite Automaton can have multiple possible transitions for the same state and input.

Conceptually:

δ(state, input) → {state1, state2, ...}
Enter fullscreen mode Exit fullscreen mode

For example:

A + X
 ├──> B
 └──> C
Enter fullscreen mode Exit fullscreen mode

NFAs are particularly important in theoretical computer science, formal languages, regular expressions, and compiler design.

In normal application development, developers usually implement deterministic state machines because business workflows typically require predictable behavior.


DFA vs NFA

Feature DFA NFA
One transition per input Yes Not necessarily
Multiple possible states No Yes
Deterministic Yes No
Easier to execute Yes Usually more complex
Common in business applications Yes Less common
Used in theoretical CS Yes Yes

An important theoretical fact is that DFA and NFA have equivalent expressive power for regular languages.

Every NFA can be converted into an equivalent DFA.


Moore Machine

A Moore Machine is a finite-state machine where the output depends primarily on the current state.

Output = f(State)
Enter fullscreen mode Exit fullscreen mode

For example:

State: RED
Output: STOP
Enter fullscreen mode Exit fullscreen mode
State: GREEN
Output: GO
Enter fullscreen mode Exit fullscreen mode

Diagram:

RED
 |
 | TIMER
 v
GREEN
 |
 | TIMER
 v
YELLOW
Enter fullscreen mode Exit fullscreen mode

The output is associated with the state.


Mealy Machine

A Mealy Machine produces output based on both:

State + Input
Enter fullscreen mode Exit fullscreen mode

Formally:

Output = f(State, Input)
Enter fullscreen mode Exit fullscreen mode

For example:

State: LOCKED
Input: VALID_PASSWORD
Output: UNLOCK
Enter fullscreen mode Exit fullscreen mode

This differs from a Moore Machine where the output is associated with the state itself.


Moore vs Mealy

Feature Moore Mealy
Output depends on State State + Input
Output associated with State Transition
Response timing Often state change Can react immediately
Complexity Often simpler Often more compact

In real-world software, you may encounter patterns inspired by both models.


Part 3 — FSM Design

How to Design an FSM from Requirements

A common mistake is to start coding immediately.

Instead, start with requirements.

Suppose we have:

A customer creates an order. The order can be paid, cancelled, shipped, delivered, or refunded.

We can derive the machine systematically.


Step 1 — Extract the States

Look for nouns or lifecycle conditions.

Possible states:

PENDING
PAID
PROCESSING
SHIPPED
DELIVERED
CANCELLED
REFUNDED
Enter fullscreen mode Exit fullscreen mode

Step 2 — Extract Events

Look for actions or things that happen.

PAY
START_PROCESSING
SHIP
DELIVER
CANCEL
REFUND
Enter fullscreen mode Exit fullscreen mode

Step 3 — Design Transitions

Now connect states and events.

PENDING + PAY
    ↓
PAID
Enter fullscreen mode Exit fullscreen mode
PAID + START_PROCESSING
    ↓
PROCESSING
Enter fullscreen mode Exit fullscreen mode
PROCESSING + SHIP
    ↓
SHIPPED
Enter fullscreen mode Exit fullscreen mode
SHIPPED + DELIVER
    ↓
DELIVERED
Enter fullscreen mode Exit fullscreen mode

Step 4 — Add Guards

Sometimes an event alone is not enough.

For example:

PENDING + PAY
Enter fullscreen mode Exit fullscreen mode

does not necessarily mean the order becomes paid.

We may require:

payment.status === "SUCCESS"
Enter fullscreen mode Exit fullscreen mode

Therefore:

PENDING
   |
   | PAY
   | [payment successful]
   v
PAID
Enter fullscreen mode Exit fullscreen mode

The condition is called a Guard.

Example:

if (payment.status === "SUCCESS") {
    transitionTo("PAID");
}
Enter fullscreen mode Exit fullscreen mode

Step 5 — Add Actions

The transition may execute side effects.

PENDING
   |
   | PAY
   |
   +--> updateOrder()
   +--> createInvoice()
   +--> sendEmail()
   +--> publishEvent()
   |
   v
PAID
Enter fullscreen mode Exit fullscreen mode

Invalid Transitions

A State Machine should explicitly define what happens when an invalid event occurs.

For example:

DELIVERED + PAY
Enter fullscreen mode Exit fullscreen mode

does not make sense.

Possible behavior:

throw new Error("Invalid transition");
Enter fullscreen mode Exit fullscreen mode

Or:

return {
    success: false,
    error: "INVALID_TRANSITION"
};
Enter fullscreen mode Exit fullscreen mode

Explicit invalid transitions are important because they prevent impossible states.


State Invariants

A State Invariant is a rule that must always be true while the machine is in a specific state.

For example:

PAID
Enter fullscreen mode Exit fullscreen mode

might require:

paymentId != null
Enter fullscreen mode Exit fullscreen mode

while:

SHIPPED
Enter fullscreen mode Exit fullscreen mode

might require:

trackingNumber != null
Enter fullscreen mode Exit fullscreen mode

Example:

if (state === "SHIPPED") {
    if (!order.trackingNumber) {
        throw new Error(
            "Shipped orders must have a tracking number"
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Invariants are extremely useful for maintaining domain correctness.


State Explosion Problem

One of the biggest problems with State Machines is state explosion.

Imagine a system with:

Authentication
Payment
Shipping
Inventory
Fraud Detection
Notification
Enter fullscreen mode Exit fullscreen mode

If every combination becomes a state, the number of states can become enormous.

For example:

PAYMENT_PENDING
PAYMENT_SUCCESS
PAYMENT_FAILED
PAYMENT_RETRYING

SHIPPING_PENDING
SHIPPING_PROCESSING
SHIPPING_FAILED
...
Enter fullscreen mode Exit fullscreen mode

Combining everything can create hundreds or thousands of states.

This is called the:

State Explosion Problem

Solutions include:

  • Hierarchical State Machines
  • Composite States
  • Parallel States
  • Separating independent state machines
  • Domain decomposition
  • Statecharts
  • Event-driven architecture

Part 4 — FSM Implementation

There are many ways to implement an FSM.


FSM Using if/else

The simplest approach:

function transition(
    state: string,
    event: string
): string {

    if (state === "PENDING" && event === "PAY") {
        return "PAID";
    }

    if (state === "PAID" && event === "SHIP") {
        return "SHIPPED";
    }

    if (state === "SHIPPED" && event === "DELIVER") {
        return "DELIVERED";
    }

    throw new Error("Invalid transition");
}
Enter fullscreen mode Exit fullscreen mode

This works well for very small machines.

However, it becomes difficult to maintain when the machine grows.


FSM Using switch

Another common approach:

function transition(
    state: string,
    event: string
): string {

    switch (state) {

        case "PENDING":
            if (event === "PAY") {
                return "PAID";
            }
            break;

        case "PAID":
            if (event === "SHIP") {
                return "SHIPPED";
            }
            break;

        case "SHIPPED":
            if (event === "DELIVER") {
                return "DELIVERED";
            }
            break;
    }

    throw new Error("Invalid transition");
}
Enter fullscreen mode Exit fullscreen mode

This is clearer than deeply nested conditions.

But it still mixes:

State Definition
Transition Definition
Business Logic
Enter fullscreen mode Exit fullscreen mode

FSM Using Objects

We can represent transitions as data.

const transitions = {
    PENDING: {
        PAY: "PAID",
    },

    PAID: {
        SHIP: "SHIPPED",
    },

    SHIPPED: {
        DELIVER: "DELIVERED",
    }
};
Enter fullscreen mode Exit fullscreen mode

Then:

function transition(
    state: string,
    event: string
) {
    const nextState =
        transitions[state]?.[event];

    if (!nextState) {
        throw new Error(
            `Invalid transition: ${state} + ${event}`
        );
    }

    return nextState;
}
Enter fullscreen mode Exit fullscreen mode

This approach separates configuration from execution.


FSM Using Maps

JavaScript and TypeScript Maps are useful when we want structured keys.

const transitions = new Map<string, Map<string, string>>();

transitions.set(
    "PENDING",
    new Map([
        ["PAY", "PAID"]
    ])
);

transitions.set(
    "PAID",
    new Map([
        ["SHIP", "SHIPPED"]
    ])
);
Enter fullscreen mode Exit fullscreen mode

Then:

function transition(
    state: string,
    event: string
) {
    const nextState =
        transitions.get(state)?.get(event);

    if (!nextState) {
        throw new Error("Invalid transition");
    }

    return nextState;
}
Enter fullscreen mode Exit fullscreen mode

Table-Driven FSM

A Table-Driven FSM represents the machine as a transition table.

Current State Event Next State
PENDING PAY PAID
PAID PROCESS PROCESSING
PROCESSING SHIP SHIPPED
SHIPPED DELIVER DELIVERED
PENDING CANCEL CANCELLED

This approach is particularly useful when the number of transitions is large.

The FSM engine can remain generic while the table contains domain-specific behavior.


Generic FSM

A reusable FSM engine might look like:

type Transition<State, Event> = {
    target: State;
    action?: () => void;
};

class FSM<State extends string, Event extends string> {

    private state: State;

    constructor(
        initialState: State,
        private transitions:
            Map<State, Map<Event, Transition<State, Event>>>
    ) {
        this.state = initialState;
    }

    getState(): State {
        return this.state;
    }

    dispatch(event: Event): void {

        const transition =
            this.transitions
                .get(this.state)
                ?.get(event);

        if (!transition) {
            throw new Error(
                `Invalid transition: ${this.state} + ${event}`
            );
        }

        transition.action?.();

        this.state = transition.target;
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the engine is reusable for different domains.


TypeScript FSM

TypeScript becomes particularly useful because states and events can be represented as union types.

type OrderState =
    | "PENDING"
    | "PAID"
    | "PROCESSING"
    | "SHIPPED"
    | "DELIVERED"
    | "CANCELLED";

type OrderEvent =
    | "PAY"
    | "PROCESS"
    | "SHIP"
    | "DELIVER"
    | "CANCEL";
Enter fullscreen mode Exit fullscreen mode

Now TypeScript can prevent invalid values.

Example:

let state: OrderState = "PENDING";
Enter fullscreen mode Exit fullscreen mode

Java FSM

A Java implementation can use enums.

enum OrderState {
    PENDING,
    PAID,
    PROCESSING,
    SHIPPED,
    DELIVERED,
    CANCELLED
}

enum OrderEvent {
    PAY,
    PROCESS,
    SHIP,
    DELIVER,
    CANCEL
}
Enter fullscreen mode Exit fullscreen mode

Then:

public OrderState transition(
    OrderState state,
    OrderEvent event
) {

    return switch (state) {

        case PENDING -> {
            if (event == OrderEvent.PAY)
                yield OrderState.PAID;

            if (event == OrderEvent.CANCEL)
                yield OrderState.CANCELLED;

            throw new IllegalStateException();
        }

        case PAID -> {
            if (event == OrderEvent.PROCESS)
                yield OrderState.PROCESSING;

            throw new IllegalStateException();
        }

        default ->
            throw new IllegalStateException(
                "Invalid transition"
            );
    };
}
Enter fullscreen mode Exit fullscreen mode

Python FSM

Python can implement a simple FSM using dictionaries.

transitions = {
    "PENDING": {
        "PAY": "PAID",
        "CANCEL": "CANCELLED"
    },

    "PAID": {
        "PROCESS": "PROCESSING"
    },

    "PROCESSING": {
        "SHIP": "SHIPPED"
    },

    "SHIPPED": {
        "DELIVER": "DELIVERED"
    }
}
Enter fullscreen mode Exit fullscreen mode

Then:

def transition(state, event):

    next_state = transitions \
        .get(state, {}) \
        .get(event)

    if next_state is None:
        raise ValueError(
            f"Invalid transition: {state} + {event}"
        )

    return next_state
Enter fullscreen mode Exit fullscreen mode

Part 5 — Advanced FSM

Hierarchical State Machines

A Hierarchical State Machine allows states to contain sub-states.

For example:

AUTHENTICATED
├── ACTIVE
├── IDLE
└── LOCKED
Enter fullscreen mode Exit fullscreen mode

Instead of treating every state independently, we can create a hierarchy.

Example:

USER
 ├── LOGGED_OUT
 └── LOGGED_IN
      ├── ACTIVE
      ├── IDLE
      └── LOCKED
Enter fullscreen mode Exit fullscreen mode

This reduces duplication.


Nested States

Nested states allow one state to contain other states.

For example:

ORDER
 ├── PAYMENT
 │    ├── PENDING
 │    ├── PROCESSING
 │    └── SUCCESS
 │
 └── SHIPPING
      ├── PENDING
      ├── SHIPPED
      └── DELIVERED
Enter fullscreen mode Exit fullscreen mode

This is especially useful for complex workflows.


Composite States

A Composite State is a state that internally contains another state machine.

For example:

CHECKOUT
Enter fullscreen mode Exit fullscreen mode

could contain:

CART
ADDRESS
PAYMENT
CONFIRMATION
Enter fullscreen mode Exit fullscreen mode

So:

CHECKOUT
   |
   +-- CART
   |
   +-- ADDRESS
   |
   +-- PAYMENT
   |
   +-- CONFIRMATION
Enter fullscreen mode Exit fullscreen mode

Parallel States

Sometimes multiple processes need to happen independently.

For example, an order might simultaneously have:

Payment:
SUCCESS

Shipping:
PROCESSING

Notification:
SENDING
Enter fullscreen mode Exit fullscreen mode

Instead of creating:

PAYMENT_SUCCESS_SHIPPING_PROCESSING_NOTIFICATION_SENDING
Enter fullscreen mode Exit fullscreen mode

we can use parallel state machines.

Conceptually:

ORDER
 |
 +---- PAYMENT
 |
 +---- SHIPPING
 |
 +---- NOTIFICATION
Enter fullscreen mode Exit fullscreen mode

Each subsystem has its own state.

This dramatically reduces state explosion.


History States

History states allow a machine to remember its previous state.

Example:

ACTIVE
 ├── BROWSING
 ├── CHECKOUT
 └── PAYMENT
Enter fullscreen mode Exit fullscreen mode

Suppose the user leaves the application while in:

PAYMENT
Enter fullscreen mode Exit fullscreen mode

When they return, the system can restore:

PAYMENT
Enter fullscreen mode Exit fullscreen mode

instead of restarting from:

BROWSING
Enter fullscreen mode Exit fullscreen mode

This concept is useful in:

  • UI workflows
  • Games
  • Long-running processes
  • Distributed workflows

Entry Actions

An Entry Action runs when entering a state.

Example:

ENTER PAID
    ↓
createInvoice()
Enter fullscreen mode Exit fullscreen mode

Conceptually:

const paidState = {
    entry: () => {
        createInvoice();
    }
};
Enter fullscreen mode Exit fullscreen mode

Exit Actions

An Exit Action runs when leaving a state.

LEAVE PROCESSING
       ↓
cleanupProcessing()
Enter fullscreen mode Exit fullscreen mode

Example:

const processingState = {
    exit: () => {
        cleanupProcessing();
    }
};
Enter fullscreen mode Exit fullscreen mode

Guards

Guards are conditions that determine whether a transition is allowed.

Example:

PENDING
   |
   | PAY
   | [amount > 0]
   v
PAID
Enter fullscreen mode Exit fullscreen mode

Implementation:

{
    event: "PAY",
    target: "PAID",
    guard: context => context.amount > 0
}
Enter fullscreen mode Exit fullscreen mode

Guards are useful for:

  • Authorization
  • Validation
  • Payment checks
  • Inventory checks
  • Feature flags
  • Business rules

Internal Transitions

An Internal Transition handles an event without changing state.

Example:

CONNECTED
   |
   | HEARTBEAT
   |
   +----> CONNECTED
Enter fullscreen mode Exit fullscreen mode

The state remains:

CONNECTED
Enter fullscreen mode Exit fullscreen mode

but an action occurs:

heartbeat();
Enter fullscreen mode Exit fullscreen mode

Event Queues

Events do not always need to be processed immediately.

A system may receive:

PAY
SHIP
DELIVER
Enter fullscreen mode Exit fullscreen mode

very quickly.

An event queue can process them sequentially:

Event Queue

PAY
 ↓
SHIP
 ↓
DELIVER
Enter fullscreen mode Exit fullscreen mode

This can help prevent race conditions.


State Persistence

If the application restarts, in-memory state disappears.

For long-running workflows, state may need to be persisted.

For example:

Database

order_id: 123
state: PAYMENT_PENDING
version: 7
Enter fullscreen mode Exit fullscreen mode

When the service restarts:

Database
   ↓
Load state
   ↓
Restore FSM
Enter fullscreen mode Exit fullscreen mode

Persistence is essential for production workflows.


Part 6 — Real-World Applications

Authentication

Authentication is naturally modeled using states.

LOGGED_OUT
     |
     | LOGIN
     v
AUTHENTICATING
     |
     | SUCCESS
     v
AUTHENTICATED
     |
     | LOGOUT
     v
LOGGED_OUT
Enter fullscreen mode Exit fullscreen mode

Additional states:

LOCKED
MFA_REQUIRED
SESSION_EXPIRED
Enter fullscreen mode Exit fullscreen mode

Order Processing

Typical order lifecycle:

CREATED
   ↓
PENDING_PAYMENT
   ↓
PAID
   ↓
PROCESSING
   ↓
SHIPPED
   ↓
DELIVERED
Enter fullscreen mode Exit fullscreen mode

Alternative path:

PENDING_PAYMENT
       |
       | CANCEL
       v
   CANCELLED
Enter fullscreen mode Exit fullscreen mode

Payment Processing

Payment systems commonly have:

CREATED
PENDING
PROCESSING
AUTHORIZED
CAPTURED
FAILED
REFUNDED
Enter fullscreen mode Exit fullscreen mode

Example:

CREATED
   |
   | PROCESS
   v
PROCESSING
   |
   +---- SUCCESS ---> AUTHORIZED
   |
   +---- FAILURE ---> FAILED
Enter fullscreen mode Exit fullscreen mode

E-Commerce Checkout

Checkout can be modeled as:

CART
 ↓
ADDRESS
 ↓
SHIPPING
 ↓
PAYMENT
 ↓
CONFIRMATION
Enter fullscreen mode Exit fullscreen mode

If payment fails:

PAYMENT
   |
   | FAILURE
   v
PAYMENT_ERROR
   |
   | RETRY
   v
PAYMENT
Enter fullscreen mode Exit fullscreen mode

HTTP Request Lifecycle

An HTTP request can conceptually move through:

RECEIVED
   ↓
VALIDATING
   ↓
AUTHENTICATING
   ↓
PROCESSING
   ↓
RESPONDING
   ↓
COMPLETED
Enter fullscreen mode Exit fullscreen mode

Error paths:

VALIDATING
   |
   | INVALID
   v
BAD_REQUEST
Enter fullscreen mode Exit fullscreen mode
AUTHENTICATING
   |
   | FAILURE
   v
UNAUTHORIZED
Enter fullscreen mode Exit fullscreen mode

WebSocket Connection

WebSocket lifecycle is another excellent example.

DISCONNECTED
      |
      | CONNECT
      v
CONNECTING
      |
      | SUCCESS
      v
CONNECTED
      |
      | DISCONNECT
      v
DISCONNECTED
Enter fullscreen mode Exit fullscreen mode

Failure path:

CONNECTING
     |
     | ERROR
     v
RECONNECTING
Enter fullscreen mode Exit fullscreen mode

Retry Systems

Retry logic can be modeled explicitly.

IDLE
 ↓
RUNNING
 ↓
FAILED
 ↓
RETRYING
 ↓
RUNNING
Enter fullscreen mode Exit fullscreen mode

Eventually:

RETRYING
   |
   | MAX_RETRIES
   v
PERMANENT_FAILURE
Enter fullscreen mode Exit fullscreen mode

This is much safer than scattering retry counters across multiple functions.


Job Processing

A background job might use:

QUEUED
 ↓
PROCESSING
 ↓
COMPLETED
Enter fullscreen mode Exit fullscreen mode

Failure:

PROCESSING
   |
   | FAILURE
   v
FAILED
   |
   | RETRY
   v
QUEUED
Enter fullscreen mode Exit fullscreen mode

Game Development

Game characters can be modeled using:

IDLE
RUNNING
JUMPING
ATTACKING
HURT
DEAD
Enter fullscreen mode Exit fullscreen mode

For example:

IDLE
 |
 | MOVE
 v
RUNNING
 |
 | JUMP
 v
JUMPING
Enter fullscreen mode Exit fullscreen mode

This is a classic application of FSMs.


Traffic Lights

A traffic light is one of the simplest FSM examples.

RED
 ↓
GREEN
 ↓
YELLOW
 ↓
RED
Enter fullscreen mode Exit fullscreen mode

The event could be:

TIMER_EXPIRED
Enter fullscreen mode Exit fullscreen mode

Elevator

An elevator can have:

IDLE
MOVING_UP
MOVING_DOWN
DOOR_OPENING
DOOR_OPEN
DOOR_CLOSING
EMERGENCY
Enter fullscreen mode Exit fullscreen mode

Transitions depend on:

  • Floor requests
  • Door sensors
  • Emergency signals
  • Movement completion

Vending Machine

A vending machine might have:

IDLE
COIN_INSERTED
PRODUCT_SELECTED
PAYMENT_COMPLETE
DISPENSING
OUT_OF_STOCK
Enter fullscreen mode Exit fullscreen mode

Example:

IDLE
 |
 | INSERT_COIN
 v
COIN_INSERTED
 |
 | SELECT_PRODUCT
 v
PRODUCT_SELECTED
 |
 | PAYMENT_OK
 v
DISPENSING
 |
 | COMPLETE
 v
IDLE
Enter fullscreen mode Exit fullscreen mode

Embedded Systems

FSMs are extremely common in embedded systems because embedded devices often have well-defined operating modes.

Examples:

BOOT
INITIALIZING
READY
RUNNING
ERROR
SHUTDOWN
Enter fullscreen mode Exit fullscreen mode

Hardware controllers often depend heavily on explicit state transitions.


Part 7 — FSM + Modern Software Architecture

FSM + OOP

Object-Oriented Programming can model states using classes.

For example:

interface State {
    handle(event: Event): State;
}
Enter fullscreen mode Exit fullscreen mode

Then:

class PendingState implements State {
    handle(event: Event): State {
        if (event === "PAY") {
            return new PaidState();
        }

        throw new Error("Invalid event");
    }
}
Enter fullscreen mode Exit fullscreen mode

This is known as the State Pattern.


FSM + Functional Programming

Functional Programming can model transitions as pure functions.

function transition(
    state: State,
    event: Event
): State {
    ...
}
Enter fullscreen mode Exit fullscreen mode

A pure transition function:

State + Event
      ↓
   New State
Enter fullscreen mode Exit fullscreen mode

has no hidden side effects.

This makes testing very easy.

For example:

expect(
    transition("PENDING", "PAY")
).toBe("PAID");
Enter fullscreen mode Exit fullscreen mode

Functional FSMs are particularly useful when combined with immutable state.


FSM + Event-Driven Architecture

In an Event-Driven Architecture, events drive state transitions.

For example:

PaymentCompleted
        |
        v
Order FSM
        |
        v
PAID
Enter fullscreen mode Exit fullscreen mode

Then:

OrderPaid
    |
    +--> Notification Service
    |
    +--> Shipping Service
    |
    +--> Analytics Service
Enter fullscreen mode Exit fullscreen mode

The State Machine controls the lifecycle while events connect different components.


FSM + Event Sourcing

Event Sourcing stores events instead of only storing the current state.

Instead of:

Order:
state = SHIPPED
Enter fullscreen mode Exit fullscreen mode

we store:

OrderCreated
PaymentCompleted
ProcessingStarted
OrderShipped
Enter fullscreen mode Exit fullscreen mode

The current state can be reconstructed by replaying events:

Initial State
     ↓
OrderCreated
     ↓
PaymentCompleted
     ↓
ProcessingStarted
     ↓
OrderShipped
     ↓
Current State = SHIPPED
Enter fullscreen mode Exit fullscreen mode

This makes FSMs a natural fit for Event Sourcing.


FSM + CQRS

CQRS separates:

Commands
Enter fullscreen mode Exit fullscreen mode

from:

Queries
Enter fullscreen mode Exit fullscreen mode

A command can cause an FSM transition.

For example:

ShipOrderCommand
        |
        v
Order FSM
        |
        v
SHIPPED
Enter fullscreen mode Exit fullscreen mode

The resulting state can then be exposed through the query side.


FSM + Distributed Systems

Distributed systems introduce additional challenges:

  • Network failures
  • Duplicate messages
  • Out-of-order events
  • Retries
  • Partial failures
  • Concurrent updates

FSMs can help define valid lifecycle transitions.

For example:

PENDING
   |
   | PAYMENT_COMPLETED
   v
PAID
Enter fullscreen mode Exit fullscreen mode

If the same event arrives twice:

PAYMENT_COMPLETED
PAYMENT_COMPLETED
Enter fullscreen mode Exit fullscreen mode

the second event should not create an invalid state.

This is where idempotency becomes critical.


FSM + Microservices

A microservice may own its own state machine.

For example:

Order Service
    |
    +-- Order FSM

Payment Service
    |
    +-- Payment FSM

Shipping Service
    |
    +-- Shipping FSM
Enter fullscreen mode Exit fullscreen mode

Instead of one giant FSM:

Order + Payment + Shipping + Inventory
Enter fullscreen mode Exit fullscreen mode

we can create several smaller machines.

This helps reduce state explosion and improves service boundaries.


FSM + Redux

Redux applications already use a state-transition model.

Conceptually:

State + Action
      ↓
   Reducer
      ↓
  New State
Enter fullscreen mode Exit fullscreen mode

This is very similar to an FSM:

State + Event
      ↓
 Transition
      ↓
 New State
Enter fullscreen mode Exit fullscreen mode

The key difference is that Redux is a general state-management architecture, while an FSM explicitly defines legal states and transitions.


FSM + Angular

Angular applications can benefit from FSMs for complex UI workflows.

For example:

IDLE
 |
 | SUBMIT
 v
SUBMITTING
 |
 +---- SUCCESS ---> SUCCESS
 |
 +---- ERROR -----> ERROR
Enter fullscreen mode Exit fullscreen mode

This is much cleaner than maintaining several independent booleans:

isLoading
isSuccess
isError
isSubmitted
Enter fullscreen mode Exit fullscreen mode

Boolean combinations can accidentally create impossible states.

For example:

isLoading = true
isSuccess = true
isError = true
Enter fullscreen mode Exit fullscreen mode

An FSM prevents these invalid combinations.


FSM + RxJS

RxJS is especially useful for event streams.

For example:

events$
    .pipe(
        scan(
            (state, event) =>
                transition(state, event),
            "IDLE"
        )
    );
Enter fullscreen mode Exit fullscreen mode

Conceptually:

Event Stream
     |
     v
RxJS scan()
     |
     v
FSM Transition
     |
     v
State Stream
Enter fullscreen mode Exit fullscreen mode

This is a powerful combination for reactive applications.


FSM + XState

XState is a popular library for implementing state machines and statecharts.

A machine can be described declaratively:

const orderMachine = createMachine({
    initial: "pending",

    states: {
        pending: {
            on: {
                PAY: "paid"
            }
        },

        paid: {
            on: {
                SHIP: "shipped"
            }
        },

        shipped: {
            on: {
                DELIVER: "delivered"
            }
        },

        delivered: {}
    }
});
Enter fullscreen mode Exit fullscreen mode

The advantage is that the state machine becomes explicit and declarative.

XState also supports advanced concepts such as:

  • Hierarchical states
  • Parallel states
  • Guards
  • Actions
  • Delays
  • Actors
  • State persistence

Part 8 — Production Project

Now let's design a production-oriented Order State Machine using:

Node.js
TypeScript
Express.js
PostgreSQL
Redis
Enter fullscreen mode Exit fullscreen mode

The goal is not simply to demonstrate an FSM.

The goal is to design something closer to a real backend system.


Project Requirements

Our order system should support:

  • Creating orders
  • Paying orders
  • Processing orders
  • Shipping orders
  • Delivering orders
  • Cancelling orders
  • Retrying failures
  • Persisting state
  • Idempotency
  • Concurrency protection
  • Testing
  • Logging
  • Observability

Architecture

A possible architecture:

                    Client
                      |
                      v
                 Express API
                      |
                      v
                Order Service
                      |
             +--------+--------+
             |                 |
             v                 v
        Order FSM           Redis
             |
             v
         PostgreSQL
             |
             v
        Event Publisher
             |
       +-----+------+
       |            |
       v            v
 Notification    Shipping
 Service         Service
Enter fullscreen mode Exit fullscreen mode

Order States

Let's define:

export enum OrderState {
    PENDING_PAYMENT = "PENDING_PAYMENT",
    PAID = "PAID",
    PROCESSING = "PROCESSING",
    SHIPPED = "SHIPPED",
    DELIVERED = "DELIVERED",
    CANCELLED = "CANCELLED",
    FAILED = "FAILED"
}
Enter fullscreen mode Exit fullscreen mode

Order Events

export enum OrderEvent {
    PAYMENT_COMPLETED = "PAYMENT_COMPLETED",
    START_PROCESSING = "START_PROCESSING",
    SHIP = "SHIP",
    DELIVER = "DELIVER",
    CANCEL = "CANCEL",
    FAILURE = "FAILURE",
    RETRY = "RETRY"
}
Enter fullscreen mode Exit fullscreen mode

Order Context

The FSM should not contain all application data inside the state itself.

We can use a Context object:

export interface OrderContext {
    orderId: string;
    customerId: string;
    amount: number;

    paymentId?: string;
    trackingNumber?: string;

    retryCount: number;
}
Enter fullscreen mode Exit fullscreen mode

The state answers:

What stage is the order in?

The context answers:

What data belongs to this order?

This separation is extremely useful.


Transition Definition

We can define transitions using configuration.

interface Transition {
    target: OrderState;

    guard?: (
        context: OrderContext
    ) => boolean;

    action?: (
        context: OrderContext
    ) => Promise<void>;
}
Enter fullscreen mode Exit fullscreen mode

Then:

const transitions = {
    [OrderState.PENDING_PAYMENT]: {
        [OrderEvent.PAYMENT_COMPLETED]: {
            target: OrderState.PAID
        },

        [OrderEvent.CANCEL]: {
            target: OrderState.CANCELLED
        }
    },

    [OrderState.PAID]: {
        [OrderEvent.START_PROCESSING]: {
            target: OrderState.PROCESSING
        }
    },

    [OrderState.PROCESSING]: {
        [OrderEvent.SHIP]: {
            target: OrderState.SHIPPED
        },

        [OrderEvent.FAILURE]: {
            target: OrderState.FAILED
        }
    },

    [OrderState.SHIPPED]: {
        [OrderEvent.DELIVER]: {
            target: OrderState.DELIVERED
        }
    },

    [OrderState.FAILED]: {
        [OrderEvent.RETRY]: {
            target: OrderState.PROCESSING
        }
    }
};
Enter fullscreen mode Exit fullscreen mode

FSM Engine

Now we can create a generic engine.

class OrderStateMachine {

    constructor(
        private state: OrderState,
        private context: OrderContext
    ) {}

    getState(): OrderState {
        return this.state;
    }

    async dispatch(
        event: OrderEvent
    ): Promise<OrderState> {

        const stateTransitions =
            transitions[this.state];

        const transition =
            stateTransitions?.[event];

        if (!transition) {
            throw new Error(
                `Invalid transition: ${this.state} + ${event}`
            );
        }

        if (
            transition.guard &&
            !transition.guard(this.context)
        ) {
            throw new Error(
                "Transition guard failed"
            );
        }

        if (transition.action) {
            await transition.action(
                this.context
            );
        }

        this.state = transition.target;

        return this.state;
    }
}
Enter fullscreen mode Exit fullscreen mode

Persistence

A production FSM should not rely only on memory.

We can persist the state in PostgreSQL.

Example database structure:

CREATE TABLE orders (
    id UUID PRIMARY KEY,
    customer_id UUID NOT NULL,
    amount NUMERIC NOT NULL,
    state VARCHAR(50) NOT NULL,
    version INTEGER NOT NULL DEFAULT 0,
    created_at TIMESTAMP NOT NULL,
    updated_at TIMESTAMP NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

The important fields are:

state
version
Enter fullscreen mode Exit fullscreen mode

The version helps with optimistic concurrency.


Optimistic Concurrency

Imagine two requests arrive simultaneously:

Request A: PAY
Request B: CANCEL
Enter fullscreen mode Exit fullscreen mode

Both read:

state = PENDING_PAYMENT
version = 10
Enter fullscreen mode Exit fullscreen mode

If both update the row without protection, we may get inconsistent behavior.

Optimistic concurrency solves this.

Example:

UPDATE orders
SET
    state = $1,
    version = version + 1
WHERE
    id = $2
    AND version = $3;
Enter fullscreen mode Exit fullscreen mode

If zero rows are updated, someone else modified the order.

Then we reject the operation.


Idempotency

Distributed systems can deliver the same event more than once.

For example:

PAYMENT_COMPLETED
PAYMENT_COMPLETED
Enter fullscreen mode Exit fullscreen mode

If the first event changes:

PENDING_PAYMENT → PAID
Enter fullscreen mode Exit fullscreen mode

the second event should not perform the payment transition again.

We can store processed event IDs.

CREATE TABLE processed_events (
    event_id UUID PRIMARY KEY,
    processed_at TIMESTAMP NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

Before processing:

Does event_id exist?

YES → Ignore
NO  → Process
Enter fullscreen mode Exit fullscreen mode

This is a common production pattern.


Retry / Failure States

Failures are normal in distributed systems.

For example:

PROCESSING
    |
    | FAILURE
    v
FAILED
    |
    | RETRY
    v
PROCESSING
Enter fullscreen mode Exit fullscreen mode

We can store:

retryCount
Enter fullscreen mode Exit fullscreen mode

Then:

if (context.retryCount >= 3) {
    transitionTo("PERMANENT_FAILURE");
}
Enter fullscreen mode Exit fullscreen mode

A more complete model might be:

PROCESSING
    |
    | ERROR
    v
RETRYING
    |
    +---- RETRY ----> PROCESSING
    |
    +---- MAX_RETRIES ---> FAILED
Enter fullscreen mode Exit fullscreen mode

Exponential Backoff

Retries should usually not happen immediately.

A common strategy is exponential backoff:

Attempt 1 → 1 second
Attempt 2 → 2 seconds
Attempt 3 → 4 seconds
Attempt 4 → 8 seconds
Enter fullscreen mode Exit fullscreen mode

Formula:

delay = baseDelay × 2^retryCount
Enter fullscreen mode Exit fullscreen mode

With jitter:

delay =
    baseDelay × 2^retryCount
    + randomJitter
Enter fullscreen mode Exit fullscreen mode

This helps prevent many workers from retrying simultaneously.


Express API

We can expose an endpoint:

POST /orders/:id/events
Enter fullscreen mode Exit fullscreen mode

Request:

{
    "event": "PAYMENT_COMPLETED"
}
Enter fullscreen mode Exit fullscreen mode

Controller:

router.post(
    "/orders/:id/events",
    async (req, res) => {

        const { id } = req.params;
        const { event } = req.body;

        const order =
            await orderRepository.findById(id);

        if (!order) {
            return res
                .status(404)
                .json({
                    message: "Order not found"
                });
        }

        const machine =
            new OrderStateMachine(
                order.state,
                order.context
            );

        const newState =
            await machine.dispatch(event);

        await orderRepository.updateState(
            id,
            newState
        );

        return res.json({
            orderId: id,
            state: newState
        });
    }
);
Enter fullscreen mode Exit fullscreen mode

Better Production Design

However, in a real production application, we should avoid directly coupling:

Controller
   ↓
FSM
   ↓
Database
Enter fullscreen mode Exit fullscreen mode

A better architecture is:

Controller
    ↓
Application Service
    ↓
Domain FSM
    ↓
Repository
    ↓
Database
Enter fullscreen mode Exit fullscreen mode

For example:

HTTP Controller
      |
      v
OrderApplicationService
      |
      v
OrderStateMachine
      |
      v
OrderRepository
      |
      v
PostgreSQL
Enter fullscreen mode Exit fullscreen mode

This follows separation of concerns.


Domain Layer

The FSM belongs to the domain layer because it contains business rules.

Example:

class Order {

    constructor(
        public readonly id: string,
        private state: OrderState,
        private context: OrderContext
    ) {}

    async apply(
        event: OrderEvent
    ) {

        const machine =
            new OrderStateMachine(
                this.state,
                this.context
            );

        this.state =
            await machine.dispatch(event);
    }

    getState() {
        return this.state;
    }
}
Enter fullscreen mode Exit fullscreen mode

The domain should not know about:

Express
HTTP
Redis
PostgreSQL
Enter fullscreen mode Exit fullscreen mode

This keeps business logic independent.


Application Layer

The Application Service coordinates operations.

class OrderApplicationService {

    constructor(
        private readonly repository:
            OrderRepository
    ) {}

    async handleEvent(
        orderId: string,
        event: OrderEvent
    ) {

        const order =
            await this.repository
                .findById(orderId);

        if (!order) {
            throw new Error(
                "Order not found"
            );
        }

        await order.apply(event);

        await this.repository.save(order);

        return order;
    }
}
Enter fullscreen mode Exit fullscreen mode

Repository Layer

The repository handles persistence.

interface OrderRepository {

    findById(
        id: string
    ): Promise<Order | null>;

    save(
        order: Order
    ): Promise<void>;
}
Enter fullscreen mode Exit fullscreen mode

The domain remains independent from PostgreSQL.


Event Publishing

After a successful transition:

PENDING_PAYMENT
       |
       | PAYMENT_COMPLETED
       v
      PAID
Enter fullscreen mode Exit fullscreen mode

we may publish:

OrderPaid
Enter fullscreen mode Exit fullscreen mode

Other services can subscribe.

Order Service
     |
     | OrderPaid
     v
Message Broker
     |
     +----> Notification
     |
     +----> Analytics
     |
     +----> Shipping
Enter fullscreen mode Exit fullscreen mode

Transactional Consistency

A critical production problem is:

Update Database
      +
Publish Event
Enter fullscreen mode Exit fullscreen mode

What happens if:

Database update succeeds
Event publishing fails
Enter fullscreen mode Exit fullscreen mode

Now the order is:

PAID
Enter fullscreen mode Exit fullscreen mode

but other services never receive:

OrderPaid
Enter fullscreen mode Exit fullscreen mode

A common solution is the Transactional Outbox Pattern.


Transactional Outbox

Instead of immediately publishing the event:

Database
   |
   +-- Update Order
   |
   +-- Insert Outbox Event
Enter fullscreen mode Exit fullscreen mode

Both happen inside the same database transaction.

Example:

BEGIN;

UPDATE orders
SET state = 'PAID'
WHERE id = $1;

INSERT INTO outbox_events (
    id,
    type,
    payload
)
VALUES (
    $2,
    'OrderPaid',
    $3
);

COMMIT;
Enter fullscreen mode Exit fullscreen mode

Then a background worker reads the outbox:

Outbox
   |
   v
Publisher
   |
   v
Message Broker
Enter fullscreen mode Exit fullscreen mode

This significantly improves reliability.


Observability

A production FSM should be observable.

Important metrics include:

state_transition_total
state_transition_failure_total
invalid_transition_total
fsm_processing_duration
retry_total
Enter fullscreen mode Exit fullscreen mode

Logs should include:

{
    "orderId": "123",
    "from": "PENDING_PAYMENT",
    "event": "PAYMENT_COMPLETED",
    "to": "PAID"
}
Enter fullscreen mode Exit fullscreen mode

This makes debugging much easier.


State Transition Logging

Every transition should ideally be traceable.

Order 123

PENDING_PAYMENT
      |
      | PAYMENT_COMPLETED
      v
PAID
      |
      | START_PROCESSING
      v
PROCESSING
      |
      | SHIP
      v
SHIPPED
Enter fullscreen mode Exit fullscreen mode

A transition log can contain:

orderId
previousState
event
newState
timestamp
actor
correlationId
Enter fullscreen mode Exit fullscreen mode

Testing an FSM

One of the biggest advantages of FSMs is testability.

We can test transitions independently.

Example:

describe(
    "Order State Machine",
    () => {

        it(
            "should transition from pending to paid",
            async () => {

                const machine =
                    new OrderStateMachine(
                        OrderState.PENDING_PAYMENT,
                        context
                    );

                await machine.dispatch(
                    OrderEvent.PAYMENT_COMPLETED
                );

                expect(
                    machine.getState()
                ).toBe(OrderState.PAID);
            }
        );
    }
);
Enter fullscreen mode Exit fullscreen mode

Testing Invalid Transitions

We should also test invalid operations.

it(
    "should reject shipping before payment",
    async () => {

        const machine =
            new OrderStateMachine(
                OrderState.PENDING_PAYMENT,
                context
            );

        await expect(
            machine.dispatch(
                OrderEvent.SHIP
            )
        ).rejects.toThrow();
    }
);
Enter fullscreen mode Exit fullscreen mode

This is extremely important.

A good FSM test suite should test:

Valid transitions
Invalid transitions
Guards
Actions
Retries
Concurrency
Persistence
Recovery
Enter fullscreen mode Exit fullscreen mode

Testing State Invariants

Suppose:

SHIPPED
Enter fullscreen mode Exit fullscreen mode

requires:

trackingNumber
Enter fullscreen mode Exit fullscreen mode

Test:

expect(() =>
    createOrder({
        state: OrderState.SHIPPED,
        trackingNumber: undefined
    })
).toThrow();
Enter fullscreen mode Exit fullscreen mode

This ensures invalid states cannot exist.


Property-Based Testing

FSMs are also excellent candidates for property-based testing.

We can define properties such as:

The machine must never reach an undefined state.

Or:

DELIVERED must never transition back to PENDING_PAYMENT.

Or:

CANCELLED orders cannot become SHIPPED.

This allows automated generation of event sequences.

Example sequence:

PAY
SHIP
DELIVER
CANCEL
PAY
SHIP
Enter fullscreen mode Exit fullscreen mode

The test verifies that no invalid state is produced.


Concurrency Testing

Production systems must test concurrent requests.

Example:

Request A:
PAYMENT_COMPLETED

Request B:
CANCEL
Enter fullscreen mode Exit fullscreen mode

Both arrive simultaneously.

The system must guarantee that only one valid transition succeeds.

Optimistic locking can enforce this.

Version = 10

Request A:
10 → 11

Request B:
10 → FAIL
Enter fullscreen mode Exit fullscreen mode

Request B receives:

CONCURRENT_MODIFICATION
Enter fullscreen mode Exit fullscreen mode

and can retry after reloading the latest state.


Complete Production Flow

Let's put everything together.

                   HTTP Request
                        |
                        v
                Express Controller
                        |
                        v
             Application Service
                        |
                        v
                  Load Order
                        |
                        v
                Current State
                        |
                        v
                  FSM Engine
                        |
              +---------+---------+
              |                   |
           Guard                Invalid
              |                   |
              v                   v
        Transition            Reject Event
              |
              v
          New State
              |
              v
       Database Transaction
              |
        +-----+------+
        |            |
        v            v
   Update Order   Outbox Event
        |            |
        +-----+------+
              |
              v
            Commit
              |
              v
       Background Publisher
              |
              v
        Message Broker
              |
       +------+------+
       |             |
       v             v
 Notification     Shipping
Enter fullscreen mode Exit fullscreen mode

This architecture demonstrates why State Machines are useful beyond simple examples.


Recommended Project Structure

A production Node.js + TypeScript project could look like:

src/
│
├── domain/
│   └── order/
│       ├── order.entity.ts
│       ├── order-state.ts
│       ├── order-event.ts
│       ├── order-state-machine.ts
│       └── order.errors.ts
│
├── application/
│   └── order/
│       ├── create-order.service.ts
│       ├── process-order-event.service.ts
│       └── cancel-order.service.ts
│
├── infrastructure/
│   ├── database/
│   │   ├── postgres.ts
│   │   └── order.repository.ts
│   │
│   ├── messaging/
│   │   └── event-publisher.ts
│   │
│   └── cache/
│       └── redis.ts
│
├── interfaces/
│   └── http/
│       ├── controllers/
│       ├── routes/
│       └── middleware/
│
├── workers/
│   └── outbox.worker.ts
│
├── config/
│   └── environment.ts
│
└── server.ts
Enter fullscreen mode Exit fullscreen mode

This separates:

Domain
Application
Infrastructure
Interfaces
Workers
Configuration
Enter fullscreen mode Exit fullscreen mode

FSM Design Principles

When designing a State Machine, follow these principles.

1. Keep States Meaningful

A state should represent a meaningful business condition.

Bad:

STEP_1
STEP_2
STEP_3
Enter fullscreen mode Exit fullscreen mode

Better:

PENDING_PAYMENT
PAID
PROCESSING
SHIPPED
DELIVERED
Enter fullscreen mode Exit fullscreen mode

2. Keep Events Explicit

Avoid vague events such as:

UPDATE
CHANGE
PROCESS
Enter fullscreen mode Exit fullscreen mode

Prefer:

PAYMENT_COMPLETED
ORDER_SHIPPED
DELIVERY_CONFIRMED
PAYMENT_FAILED
Enter fullscreen mode Exit fullscreen mode

Explicit events make systems easier to understand.


3. Make Invalid Transitions Impossible

Do not allow:

DELIVERED → PAID
Enter fullscreen mode Exit fullscreen mode

unless the domain explicitly requires it.


4. Separate State from Context

State:

PAID
Enter fullscreen mode Exit fullscreen mode

Context:

{
    orderId,
    customerId,
    amount,
    paymentId
}
Enter fullscreen mode Exit fullscreen mode

This prevents the state from becoming a giant data structure.


5. Keep Side Effects Controlled

The transition logic should be predictable.

Instead of mixing everything:

transition() {
    updateDatabase();
    sendEmail();
    callStripe();
    publishKafkaMessage();
    updateRedis();
}
Enter fullscreen mode Exit fullscreen mode

prefer:

FSM
 ↓
Domain Event
 ↓
Application / Infrastructure
Enter fullscreen mode Exit fullscreen mode

This gives better separation.


Common FSM Mistakes

Mistake 1 — Using Too Many Boolean Flags

Instead of:

isLoading
isSuccess
isError
isCancelled
Enter fullscreen mode Exit fullscreen mode

use:

IDLE
LOADING
SUCCESS
ERROR
CANCELLED
Enter fullscreen mode Exit fullscreen mode

One state is often better than many independent booleans.


Mistake 2 — Allowing Impossible States

For example:

isLoading = true
isSuccess = true
Enter fullscreen mode Exit fullscreen mode

An FSM can eliminate these combinations.


Mistake 3 — Putting Everything Into One FSM

Don't create:

OrderPaymentShippingInventoryNotificationFSM
Enter fullscreen mode Exit fullscreen mode

Split independent concerns.

Order FSM
Payment FSM
Shipping FSM
Notification FSM
Enter fullscreen mode Exit fullscreen mode

Mistake 4 — Ignoring Concurrency

An FSM in memory may be correct locally but incorrect in a distributed system.

Always consider:

Concurrent events
Duplicate events
Out-of-order events
Retries
Persistence
Enter fullscreen mode Exit fullscreen mode

Mistake 5 — Treating Persistence as an Afterthought

If state matters, persist it.

For critical workflows:

state
version
transition history
event ID
timestamp
Enter fullscreen mode Exit fullscreen mode

can be extremely valuable.


FSM vs State Pattern

FSM and the State Pattern are related but not identical.

A State Machine focuses on:

States
Events
Transitions
Rules
Enter fullscreen mode Exit fullscreen mode

The State Pattern is an Object-Oriented design pattern where behavior is delegated to state objects.

Example:

Order
  |
  +-- PendingState
  +-- PaidState
  +-- ShippedState
Enter fullscreen mode Exit fullscreen mode

The State Pattern can be used to implement an FSM, but an FSM does not have to use the State Pattern.


FSM vs Workflow Engine

An FSM is usually focused on:

States + Transitions
Enter fullscreen mode Exit fullscreen mode

A Workflow Engine often provides much more:

  • Long-running workflows
  • Timers
  • Retries
  • Persistence
  • Distributed execution
  • Compensation
  • Human approval
  • Recovery

Examples of workflow-oriented systems include platforms such as Temporal.

FSMs can be part of a workflow engine, but they are conceptually smaller.


FSM vs Event-Driven Architecture

These concepts are also different.

FSM:

State + Event → New State
Enter fullscreen mode Exit fullscreen mode

Event-driven architecture:

Event → Subscribers
Enter fullscreen mode Exit fullscreen mode

They can work together.

For example:

PaymentCompleted
       |
       v
Order FSM
       |
       v
PAID
       |
       v
OrderPaid
       |
       +----> Notification
       +----> Shipping
       +----> Analytics
Enter fullscreen mode Exit fullscreen mode

FSM Mental Model

A very useful mental model is:

STATE
  +
EVENT
  +
GUARD
  |
  v
TRANSITION
  |
  +---- ACTION
  |
  v
NEW STATE
Enter fullscreen mode Exit fullscreen mode

Or even more simply:

Where am I?
     +
What happened?
     +
Am I allowed?
     ↓
Where do I go?
     +
What should I do?
Enter fullscreen mode Exit fullscreen mode

The Most Important Formula

The core of a State Machine can be summarized as:

δ(State, Event) → State
Enter fullscreen mode Exit fullscreen mode

For a more practical application:

Transition =
    Current State
    +
    Event
    +
    Optional Guard
    →
    Next State
    +
    Optional Action
Enter fullscreen mode Exit fullscreen mode

A Complete Example

Consider an online order:

                    PAYMENT_COMPLETED
       +--------------------------------------+
       |                                      |
       v                                      |
+------------------+                    +----------+
| PENDING_PAYMENT  | -----------------> |   PAID   |
+------------------+                    +----------+
       |                                      |
       | CANCEL                               | PROCESS
       v                                      v
+-------------+                         +-------------+
|  CANCELLED  |                         | PROCESSING  |
+-------------+                         +-------------+
                                              |
                                              | SHIP
                                              v
                                         +---------+
                                         | SHIPPED |
                                         +---------+
                                              |
                                              | DELIVER
                                              v
                                         +-----------+
                                         | DELIVERED |
                                         +-----------+
Enter fullscreen mode Exit fullscreen mode

The machine guarantees that:

PENDING_PAYMENT
    → PAID
    → PROCESSING
    → SHIPPED
    → DELIVERED
Enter fullscreen mode Exit fullscreen mode

is valid.

But:

PENDING_PAYMENT
    → SHIPPED
Enter fullscreen mode Exit fullscreen mode

is invalid.

And:

DELIVERED
    → PENDING_PAYMENT
Enter fullscreen mode Exit fullscreen mode

is also invalid.

This is the core value of State Machines:

They make valid behavior explicit and invalid behavior enforceable.


When Should You Use an FSM?

FSMs are especially useful when:

  • A system has a lifecycle
  • There are explicit states
  • Transitions depend on events
  • Invalid transitions matter
  • Business rules are complex
  • The workflow needs to be visualized
  • You need strong testability
  • You need predictable behavior

Examples:

Authentication
Payments
Orders
Checkout
Jobs
Retries
Connections
UI workflows
Games
Embedded systems
Enter fullscreen mode Exit fullscreen mode

When Should You Avoid an FSM?

Not every problem needs one.

If the logic is simply:

const result = calculatePrice(input);
Enter fullscreen mode Exit fullscreen mode

there is probably no reason to introduce an FSM.

FSMs are useful when there is a meaningful lifecycle.

If there are no meaningful states or transitions, an FSM may add unnecessary complexity.


Final Production Checklist

Before deploying an FSM-based system, ask:

[ ] Are all states explicitly defined?
[ ] Are all events explicitly defined?
[ ] Is there a clear initial state?
[ ] Are valid transitions documented?
[ ] Are invalid transitions rejected?
[ ] Are guards tested?
[ ] Are actions controlled?
[ ] Are invariants enforced?
[ ] Is state persisted when necessary?
[ ] Is concurrency handled?
[ ] Is idempotency implemented?
[ ] Are duplicate events handled?
[ ] Are retries controlled?
[ ] Is exponential backoff used?
[ ] Is transition history observable?
[ ] Are metrics available?
[ ] Are logs structured?
[ ] Are transitions unit tested?
[ ] Are invalid transitions tested?
[ ] Is state explosion controlled?
[ ] Are independent workflows separated?
Enter fullscreen mode Exit fullscreen mode

Conclusion

Finite State Machines are much more than a theoretical computer science concept.

They provide a practical way to model systems that evolve through well-defined states.

The fundamental model is simple:

State
 +
Event
 ↓
Transition
 ↓
New State
Enter fullscreen mode Exit fullscreen mode

But this simple abstraction can scale into powerful architectural patterns.

We can use:

Guards
Actions
Entry / Exit Actions
Hierarchical States
Nested States
Parallel States
History
Event Queues
Persistence
Concurrency Control
Idempotency
Retries
Event Sourcing
CQRS
Event-Driven Architecture
Microservices
Enter fullscreen mode Exit fullscreen mode

In modern backend systems, State Machines are particularly valuable for workflows such as:

Orders
Payments
Authentication
Jobs
Retries
Shipping
Subscriptions
WebSocket Connections
Enter fullscreen mode Exit fullscreen mode

The most important lesson is not how to write an FSM.

It is how to think in terms of explicit states, explicit events, explicit transitions, and explicit business rules.

Instead of writing code that implicitly describes what the system can do, a State Machine lets us explicitly define:

What states are possible?
What events can occur?
Which transitions are legal?
Which transitions are invalid?
What conditions must be satisfied?
What side effects should happen?
How should the system recover from failure?
Enter fullscreen mode Exit fullscreen mode

That is why State Machines remain useful from simple applications to production distributed systems.

At the simplest level:

State + Event → Next State
Enter fullscreen mode Exit fullscreen mode

At the production level:

Event
  ↓
Validation
  ↓
Current State
  ↓
Guard
  ↓
FSM Transition
  ↓
New State
  ↓
Persistence
  ↓
Outbox Event
  ↓
Message Broker
  ↓
Distributed Consumers
  ↓
Observability
Enter fullscreen mode Exit fullscreen mode

Once you start seeing software systems as state transitions, many complex workflows become easier to design, reason about, test, and maintain.

A well-designed State Machine does not merely describe what a system does — it defines what the system is allowed to do.

Top comments (0)