Table of Content
- The real problem: implicit state
- Where state machines show up
- The minimal model (no academic fluff)
- 1) Define states that can’t overlap
- 2) Define actions as intent
- 3) Define transitions as the law
- The execution engine: enforce transitions, nothing else
- What state machines do not solve
- How to go further without turning it into spaghetti
- Two real-world cases where this saves your ass
- 1) Webhook-heavy payment systems
- 2) Order lifecycles
- Final thought
- Recommended service
The State Machine Pattern: Stop Guessing What Your Code Is Doing
Let’s be brutally honest: most production bugs are state bugs.
Not syntax errors. Not missing semicolons. But the kind of bug where everyone stares at the logs and asks: “how the hell did we end up here?”
Refunds applied twice. Orders that are somhow both paid and cancelled. Webhooks replayed into chaos.
If your system can’t answer “where am I?” and “what am I allowed to do next?”, you don’t have business logic — you have a liability.
That’s exactly what a state machine fixes: it turns invisible rules into explicit rules.
The real problem: implicit state
Most teams “have a state machine” already. They just don’t either know or admit it.
They encode it implicitly with if / else, status flags, and scattered checks in controllers, workers, and cron jobs. The code looks simple, but the runtime behavior becomes impossible to reason about because the rules are spread everywhere.
A state machine does one thing: it makes state explicit and makes transitions illegal by default.
Where state machines show up
Anytime something changes over time and not all actions are always allowed, you are dealing with a state machine.
Think payments, orders, subscriptions, delivery workflows, CI pipelines, network protocols, embedded devices, game engines. You can ignore it, but you can’t escape it.
Ignoring it doesn’t remove complexity. It just moves it from design to runtime.
The minimal model (no academic fluff)
A real state machine is only three concepts.
States: where you are.
Actions (events): what is requested or what happened.
Transitions: what is allowed from one state to the next.
That’s it. Everything else is optional and should be earned.
1) Define states that can’t overlap
Your states must be mutually exclusive, exhaustive, and meaningful from a business point of view. If two states can be true at the same time, your model is broken.
enum State {
PENDING = 'Pending',
PROCESSING = 'Processing',
PAID = 'Paid',
FAILED = 'Failed',
CANCELLED = 'Cancelled',
REFUNDED = 'Refunded',
}2) Define actions as intent
Actions are not results. They are intent.
A result is “Paid”. An action is “ReceiveSuccess”. One describes a state, the other describes an event.
enum Action {
INITIATE_PAYMENT = 'InitiatePayment',
RECEIVE_SUCCESS = 'ReceiveSuccess',
RECEIVE_FAILURE = 'ReceiveFailure',
CANCEL = 'Cancel',
RETRY = 'Retry',
REFUND = 'Refund',
}3) Define transitions as the law
The transition table is your contract.
If it’s not in the table, it’s not allowed. No “just this once”. No hidden exceptions. If the business wants a new rule, you add a transition. That’s how you keep systems sane.
type TransitionMap = {
[key in State]?: {
[key in Action]?: State;
};
};
const transitions: TransitionMap = {
[State.PENDING]: {
[Action.INITIATE_PAYMENT]: State.PROCESSING,
[Action.CANCEL]: State.CANCELLED,
},
[State.PROCESSING]: {
[Action.RECEIVE_SUCCESS]: State.PAID,
[Action.RECEIVE_FAILURE]: State.FAILED,
[Action.CANCEL]: State.CANCELLED,
},
[State.FAILED]: {
[Action.RETRY]: State.PROCESSING,
[Action.CANCEL]: State.CANCELLED,
},
[State.PAID]: {
[Action.REFUND]: State.REFUNDED,
},
};The execution engine: enforce transitions, nothing else
Here’s the rule most people break: the state machine should not do side effects.
No API calls. No DB writes. No webhook handling. No “business processing”.
It only validates and updates state. Side effects happen outside, after legality is checked.
class PaymentStateMachine {
private currentState: State;
constructor(initialState: State = State.PENDING) {
this.currentState = initialState;
}
public transition(action: Action): State {
const nextState = transitions[this.currentState]?.[action];
if (!nextState) {
throw new Error(`[FSM Error]: Illegal transition from ${this.currentState} via ${action}`);
}
console.log(`[FSM Update]: ${this.currentState} ➡️ ${nextState} (via ${action})`);
this.currentState = nextState;
return this.currentState;
}
public getState(): State {
return this.currentState;
}
}This class has one job: make illegal transitions impossible.
What state machines do not solve
They don’t replace your database. They don’t magically solve distributed consistency. They don’t “fix concurrency”.
What they do is more valuable: they prevent your business rules from becoming a fog.
Yes, the transition table can grow. When workflows become complex, you compose machines (or use a workflow engine). The important point is that complexity is explicit and reviewable.
How to go further without turning it into spaghetti
When you scale beyond the toy version, there are a few upgrades that actually matter.
Persist the state in a database (or go full event sourcing if you need an audit trail). Add guards to validate conditions before a transition. Emit domain events on transitions so other parts of the system can react.
And just as important: keep your discipline.
Never introduce “temporary bypasses”. Never mutate state outside the machine. Never let infrastructure decide business legality.
That’s how systems rot.
Two real-world cases where this saves your ass
1) Webhook-heavy payment systems
Webhooks arrive late, arrive twice, and arrive out of order. You can fight the network, or you can design for reality.
With a state machine, every webhook becomes a question:
Given my current state, is this event meaningful?
If yes, transition. If not, ignore. No drama.
This is also how you get idempotency “for free”: duplicate events become harmless because illegal transitions simply don’t exist.
type PaymentWebhook =
| 'charge.succeeded'
| 'charge.failed'
| 'charge.refunded'
| 'payment_intent.canceled';
const webhookToAction: Record<PaymentWebhook, Action> = {
'charge.succeeded': Action.RECEIVE_SUCCESS,
'charge.failed': Action.RECEIVE_FAILURE,
'charge.refunded': Action.REFUND,
'payment_intent.canceled': Action.CANCEL,
};
async function handlePaymentWebhook(event: PaymentWebhook, paymentId: string) {
const payment = await repo.getPayment(paymentId); // state persisted elsewhere
const machine = new PaymentStateMachine(payment.state as State);
const action = webhookToAction[event];
if (!action) return; // unknown events ignored
try {
const nextState = machine.transition(action);
await repo.updateState(paymentId, nextState); // side effect after legality check
} catch (err) {
// duplicate or out-of-order webhook; we log and move on
console.warn(`[Webhook ignored]: ${event} while in ${payment.state}`);
}
}2) Order lifecycles
Orders don’t teleport from Created to Delivered. Each step constrains the next. That constraint protects revenue, users, and operations.
enum OrderState {
CREATED = 'Created',
PAID = 'Paid',
PACKED = 'Packed',
SHIPPED = 'Shipped',
DELIVERED = 'Delivered',
RETURN_REQUESTED = 'ReturnRequested',
RETURNED = 'Returned',
CANCELLED = 'Cancelled',
}
enum OrderAction {
CONFIRM_PAYMENT = 'ConfirmPayment',
PACK = 'Pack',
SHIP = 'Ship',
CONFIRM_DELIVERY = 'ConfirmDelivery',
REQUEST_RETURN = 'RequestReturn',
CONFIRM_RETURN = 'ConfirmReturn',
CANCEL = 'Cancel',
}
type OrderTransitionMap = {
[key in OrderState]?: { [key in OrderAction]?: OrderState };
};
const orderTransitions: OrderTransitionMap = {
[OrderState.CREATED]: {
[OrderAction.CONFIRM_PAYMENT]: OrderState.PAID,
[OrderAction.CANCEL]: OrderState.CANCELLED,
},
[OrderState.PAID]: {
[OrderAction.PACK]: OrderState.PACKED,
[OrderAction.CANCEL]: OrderState.CANCELLED,
},
[OrderState.PACKED]: {
[OrderAction.SHIP]: OrderState.SHIPPED,
},
[OrderState.SHIPPED]: {
[OrderAction.CONFIRM_DELIVERY]: OrderState.DELIVERED,
[OrderAction.REQUEST_RETURN]: OrderState.RETURN_REQUESTED,
},
[OrderState.DELIVERED]: {
[OrderAction.REQUEST_RETURN]: OrderState.RETURN_REQUESTED,
},
[OrderState.RETURN_REQUESTED]: {
[OrderAction.CONFIRM_RETURN]: OrderState.RETURNED,
},
};
class OrderStateMachine {
constructor(private currentState: OrderState = OrderState.CREATED) {}
transition(action: OrderAction): OrderState {
const next = orderTransitions[this.currentState]?.[action];
if (!next) throw new Error(`Illegal order transition: ${this.currentState} -> ${action}`);
this.currentState = next;
return this.currentState;
}
getState() {
return this.currentState;
}
}When a support agent manually triggers an action, the machine enforces the same rules. Human overrides become explicit business rules, not silent corruption.
Final thought
A state machine isn’t an abstraction for juniors. It’s a discipline.
Make state explicit. Make rules explicit. Make failure explicit.
If your system matters, guessing is not acceptable.
Recommended service
Need help designing business‑critical workflows that don’t explode at scale? That’s exactly what I do with Custom Engineering. Custom Engineering
Critical Systems
Engineering discipline from high-stakes environments: security, resilience, auditability, and predictable performance — applied to real-world products.
Learn more