Definition
- Why not free text – A plain string column lets any code set any value, allowing Delivered to Preparing, which corrupts reporting and triggers wrong actions.
- Transition table – Defines the complete set of states and which target states are reachable from each source; the only way to change status.
- Optimistic locking – A version field ensures concurrent transitions do not both succeed.
- Saga backbone – Each Saga step is a state transition; compensations are reverse transitions.
- Idempotent transitions – Re-applying an event on an already-transitioned order is a safe no-op.
Think of a loan application at National Bank of India moving through stages: Applied, Under Review, Approved, Disbursed, or Rejected. If this is just a free-text label, careless code could set it to Disbursed directly from Applied, skipping review and approval, which means money goes out with no checks. A state machine is a strict rulebook posted on the wall: from Applied you may only go to Under Review or Rejected, never straight to Disbursed.
Understanding the Concepts
Why is a free-text status dangerous?
If status is a plain string that any service can write, nothing stops a bug or race from setting an order to an impossible value or sequence.
package com.bank.order.antipattern;
public class FreeTextStatusWrong {
private String status; // plain String
// WRONG: any code can set any value
public void setStatus(String newStatus) {
this.status = newStatus;
}
// Bug: code accidentally sets Delivered -> Preparing
// Result: restaurant starts preparing an already-delivered order
// customer is charged twice
// reporting shows impossible state
}
How does a state machine prevent invalid transitions?
Define the complete set of states and a transition table listing which events are allowed from each state.
package com.bank.order.statemachine;
import java.util.*;
public enum OrderState {
CREATED, CONFIRMED, PREPARING, PICKED_UP, DELIVERED, CANCELLED, REFUNDED;
}
public enum OrderEvent {
CONFIRM, START_PREPARING, PICK_UP, DELIVER, CANCEL, REFUND;
}
public class OrderStateMachine {
private static final Map<OrderState, Map<OrderEvent, OrderState>> TRANSITIONS;
static {
TRANSITIONS = new EnumMap<>(OrderState.class);
TRANSITIONS.put(OrderState.CREATED, Map.of(
OrderEvent.CONFIRM, OrderState.CONFIRMED,
OrderEvent.CANCEL, OrderState.CANCELLED));
TRANSITIONS.put(OrderState.CONFIRMED, Map.of(
OrderEvent.START_PREPARING, OrderState.PREPARING,
OrderEvent.CANCEL, OrderState.CANCELLED));
TRANSITIONS.put(OrderState.PREPARING, Map.of(
OrderEvent.PICK_UP, OrderState.PICKED_UP,
OrderEvent.CANCEL, OrderState.CANCELLED));
TRANSITIONS.put(OrderState.PICKED_UP, Map.of(
OrderEvent.DELIVER, OrderState.DELIVERED));
TRANSITIONS.put(OrderState.DELIVERED, Map.of(
OrderEvent.REFUND, OrderState.REFUNDED));
// CANCELLED and REFUNDED: no transitions (terminal states)
TRANSITIONS.put(OrderState.CANCELLED, Map.of());
TRANSITIONS.put(OrderState.REFUNDED, Map.of());
}
private OrderState currentState;
private int version = 0;
public OrderStateMachine() {
this.currentState = OrderState.CREATED;
}
public OrderState transition(OrderEvent event) {
Map<OrderEvent, OrderState> allowed = TRANSITIONS.get(currentState);
if (allowed == null || !allowed.containsKey(event)) {
throw new IllegalStateException(
"Invalid transition: " + currentState + " + " + event);
}
currentState = allowed.get(event);
version++;
return currentState;
}
public OrderState getCurrentState() { return currentState; }
public int getVersion() { return version; }
public static void main(String[] args) {
OrderStateMachine order = new OrderStateMachine();
System.out.println("State: " + order.getCurrentState());
order.transition(OrderEvent.CONFIRM);
System.out.println("State: " + order.getCurrentState());
order.transition(OrderEvent.START_PREPARING);
System.out.println("State: " + order.getCurrentState());
try {
order.transition(OrderEvent.CONFIRM); // invalid!
} catch (IllegalStateException e) {
System.out.println("Rejected: " + e.getMessage());
}
}
}
State: CREATED
State: CONFIRMED
State: PREPARING
Rejected: Invalid transition: PREPARING + CONFIRM
How does it pair with the Saga pattern?
Each Saga step is a state transition. Compensations are reverse transitions.
package com.bank.order.saga;
public class OrderSagaWithStateMachine {
private final OrderStateMachine stateMachine;
public OrderSagaWithStateMachine(OrderStateMachine stateMachine) {
this.stateMachine = stateMachine;
}
public void executeSaga() {
// Step 1: Payment success -> CONFIRM
stateMachine.transition(OrderEvent.CONFIRM);
// Step 2: Restaurant accepts -> START_PREPARING
boolean restaurantAccepted = callRestaurant();
if (!restaurantAccepted) {
// Compensation: cancel + refund
stateMachine.transition(OrderEvent.CANCEL);
refundPayment();
return;
}
stateMachine.transition(OrderEvent.START_PREPARING);
// The state always tells you exactly where the Saga is
// and what compensation is needed.
}
private boolean callRestaurant() { return true; }
private void refundPayment() { }
}
How do idempotent transitions work?
Re-applying an event on an already-transitioned order is a safe no-op, not an error.
package com.bank.order.statemachine;
public class IdempotentStateMachine {
public OrderState transitionIdempotent(OrderState current, OrderEvent event,
OrderState expectedTarget) {
if (current == expectedTarget) {
// Already in the target state: idempotent no-op
return current;
}
// Normal transition
return transition(current, event);
}
// Saga retries mark-delivered on a DELIVERED order -> no-op, safe.
}
How does optimistic locking prevent concurrent corruption?
A version field ensures two concurrent transitions cannot both succeed.
package com.bank.order.repository;
public class OrderRepository {
public boolean updateState(String orderId, OrderState newState,
int expectedVersion) {
// UPDATE orders SET state = ?, version = version + 1
// WHERE order_id = ? AND version = ?
// Affected rows = 0 means someone else transitioned first.
int affected = executeUpdate(orderId, newState, expectedVersion);
return affected > 0;
}
private int executeUpdate(String id, OrderState s, int v) { return 1; }
}
How do you attach side effects to transitions?
Actions fire on entering a state, keeping behavior consistent with state.
package com.bank.order.statemachine;
import java.util.Map;
import java.util.function.Consumer;
public class TransitionActions {
private static final Map<OrderState, Consumer<String>> ON_ENTER = Map.of(
OrderState.CONFIRMED, orderId -> sendConfirmationSms(orderId),
OrderState.CANCELLED, orderId -> issueRefund(orderId),
OrderState.DELIVERED, orderId -> sendDeliveryReceipt(orderId)
);
public static void fireOnEnter(OrderState state, String orderId) {
Consumer<String> action = ON_ENTER.get(state);
if (action != null) action.accept(orderId);
}
private static void sendConfirmationSms(String id) { }
private static void issueRefund(String id) { }
private static void sendDeliveryReceipt(String id) { }
}
How does idempotent order creation work?
A client-generated idempotency key ensures retries never duplicate.
package com.bank.order.service;
public class IdempotentOrderCreation {
public Order create(String customerId, String idempotencyKey) {
Order existing = repository.findByIdempotencyKey(idempotencyKey);
if (existing != null) return existing; // no duplicate
Order order = new Order(customerId, idempotencyKey);
repository.save(order);
return order;
}
}
What are common interview follow-up questions?
How would you add a new state without breaking existing orders? How do you handle a transition that fails mid-side-effect? How do you visualize the state machine for non-technical stakeholders? What is the difference between a Mealy and a Moore state machine?
Visual Overview
┌──────────────────────────────────────────────────────────┐ │ ORDER STATE MACHINE │ ├──────────────────────────────────────────────────────────┤ │ │ │ ┌─────────┐ CONFIRM ┌───────────┐ PREPARE │ │ │ CREATED ├───────────▶│ CONFIRMED ├──────────▶ │ │ └────┬────┘ └─────┬─────┘ │ │ │ │ │ ┌──▼──────┐ │ │ CANCEL CANCEL │PREPARING│ │ │ │ │ └──┬──────┘ │ │ ▼ ▼ │ │ │ ┌──────────┐ ┌──────────┐ PICK_UP │ │ │CANCELLED │ │CANCELLED │ │ │ │ └──────────┘ └──────────┘ ┌───────▼──────┐ │ │ │ PICKED_UP │ │ │ └───────┬──────┘ │ │ DELIVER │ │ │ │ │ ┌───────▼──────┐ │ │ │ DELIVERED │ │ │ └───────┬──────┘ │ │ REFUND │ │ │ │ │ ┌───────▼──────┐ │ │ │ REFUNDED │ │ │ └──────────────┘ │ │ │ │ Invalid: DELIVERED + CONFIRM = REJECTED │ │ Idempotent: DELIVERED + DELIVER = no-op │ └──────────────────────────────────────────────────────────┘
Key Takeaway
A free-text status field lets any code set any value, allowing impossible states like Delivered to Preparing that corrupt reporting and trigger wrong actions like double-charging. At National Bank of India, exactly this happened: a delivered order jumped back to Preparing due to a race condition, triggering double preparation and a wrong refund. Pankaj replaced it with an explicit state machine that defines the complete set of states and a transition table of allowed moves.
The state machine makes invalid transitions impossible by construction. The transition method consults the table and rejects anything not listed, so Delivered plus Confirm throws an error instead of corrupting the order. Optimistic locking with a version field ensures concurrent transitions cannot both succeed. Side effects like sending confirmation SMS or issuing refunds are attached to transitions so behavior is always consistent with state.
The state machine is the Saga's backbone. Each Saga step (payment, restaurant acceptance, courier pickup) is a state transition, and compensation is a reverse transition. The state always tells you exactly where the Saga is and what compensation is needed. Idempotent transitions mean re-applying mark-delivered on an already-delivered order is a harmless no-op, which matters because Saga steps are at-least-once. Idempotent order creation with a client key ensures retries never duplicate.
Remember one sentence: An explicit state machine makes invalid order transitions unreachable by construction, provides the clean transitions a Saga uses for multi-service coordination with compensations, and combined with idempotent creation and transitions keeps the order backend consistent under retries.
---