RoadmapDay 49 / 80
System DesignMonth 3 · Week 10

Day 49: Design: E-Commerce Checkout Flow with State Machines

Model a multi-step checkout flow as an explicit finite state machine to eliminate the impossible-state bugs ad-hoc boolean flags create.

Mark this day complete

Study

Concepts

Why boolean flags fail for multi-step flows

A checkout flow modeled with independent booleans (`isLoading`, `hasError`, `isPaymentProcessing`, `isSuccess`) allows IMPOSSIBLE combinations the code was never designed for — `isLoading && isSuccess` both true simultaneously is a state your reducer/component can accidentally reach through a bug, and every consumer of that state has to defensively guess which combination is "real". A state machine instead defines a finite, named set of MUTUALLY EXCLUSIVE states (`idle`, `validatingCart`, `enteringPayment`, `processingPayment`, `success`, `failed`) plus explicit transitions between them — the impossible combinations are structurally unrepresentable, not just avoided by convention.

This matters most exactly in checkout because the cost of a bug is directly financial: double-charging a card, showing "success" after a failed payment, or allowing a user to resubmit a payment already in flight are the kinds of bugs a state machine's explicit transition table prevents by construction (a `SUBMIT_PAYMENT` event is simply not defined as valid from the `processingPayment` state, so a double-click cannot fire it twice).

Modeling with XState (or the same idea, hand-rolled)

A library like XState makes the state chart explicit and visualizable: each state lists which events it accepts and which state each event transitions to, with side effects (API calls) modeled as invoked services attached to a state, whose resolution/rejection fires a further transition — this keeps "what can happen next" fully declarative and inspectable, rather than scattered across scattered `if` conditions in event handlers.

Even without adopting a library, the DISCIPLINE of designing the state chart on paper first — enumerating every state, every valid event per state, and every transition — catches edge cases (what happens if the network drops during `processingPayment`? what if the cart changes while the user is on the payment step?) before they become production incidents, which is exactly the mindset a system design interview is testing for.

See It

Visualizations

Visualization

Checkout as an explicit state machine

idle

cart review, "Checkout" button available

validatingCart

stock + price re-check before payment

enteringPayment

form is editable; SUBMIT_PAYMENT is the only forward event

processingPayment

form locked; no event can trigger a second charge

success | failed

terminal or retryable states — never both true at once

Build It

Code Examples

A checkout state machine with XState

js
import { createMachine, assign } from 'xstate';

const checkoutMachine = createMachine({
  id: 'checkout',
  initial: 'idle',
  context: { error: null },
  states: {
    idle: { on: { START_CHECKOUT: 'validatingCart' } },
    validatingCart: {
      invoke: {
        src: 'validateCart',
        onDone: 'enteringPayment',
        onError: { target: 'failed', actions: assign({ error: (_, e) => e.data }) },
      },
    },
    enteringPayment: { on: { SUBMIT_PAYMENT: 'processingPayment' } },
    // Notice: SUBMIT_PAYMENT is NOT a valid event here — a double
    // click while processing simply does nothing, by construction.
    processingPayment: {
      invoke: {
        src: 'chargeCard',
        onDone: 'success',
        onError: { target: 'failed', actions: assign({ error: (_, e) => e.data }) },
      },
    },
    success: { type: 'final' },
    failed: { on: { RETRY: 'enteringPayment' } },
  },
});

Consuming it in a component — no boolean-flag juggling

jsx
import { useMachine } from '@xstate/react';

function Checkout() {
  const [state, send] = useMachine(checkoutMachine, {
    services: {
      validateCart: () => api.validateCart(),
      chargeCard: (ctx, event) => api.chargeCard(event.paymentDetails),
    },
  });

  if (state.matches('processingPayment')) return <ProcessingSpinner />;
  if (state.matches('success')) return <OrderConfirmed />;
  if (state.matches('failed')) return (
    <ErrorBanner message={state.context.error} onRetry={() => send('RETRY')} />
  );
  if (state.matches('enteringPayment')) {
    return <PaymentForm onSubmit={(details) => send({ type: 'SUBMIT_PAYMENT', paymentDetails: details })} />;
  }
  return <CartReview onCheckout={() => send('START_CHECKOUT')} />;
}

Remember

Key Takeaways

  • Independent boolean flags allow impossible state combinations; a state machine makes them structurally unrepresentable.
  • In checkout specifically, impossible states translate directly into financial bugs — double charges, false "success", resubmitted payments.
  • A state chart lists, per state, exactly which events are valid — an invalid event (double-click SUBMIT_PAYMENT while processing) is simply a no-op.
  • Design the state chart on paper FIRST, enumerating every state/event/transition, before writing any implementation.
  • Side effects (API calls) attach to specific states as invoked services, keeping "what happens next" fully declarative.

Do It

Practice

  1. 1Draw the full state chart (on paper) for a checkout flow that also supports an "apply promo code" step and a "select shipping method" step.
  2. 2Implement the XState checkout machine above against mocked validateCart/chargeCard services, including the failed → retry path.
  3. 3Identify one impossible state your current or a past project's boolean-flag-based flow could reach, and redesign it as a state chart.