Order Management System Low-Level Design: Order State Machine, Inventory Reservation, and Fulfillment Coordination

Order State Machine

An order progresses through a defined set of states with controlled transitions:

CART → PLACED → PAYMENT_PENDING → CONFIRMED → PICKING → PACKED → SHIPPED → DELIVERED
                                ↘ PAYMENT_FAILED → CANCELLED
              ↘ CANCELLED (user cancellation before payment)
CONFIRMED → CANCELLED (cancellation window, before picking starts)
DELIVERED → RETURN_REQUESTED → RETURNING → RETURNED

State transitions are validated in the domain layer — an order in SHIPPED state cannot transition directly to CANCELLED. Each transition is persisted as an immutable event in an order_events table, giving a full audit trail. The current state is denormalized into the orders table for query performance.

Order Schema

orders:
  order_id        UUID PRIMARY KEY DEFAULT gen_random_uuid()
  user_id         UUID NOT NULL
  status          VARCHAR(30) NOT NULL DEFAULT 'PLACED'
  total_amount    NUMERIC(12,2) NOT NULL
  currency        CHAR(3) NOT NULL DEFAULT 'USD'
  shipping_addr   JSONB NOT NULL
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
  version         INT NOT NULL DEFAULT 1    -- optimistic locking

order_items:
  item_id         UUID PRIMARY KEY DEFAULT gen_random_uuid()
  order_id        UUID NOT NULL REFERENCES orders(order_id)
  product_id      UUID NOT NULL
  sku             VARCHAR(100) NOT NULL
  quantity        INT NOT NULL CHECK (quantity > 0)
  unit_price      NUMERIC(10,2) NOT NULL
  subtotal        NUMERIC(12,2) GENERATED ALWAYS AS (quantity * unit_price) STORED

Inventory Reservation

Inventory reservation uses a two-phase approach to avoid overselling while keeping reservation windows short:

  • Soft reserve (on order placement): Decrement available_quantity by order quantity. The item is held for this order but not yet committed.
  • Hard deduct (on payment confirmation): Move quantity from reserved to committed. Update reserved_quantity and sold_quantity.
  • Release (on cancellation or timeout): Increment available_quantity back. A background job releases soft reserves older than the reservation timeout (e.g., 30 minutes for unpaid orders).
inventory:
  product_id          UUID PRIMARY KEY
  sku                 VARCHAR(100) NOT NULL UNIQUE
  total_quantity      INT NOT NULL
  reserved_quantity   INT NOT NULL DEFAULT 0   -- soft reserved
  sold_quantity       INT NOT NULL DEFAULT 0   -- hard deducted
  available_quantity  INT GENERATED ALWAYS AS
                        (total_quantity - reserved_quantity - sold_quantity) STORED
  version             INT NOT NULL DEFAULT 1   -- optimistic locking

Optimistic Locking for Inventory

Concurrent orders for the same SKU race on inventory. Optimistic locking detects conflicts without row-level locks:

-- Attempt soft reserve with version check
UPDATE inventory
SET reserved_quantity = reserved_quantity + $qty,
    version = version + 1
WHERE product_id = $product_id
  AND version = $expected_version
  AND available_quantity >= $qty;

-- If 0 rows updated: either version mismatch (retry) or insufficient stock (reject)

A version mismatch means another transaction modified the row concurrently. The service reloads the current version and retries up to N times. After N retries, return a conflict error to the caller. For high-contention SKUs (flash sales), use a Redis counter for the hot path and reconcile with the database asynchronously.

Event-Driven Coordination

Services coordinate through events rather than direct calls, keeping each service independent:

Order Service publishes:  OrderPlaced, OrderConfirmed, OrderCancelled
Payment Service listens:  OrderPlaced → initiate payment
Payment Service publishes: PaymentCaptured, PaymentFailed
Inventory Service listens: OrderPlaced → soft reserve
                           PaymentCaptured → hard deduct
                           OrderCancelled → release reserve
Fulfillment Service listens: PaymentCaptured → create shipment task

Events are published to Kafka topics partitioned by order_id, ensuring ordered delivery per order. Each service maintains its own state and handles idempotent event processing (deduplicate on event_id).

Saga Compensation

If payment fails after inventory has been soft-reserved, the saga must compensate:

OrderPlaced event → inventory soft-reserves → payment initiated
PaymentFailed event → OrderService transitions to PAYMENT_FAILED
                    → publishes OrderCancelled
OrderCancelled event → inventory releases soft reserve

Each compensating step is idempotent. If the OrderCancelled event is delivered twice, the inventory release must be safe to apply twice (check current status before releasing). Saga state can be tracked in a sagas table (saga_id, order_id, current_step, status) for observability and manual intervention.

Split Shipments and Returns

A single order may ship from multiple warehouses if items are not co-located. The fulfillment service creates one shipment record per warehouse assignment:

shipments:
  shipment_id   UUID PRIMARY KEY
  order_id      UUID NOT NULL REFERENCES orders(order_id)
  warehouse_id  UUID NOT NULL
  status        VARCHAR(30) NOT NULL   -- CREATED, PICKED, PACKED, SHIPPED, DELIVERED
  tracking_num  VARCHAR(100)
  carrier       VARCHAR(50)
  shipped_at    TIMESTAMPTZ
  delivered_at  TIMESTAMPTZ

shipment_items:
  shipment_id   UUID REFERENCES shipments(shipment_id)
  item_id       UUID REFERENCES order_items(item_id)
  quantity      INT NOT NULL

The order transitions to DELIVERED only when all shipments are in DELIVERED state. Partial delivery is surfaced to the customer through shipment-level tracking rather than order-level status. Returns create a return_request linked to specific shipment items, triggering the reverse logistics flow and refund pipeline.

{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “What are the states in an order management state machine?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “A typical order state machine transitions through states such as PENDING, CONFIRMED, PAYMENT_AUTHORIZED, FULFILLMENT_IN_PROGRESS, SHIPPED, DELIVERED, and CANCELLED or REFUNDED. Each transition is triggered by a domain event and enforced by guard conditions, ensuring invalid state jumps (e.g., shipping a cancelled order) are rejected at the application layer.”
}
},
{
“@type”: “Question”,
“name”: “How is inventory reservation handled to prevent oversell?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Inventory reservation uses an optimistic-locking or compare-and-swap update on a reserved_quantity column, decrementing available stock atomically when an order is placed. A reservation TTL (e.g., 15 minutes) is enforced by a background job that releases expired holds, returning stock to the available pool if payment is not completed in time.”
}
},
{
“@type”: “Question”,
“name”: “How does a saga pattern handle payment failure after inventory reservation?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “In a choreography-based saga, a PaymentFailed event triggers a compensating InventoryReleaseCommand that increments available_quantity back to its pre-reservation value. In an orchestration-based saga, the central saga orchestrator explicitly invokes the compensating transaction on the inventory service, then marks the order as CANCELLED and publishes a notification event.”
}
},
{
“@type”: “Question”,
“name”: “How are split shipments modeled?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “A parent order is decomposed into one or more Fulfillment records, each linked to a warehouse location and carrying its own line items, carrier tracking number, and independent state machine. The parent order's status is derived from the aggregate state of its fulfillments, becoming FULLY_SHIPPED only when every fulfillment reaches the SHIPPED state.”
}
}
]
}

See also: Scale AI Interview Guide 2026: Data Infrastructure, RLHF Pipelines, and ML Engineering

See also: Shopify Interview Guide

See also: Stripe Interview Guide 2026: Process, Bug Bash Round, and Payment Systems

Scroll to Top