B2B platform for digital goods

Order State Machine Design 2026: Modelling Order Lifecycles in Digital Goods

Production design of a B2B order FSM: 6 core states, transition invariants, audit log, and TypeScript implementation.

Order State Machine Design 2026: Modelling Order Lifecycles in Digital Goods

The order state machine is the heart of any B2B fulfilment. Correct design makes the system observable, debuggable and audit-friendly. Bad design breeds impossible states and race conditions you can chase for months. This article is a detailed guide to designing an FSM for digital-goods orders.

1. Six core states

enum OrderState {
  PENDING = 'pending',         // created, awaiting payment + reservation
  RESERVED = 'reserved',       // inventory locked for this order
  PROCESSING = 'processing',   // supplier API called, awaiting webhook
  DELIVERED = 'delivered',     // success, code released to customer
  FAILED = 'failed',           // terminal β€” supplier rejected or timed out
  REFUNDED = 'refunded',       // terminal β€” funds returned to customer
}

Allowed transitions (graph):

pending     β†’ reserved | failed
reserved    β†’ processing | failed
processing  β†’ delivered | failed
delivered   β†’ refunded
failed      β†’ (terminal)
refunded    β†’ (terminal)

Any other transition (e.g., delivered β†’ processing) is invalid and must throw.

2. Invariants

An invariant is a property always true regardless of state. For an order:

  1. total_amount > 0
  2. reserved_at IS NULL ⇔ state ∈ {pending, failed}
  3. delivered_at IS NULL ⇔ state β‰  delivered
  4. refund_amount <= total_amount
  5. state = refunded β‡’ delivered_at IS NOT NULL

Check invariants both inside the FSM transition handler and in the DB via CHECK constraints:

ALTER TABLE orders ADD CONSTRAINT chk_refund_amount
  CHECK (refund_amount <= total_amount);
ALTER TABLE orders ADD CONSTRAINT chk_refunded_delivered
  CHECK (state != 'refunded' OR delivered_at IS NOT NULL);

3. XState or enum+switch?

For small/medium projects, enum + switch is enough:

async function transition(order: Order, event: OrderEvent): Promise<Order> {
  const next = nextState(order.state, event.type);
  if (!next) throw new Error(`invalid transition ${order.state} + ${event.type}`);
  return db.tx(async (tx) => {
    const updated = await tx.orders.update(order.id, {
      state: next,
      version: order.version + 1, // optimistic lock
    }, { where: { version: order.version } });
    await tx.audit.insert({
      event_id: randomUUID(),
      order_id: order.id,
      from_state: order.state,
      to_state: next,
      actor: event.actor,
      timestamp: new Date(),
    });
    return updated;
  });
}

For complex flows (multi-supplier, partial-delivery, fraud-hold) XState provides a visual diagram and hierarchical states.

4. Implementation comparison

Approach LoC Visualisable Type-safe Performance
Boolean flags 50 No No Best
Enum + switch 150 No Strong Best
XState 200+ Yes (Inspector) Strong Good
Temporal workflow 500+ Yes Strong Good (async)

FoxReload's internal core uses enum+switch (suits a p95 transition latency of 8ms). For partners we recommend XState for visualising order lifecycle in the UI.

CTA

The FoxReload API exposes the current state and audit log via GET /v1/orders/{id}/events β€” the webhook event stream mirrors the internal FSM. Get access.

Frequently asked questions

How many states should an order FSM have?
At least 5–6 for digital goods: pending β†’ reserved β†’ processing β†’ delivered β†’ (refunded), with failed as an absorbing terminal state. Slack states like awaiting_payment or partial_fulfillment should be added only if business logic demands β€” every extra state adds ~5% in bugs.
Can I use boolean flags instead of a state machine?
No. Flags like is_paid, is_shipped, is_refunded create impossible states (e.g., refunded=true while is_shipped=false). An enum-based FSM guarantees each order is in exactly one valid state. This eliminates 30%+ of state-related bugs.
How do I roll back an order on error?
An FSM doesn't roll back. From processing, on supplier error, transition to failed (terminal); then a separate failed β†’ refunded transition runs the refund. No backward edges β€” they break the audit trail.
What goes in the audit log for each transition?
event_id (UUIDv4), order_id, from_state, to_state, timestamp, actor (user_id or 'system'), reason, source_ip, metadata (e.g., supplier_id on processing→delivered). Append-only; no updates on existing rows.
Get FoxReload API access

Related articles