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
- Part 1 — Fundamentals
- Part 2 — Finite State Machines
- Part 3 — FSM Design
- Part 4 — FSM Implementation
- Part 5 — Advanced FSM
- Part 6 — Real-World Applications
- Part 7 — FSM + Modern Software Architecture
- Part 8 — Production Project
- 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
For example, imagine an online order.
An order might have the following states:
PENDING
PAID
PROCESSING
SHIPPED
DELIVERED
CANCELLED
The order changes state when events occur.
For example:
PENDING + PAY
↓
PAID
PAID + START_PROCESSING
↓
PROCESSING
PROCESSING + SHIP
↓
SHIPPED
SHIPPED + DELIVER
↓
DELIVERED
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
For example:
if (order.status === "pending") {
if (event === "pay") {
...
}
}
if (order.status === "paid") {
if (event === "ship") {
...
}
}
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
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
means the order has been created but has not been paid.
Another state:
PAID
means payment has successfully completed.
Another:
SHIPPED
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
For example:
State: PENDING
Event: PAY
The machine may transition to:
PAID
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
Example:
PENDING --PAY--> PAID
Another example:
PAID --START_PROCESSING--> PROCESSING
A transition can also have conditions and actions.
PENDING
|
| PAY
| [payment successful]
v
PAID
The Concept of Action
An Action is something the system performs when a transition occurs.
For example:
PENDING --PAY--> PAID
The transition may execute:
sendPaymentConfirmation();
or:
createInvoice();
or:
publishOrderPaidEvent();
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?
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 |
+-----------+
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
Events:
TURN_ON
TURN_OFF
Transitions:
OFF --TURN_ON--> ON
ON --TURN_OFF--> OFF
Diagram:
TURN_ON
OFF ------------> ON
^ |
| |
+--- TURN_OFF ----+
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}`);
}
Usage:
dispatch("TURN_ON");
console.log(state);
// ON
dispatch("TURN_OFF");
console.log(state);
// OFF
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)
Where:
Q = Set of states
Σ = Set of input symbols
δ = Transition function
q₀ = Initial state
F = Set of accepting/final states
Let's understand each component.
Components of an FSM
Consider:
States:
PENDING
PAID
SHIPPED
DELIVERED
Then:
Q = {
PENDING,
PAID,
SHIPPED,
DELIVERED
}
Events form the input alphabet:
Σ = {
PAY,
SHIP,
DELIVER
}
The transition function determines where the machine goes.
For example:
δ(PENDING, PAY) = PAID
δ(PAID, SHIP) = SHIPPED
δ(SHIPPED, DELIVER) = DELIVERED
The initial state is:
q₀ = PENDING
Accepting states might be:
F = {
DELIVERED
}
Transition Function
The transition function is one of the most important concepts in FSMs.
It can be expressed as:
δ(currentState, event) = nextState
Example:
δ(PENDING, PAY) = PAID
Meaning:
If the current state is PENDING and the PAY event occurs, transition to PAID.
Another:
δ(PAID, SHIP) = SHIPPED
Initial State
The Initial State is where the machine starts.
For an order:
PENDING
For authentication:
LOGGED_OUT
For a WebSocket:
DISCONNECTED
For a payment:
CREATED
The initial state is usually represented using an arrow:
+---------+
| PENDING |
+---------+
^
|
START
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
Here:
DELIVERED
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
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
That means:
δ(state, input) = exactly one state
For example:
PENDING + PAY → PAID
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, ...}
For example:
A + X
├──> B
└──> C
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)
For example:
State: RED
Output: STOP
State: GREEN
Output: GO
Diagram:
RED
|
| TIMER
v
GREEN
|
| TIMER
v
YELLOW
The output is associated with the state.
Mealy Machine
A Mealy Machine produces output based on both:
State + Input
Formally:
Output = f(State, Input)
For example:
State: LOCKED
Input: VALID_PASSWORD
Output: UNLOCK
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
Step 2 — Extract Events
Look for actions or things that happen.
PAY
START_PROCESSING
SHIP
DELIVER
CANCEL
REFUND
Step 3 — Design Transitions
Now connect states and events.
PENDING + PAY
↓
PAID
PAID + START_PROCESSING
↓
PROCESSING
PROCESSING + SHIP
↓
SHIPPED
SHIPPED + DELIVER
↓
DELIVERED
Step 4 — Add Guards
Sometimes an event alone is not enough.
For example:
PENDING + PAY
does not necessarily mean the order becomes paid.
We may require:
payment.status === "SUCCESS"
Therefore:
PENDING
|
| PAY
| [payment successful]
v
PAID
The condition is called a Guard.
Example:
if (payment.status === "SUCCESS") {
transitionTo("PAID");
}
Step 5 — Add Actions
The transition may execute side effects.
PENDING
|
| PAY
|
+--> updateOrder()
+--> createInvoice()
+--> sendEmail()
+--> publishEvent()
|
v
PAID
Invalid Transitions
A State Machine should explicitly define what happens when an invalid event occurs.
For example:
DELIVERED + PAY
does not make sense.
Possible behavior:
throw new Error("Invalid transition");
Or:
return {
success: false,
error: "INVALID_TRANSITION"
};
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
might require:
paymentId != null
while:
SHIPPED
might require:
trackingNumber != null
Example:
if (state === "SHIPPED") {
if (!order.trackingNumber) {
throw new Error(
"Shipped orders must have a tracking number"
);
}
}
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
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
...
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");
}
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");
}
This is clearer than deeply nested conditions.
But it still mixes:
State Definition
Transition Definition
Business Logic
FSM Using Objects
We can represent transitions as data.
const transitions = {
PENDING: {
PAY: "PAID",
},
PAID: {
SHIP: "SHIPPED",
},
SHIPPED: {
DELIVER: "DELIVERED",
}
};
Then:
function transition(
state: string,
event: string
) {
const nextState =
transitions[state]?.[event];
if (!nextState) {
throw new Error(
`Invalid transition: ${state} + ${event}`
);
}
return nextState;
}
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"]
])
);
Then:
function transition(
state: string,
event: string
) {
const nextState =
transitions.get(state)?.get(event);
if (!nextState) {
throw new Error("Invalid transition");
}
return nextState;
}
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;
}
}
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";
Now TypeScript can prevent invalid values.
Example:
let state: OrderState = "PENDING";
Java FSM
A Java implementation can use enums.
enum OrderState {
PENDING,
PAID,
PROCESSING,
SHIPPED,
DELIVERED,
CANCELLED
}
enum OrderEvent {
PAY,
PROCESS,
SHIP,
DELIVER,
CANCEL
}
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"
);
};
}
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"
}
}
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
Part 5 — Advanced FSM
Hierarchical State Machines
A Hierarchical State Machine allows states to contain sub-states.
For example:
AUTHENTICATED
├── ACTIVE
├── IDLE
└── LOCKED
Instead of treating every state independently, we can create a hierarchy.
Example:
USER
├── LOGGED_OUT
└── LOGGED_IN
├── ACTIVE
├── IDLE
└── LOCKED
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
This is especially useful for complex workflows.
Composite States
A Composite State is a state that internally contains another state machine.
For example:
CHECKOUT
could contain:
CART
ADDRESS
PAYMENT
CONFIRMATION
So:
CHECKOUT
|
+-- CART
|
+-- ADDRESS
|
+-- PAYMENT
|
+-- CONFIRMATION
Parallel States
Sometimes multiple processes need to happen independently.
For example, an order might simultaneously have:
Payment:
SUCCESS
Shipping:
PROCESSING
Notification:
SENDING
Instead of creating:
PAYMENT_SUCCESS_SHIPPING_PROCESSING_NOTIFICATION_SENDING
we can use parallel state machines.
Conceptually:
ORDER
|
+---- PAYMENT
|
+---- SHIPPING
|
+---- NOTIFICATION
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
Suppose the user leaves the application while in:
PAYMENT
When they return, the system can restore:
PAYMENT
instead of restarting from:
BROWSING
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()
Conceptually:
const paidState = {
entry: () => {
createInvoice();
}
};
Exit Actions
An Exit Action runs when leaving a state.
LEAVE PROCESSING
↓
cleanupProcessing()
Example:
const processingState = {
exit: () => {
cleanupProcessing();
}
};
Guards
Guards are conditions that determine whether a transition is allowed.
Example:
PENDING
|
| PAY
| [amount > 0]
v
PAID
Implementation:
{
event: "PAY",
target: "PAID",
guard: context => context.amount > 0
}
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
The state remains:
CONNECTED
but an action occurs:
heartbeat();
Event Queues
Events do not always need to be processed immediately.
A system may receive:
PAY
SHIP
DELIVER
very quickly.
An event queue can process them sequentially:
Event Queue
PAY
↓
SHIP
↓
DELIVER
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
When the service restarts:
Database
↓
Load state
↓
Restore FSM
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
Additional states:
LOCKED
MFA_REQUIRED
SESSION_EXPIRED
Order Processing
Typical order lifecycle:
CREATED
↓
PENDING_PAYMENT
↓
PAID
↓
PROCESSING
↓
SHIPPED
↓
DELIVERED
Alternative path:
PENDING_PAYMENT
|
| CANCEL
v
CANCELLED
Payment Processing
Payment systems commonly have:
CREATED
PENDING
PROCESSING
AUTHORIZED
CAPTURED
FAILED
REFUNDED
Example:
CREATED
|
| PROCESS
v
PROCESSING
|
+---- SUCCESS ---> AUTHORIZED
|
+---- FAILURE ---> FAILED
E-Commerce Checkout
Checkout can be modeled as:
CART
↓
ADDRESS
↓
SHIPPING
↓
PAYMENT
↓
CONFIRMATION
If payment fails:
PAYMENT
|
| FAILURE
v
PAYMENT_ERROR
|
| RETRY
v
PAYMENT
HTTP Request Lifecycle
An HTTP request can conceptually move through:
RECEIVED
↓
VALIDATING
↓
AUTHENTICATING
↓
PROCESSING
↓
RESPONDING
↓
COMPLETED
Error paths:
VALIDATING
|
| INVALID
v
BAD_REQUEST
AUTHENTICATING
|
| FAILURE
v
UNAUTHORIZED
WebSocket Connection
WebSocket lifecycle is another excellent example.
DISCONNECTED
|
| CONNECT
v
CONNECTING
|
| SUCCESS
v
CONNECTED
|
| DISCONNECT
v
DISCONNECTED
Failure path:
CONNECTING
|
| ERROR
v
RECONNECTING
Retry Systems
Retry logic can be modeled explicitly.
IDLE
↓
RUNNING
↓
FAILED
↓
RETRYING
↓
RUNNING
Eventually:
RETRYING
|
| MAX_RETRIES
v
PERMANENT_FAILURE
This is much safer than scattering retry counters across multiple functions.
Job Processing
A background job might use:
QUEUED
↓
PROCESSING
↓
COMPLETED
Failure:
PROCESSING
|
| FAILURE
v
FAILED
|
| RETRY
v
QUEUED
Game Development
Game characters can be modeled using:
IDLE
RUNNING
JUMPING
ATTACKING
HURT
DEAD
For example:
IDLE
|
| MOVE
v
RUNNING
|
| JUMP
v
JUMPING
This is a classic application of FSMs.
Traffic Lights
A traffic light is one of the simplest FSM examples.
RED
↓
GREEN
↓
YELLOW
↓
RED
The event could be:
TIMER_EXPIRED
Elevator
An elevator can have:
IDLE
MOVING_UP
MOVING_DOWN
DOOR_OPENING
DOOR_OPEN
DOOR_CLOSING
EMERGENCY
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
Example:
IDLE
|
| INSERT_COIN
v
COIN_INSERTED
|
| SELECT_PRODUCT
v
PRODUCT_SELECTED
|
| PAYMENT_OK
v
DISPENSING
|
| COMPLETE
v
IDLE
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
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;
}
Then:
class PendingState implements State {
handle(event: Event): State {
if (event === "PAY") {
return new PaidState();
}
throw new Error("Invalid event");
}
}
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 {
...
}
A pure transition function:
State + Event
↓
New State
has no hidden side effects.
This makes testing very easy.
For example:
expect(
transition("PENDING", "PAY")
).toBe("PAID");
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
Then:
OrderPaid
|
+--> Notification Service
|
+--> Shipping Service
|
+--> Analytics Service
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
we store:
OrderCreated
PaymentCompleted
ProcessingStarted
OrderShipped
The current state can be reconstructed by replaying events:
Initial State
↓
OrderCreated
↓
PaymentCompleted
↓
ProcessingStarted
↓
OrderShipped
↓
Current State = SHIPPED
This makes FSMs a natural fit for Event Sourcing.
FSM + CQRS
CQRS separates:
Commands
from:
Queries
A command can cause an FSM transition.
For example:
ShipOrderCommand
|
v
Order FSM
|
v
SHIPPED
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
If the same event arrives twice:
PAYMENT_COMPLETED
PAYMENT_COMPLETED
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
Instead of one giant FSM:
Order + Payment + Shipping + Inventory
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
This is very similar to an FSM:
State + Event
↓
Transition
↓
New State
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
This is much cleaner than maintaining several independent booleans:
isLoading
isSuccess
isError
isSubmitted
Boolean combinations can accidentally create impossible states.
For example:
isLoading = true
isSuccess = true
isError = true
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"
)
);
Conceptually:
Event Stream
|
v
RxJS scan()
|
v
FSM Transition
|
v
State Stream
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: {}
}
});
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
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
Order States
Let's define:
export enum OrderState {
PENDING_PAYMENT = "PENDING_PAYMENT",
PAID = "PAID",
PROCESSING = "PROCESSING",
SHIPPED = "SHIPPED",
DELIVERED = "DELIVERED",
CANCELLED = "CANCELLED",
FAILED = "FAILED"
}
Order Events
export enum OrderEvent {
PAYMENT_COMPLETED = "PAYMENT_COMPLETED",
START_PROCESSING = "START_PROCESSING",
SHIP = "SHIP",
DELIVER = "DELIVER",
CANCEL = "CANCEL",
FAILURE = "FAILURE",
RETRY = "RETRY"
}
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;
}
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>;
}
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
}
}
};
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;
}
}
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
);
The important fields are:
state
version
The version helps with optimistic concurrency.
Optimistic Concurrency
Imagine two requests arrive simultaneously:
Request A: PAY
Request B: CANCEL
Both read:
state = PENDING_PAYMENT
version = 10
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;
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
If the first event changes:
PENDING_PAYMENT → PAID
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
);
Before processing:
Does event_id exist?
YES → Ignore
NO → Process
This is a common production pattern.
Retry / Failure States
Failures are normal in distributed systems.
For example:
PROCESSING
|
| FAILURE
v
FAILED
|
| RETRY
v
PROCESSING
We can store:
retryCount
Then:
if (context.retryCount >= 3) {
transitionTo("PERMANENT_FAILURE");
}
A more complete model might be:
PROCESSING
|
| ERROR
v
RETRYING
|
+---- RETRY ----> PROCESSING
|
+---- MAX_RETRIES ---> FAILED
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
Formula:
delay = baseDelay × 2^retryCount
With jitter:
delay =
baseDelay × 2^retryCount
+ randomJitter
This helps prevent many workers from retrying simultaneously.
Express API
We can expose an endpoint:
POST /orders/:id/events
Request:
{
"event": "PAYMENT_COMPLETED"
}
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
});
}
);
Better Production Design
However, in a real production application, we should avoid directly coupling:
Controller
↓
FSM
↓
Database
A better architecture is:
Controller
↓
Application Service
↓
Domain FSM
↓
Repository
↓
Database
For example:
HTTP Controller
|
v
OrderApplicationService
|
v
OrderStateMachine
|
v
OrderRepository
|
v
PostgreSQL
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;
}
}
The domain should not know about:
Express
HTTP
Redis
PostgreSQL
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;
}
}
Repository Layer
The repository handles persistence.
interface OrderRepository {
findById(
id: string
): Promise<Order | null>;
save(
order: Order
): Promise<void>;
}
The domain remains independent from PostgreSQL.
Event Publishing
After a successful transition:
PENDING_PAYMENT
|
| PAYMENT_COMPLETED
v
PAID
we may publish:
OrderPaid
Other services can subscribe.
Order Service
|
| OrderPaid
v
Message Broker
|
+----> Notification
|
+----> Analytics
|
+----> Shipping
Transactional Consistency
A critical production problem is:
Update Database
+
Publish Event
What happens if:
Database update succeeds
Event publishing fails
Now the order is:
PAID
but other services never receive:
OrderPaid
A common solution is the Transactional Outbox Pattern.
Transactional Outbox
Instead of immediately publishing the event:
Database
|
+-- Update Order
|
+-- Insert Outbox Event
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;
Then a background worker reads the outbox:
Outbox
|
v
Publisher
|
v
Message Broker
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
Logs should include:
{
"orderId": "123",
"from": "PENDING_PAYMENT",
"event": "PAYMENT_COMPLETED",
"to": "PAID"
}
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
A transition log can contain:
orderId
previousState
event
newState
timestamp
actor
correlationId
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);
}
);
}
);
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();
}
);
This is extremely important.
A good FSM test suite should test:
Valid transitions
Invalid transitions
Guards
Actions
Retries
Concurrency
Persistence
Recovery
Testing State Invariants
Suppose:
SHIPPED
requires:
trackingNumber
Test:
expect(() =>
createOrder({
state: OrderState.SHIPPED,
trackingNumber: undefined
})
).toThrow();
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
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
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
Request B receives:
CONCURRENT_MODIFICATION
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
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
This separates:
Domain
Application
Infrastructure
Interfaces
Workers
Configuration
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
Better:
PENDING_PAYMENT
PAID
PROCESSING
SHIPPED
DELIVERED
2. Keep Events Explicit
Avoid vague events such as:
UPDATE
CHANGE
PROCESS
Prefer:
PAYMENT_COMPLETED
ORDER_SHIPPED
DELIVERY_CONFIRMED
PAYMENT_FAILED
Explicit events make systems easier to understand.
3. Make Invalid Transitions Impossible
Do not allow:
DELIVERED → PAID
unless the domain explicitly requires it.
4. Separate State from Context
State:
PAID
Context:
{
orderId,
customerId,
amount,
paymentId
}
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();
}
prefer:
FSM
↓
Domain Event
↓
Application / Infrastructure
This gives better separation.
Common FSM Mistakes
Mistake 1 — Using Too Many Boolean Flags
Instead of:
isLoading
isSuccess
isError
isCancelled
use:
IDLE
LOADING
SUCCESS
ERROR
CANCELLED
One state is often better than many independent booleans.
Mistake 2 — Allowing Impossible States
For example:
isLoading = true
isSuccess = true
An FSM can eliminate these combinations.
Mistake 3 — Putting Everything Into One FSM
Don't create:
OrderPaymentShippingInventoryNotificationFSM
Split independent concerns.
Order FSM
Payment FSM
Shipping FSM
Notification FSM
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
Mistake 5 — Treating Persistence as an Afterthought
If state matters, persist it.
For critical workflows:
state
version
transition history
event ID
timestamp
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
The State Pattern is an Object-Oriented design pattern where behavior is delegated to state objects.
Example:
Order
|
+-- PendingState
+-- PaidState
+-- ShippedState
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
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
Event-driven architecture:
Event → Subscribers
They can work together.
For example:
PaymentCompleted
|
v
Order FSM
|
v
PAID
|
v
OrderPaid
|
+----> Notification
+----> Shipping
+----> Analytics
FSM Mental Model
A very useful mental model is:
STATE
+
EVENT
+
GUARD
|
v
TRANSITION
|
+---- ACTION
|
v
NEW STATE
Or even more simply:
Where am I?
+
What happened?
+
Am I allowed?
↓
Where do I go?
+
What should I do?
The Most Important Formula
The core of a State Machine can be summarized as:
δ(State, Event) → State
For a more practical application:
Transition =
Current State
+
Event
+
Optional Guard
→
Next State
+
Optional Action
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 |
+-----------+
The machine guarantees that:
PENDING_PAYMENT
→ PAID
→ PROCESSING
→ SHIPPED
→ DELIVERED
is valid.
But:
PENDING_PAYMENT
→ SHIPPED
is invalid.
And:
DELIVERED
→ PENDING_PAYMENT
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
When Should You Avoid an FSM?
Not every problem needs one.
If the logic is simply:
const result = calculatePrice(input);
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?
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
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
In modern backend systems, State Machines are particularly valuable for workflows such as:
Orders
Payments
Authentication
Jobs
Retries
Shipping
Subscriptions
WebSocket Connections
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?
That is why State Machines remain useful from simple applications to production distributed systems.
At the simplest level:
State + Event → Next State
At the production level:
Event
↓
Validation
↓
Current State
↓
Guard
↓
FSM Transition
↓
New State
↓
Persistence
↓
Outbox Event
↓
Message Broker
↓
Distributed Consumers
↓
Observability
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)