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:
total_amount > 0reserved_at IS NULLβstate β {pending, failed}delivered_at IS NULLβstate β deliveredrefund_amount <= total_amountstate = 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.
