Back to Scenarios & Design Questions
Q64Question 64 of 67

Why model an order's status as an explicit state machine rather than a free-text status field

Key Concepts
  • A free-text status allows impossible states like Delivered to Preparing; a state machine rejects invalid transitions by construction.
  • The transition table centralizes all lifecycle rules in one place instead of scattered if-checks.
  • Optimistic locking on the state version makes concurrent transitions safe.
  • The state machine is the Saga's backbone: each Saga step advances the state, and compensation reverses it.
  • Idempotent transitions mean re-applying mark-delivered on an already-delivered order is a no-op.
  • Side effects (send confirmation, issue refund) are attached to transitions for consistency with state.
  • A transition history provides a full audit trail of every state change with timestamps.

Definition

A state machine allows only defined states and legal transitions so impossible jumps are rejected, pairing with Sagas for multi-step workflows and idempotency keys so retries never create duplicates.
  • 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.

JAVA
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.

JAVA
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());
        }
    }
}
TEXT
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.

JAVA
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.

JAVA
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.

JAVA
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.

JAVA
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.

JAVA
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

DIAGRAM
┌──────────────────────────────────────────────────────────┐
│              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.

---

Share
Use ← → arrow keys to navigate