System Design ·

Designing an E-Commerce Order System: Cart Management, Checkout Orchestration, and Fulfillment Workflows at Scale

A practical guide to e-commerce order system design covering cart state management, multi-step checkout orchestration, order state machines, fulfillment workflows, and the consistency challenges of coordinating payment, inventory, and order services.

Designing an E-Commerce Order System: Cart Management, Checkout Orchestration, and Fulfillment Workflows at Scale

Most e-commerce order systems look simple until you operate one at scale. The basic path (add to cart, enter address, pay, ship) is straightforward. The problems appear at the edges: what happens when inventory sells out mid-checkout, when a payment authorizes but the order fails to persist, when a warehouse ships three items but holds the fourth for restock, or when a customer wants a refund for the two items that arrived damaged.

This article walks through the full lifecycle of an order from cart through fulfillment, focusing on the consistency problems and the concrete patterns that solve them.

Cart State Management

Ephemeral vs Persistent Carts

The first decision is where cart state lives. Two options: session-scoped (ephemeral) or database-backed (persistent).

An ephemeral cart lives in a signed cookie or a Redis key with a TTL. It is fast, has zero database load, and requires no cleanup jobs. The tradeoff: you lose the cart on session expiry, you cannot sync across devices, and you cannot recover it for remarketing purposes.

A persistent cart lives in a database row linked to a user or a guest token. It survives browser closes, enables cross-device sync, and feeds abandonment flows.

In practice, most production systems use both: an ephemeral layer (Redis, short TTL) as a write buffer, flushed to the database on login or periodically.

interface CartItem {
  productId: string;
  variantId: string;
  quantity: number;
  unitPriceSnapshot: number; // price at add-to-cart time
  addedAt: Date;
}

interface Cart {
  id: string;
  userId: string | null;   // null for guest carts
  guestToken: string | null;
  items: CartItem[];
  createdAt: Date;
  updatedAt: Date;
  expiresAt: Date | null;
}

One important field: unitPriceSnapshot. Prices can change while a cart is open. You need to decide whether to lock the price at add time (better UX, requires staleness checks) or re-fetch at checkout (accurate, but surprises users). Most implementations snapshot at add time and re-validate at checkout, showing a “price changed” warning if there is a discrepancy.

Cart Merging on Login

When a guest user authenticates, you have two carts: one keyed by the guest token, one keyed by the user ID from a previous session. Merge strategies:

  • Replace: discard the user’s persisted cart, use the guest cart. Simple but loses previous session state.
  • Union: merge items, taking the higher quantity when the same variant appears in both. Preserves intent but can produce unexpectedly large quantities.
  • Interactive: surface both carts, let the user decide. High implementation cost, rarely worth it unless your average cart value is high.

The union strategy works well for most cases. Implement it as an atomic operation that reads both carts, merges in application memory, and writes the merged result in a single transaction.

async function mergeCarts(
  guestCartId: string,
  userId: string,
  db: Database
): Promise<Cart> {
  return db.transaction(async (tx) => {
    const [guestCart, userCart] = await Promise.all([
      tx.findCart({ id: guestCartId }),
      tx.findCart({ userId }),
    ]);

    if (!guestCart) return userCart ?? createEmptyCart(userId);
    if (!userCart) {
      await tx.updateCart(guestCartId, { userId, guestToken: null });
      return guestCart;
    }

    const merged = new Map<string, CartItem>();
    for (const item of userCart.items) {
      merged.set(item.variantId, item);
    }
    for (const item of guestCart.items) {
      const existing = merged.get(item.variantId);
      merged.set(item.variantId, {
        ...item,
        quantity: existing
          ? Math.max(existing.quantity, item.quantity)
          : item.quantity,
      });
    }

    await tx.deleteCart(guestCartId);
    return tx.updateCart(userCart.id, { items: [...merged.values()] });
  });
}

Checkout Orchestration

Checkout is a multi-step process that coordinates several external dependencies: address validation, tax calculation, and payment authorization. Each step can fail independently. The orchestrator needs to handle partial failures cleanly.

Step Sequence

A typical checkout has this shape:

  1. Lock inventory (reserve, not decrement)
  2. Validate shipping address
  3. Calculate tax
  4. Authorize payment
  5. Persist the order
  6. Release inventory lock and decrement stock
  7. Publish order-created event

The worst place to fail is between steps 4 and 5: payment authorized, order not yet persisted. This is the classic two-generals problem in e-commerce.

Inventory Reservation

Do not decrement inventory at checkout start. Reserve it. A reservation is a soft hold with an expiry:

interface InventoryReservation {
  id: string;
  variantId: string;
  quantity: number;
  checkoutSessionId: string;
  expiresAt: Date;  // typically 10-15 minutes
}

A background job sweeps expired reservations every minute and releases the held stock. This means a customer who abandons mid-checkout does not permanently remove inventory from the pool.

-- Atomic reservation: succeeds only if available stock >= requested quantity
WITH available AS (
  SELECT quantity_available - COALESCE(
    (SELECT SUM(quantity) FROM inventory_reservations
     WHERE variant_id = $1 AND expires_at > NOW()),
    0
  ) AS net_available
  FROM inventory
  WHERE variant_id = $1
  FOR UPDATE
)
INSERT INTO inventory_reservations (id, variant_id, quantity, checkout_session_id, expires_at)
SELECT gen_random_uuid(), $1, $2, $3, NOW() + INTERVAL '15 minutes'
FROM available
WHERE net_available >= $2
RETURNING id;

If the INSERT returns no row, stock is insufficient. Return the error before touching payment.

Tax Calculation and Address Validation

These are typically external API calls (Avalara, TaxJar, or a self-hosted service). Run them in parallel where possible:

async function prepareCheckout(session: CheckoutSession) {
  const [addressResult, taxResult] = await Promise.allSettled([
    validateAddress(session.shippingAddress),
    calculateTax({
      lineItems: session.items,
      destination: session.shippingAddress,
      origin: session.warehouseAddress,
    }),
  ]);

  if (addressResult.status === "rejected") {
    throw new CheckoutError("INVALID_ADDRESS", addressResult.reason);
  }
  if (taxResult.status === "rejected") {
    // Tax APIs can be flaky. Decide: fail checkout or proceed with estimated tax?
    // For compliance-sensitive markets, fail. For others, use a fallback rate.
    throw new CheckoutError("TAX_CALCULATION_FAILED", taxResult.reason);
  }

  return {
    validatedAddress: addressResult.value,
    taxBreakdown: taxResult.value,
  };
}

Payment Authorization Flow

Authorize first, capture later. Authorization places a hold on the customer’s card without charging it. Capture happens when the order ships (or immediately, for digital goods). This gives you a window to cancel without a refund cycle.

The critical pattern here is idempotency. If your authorization request times out, you do not know whether the payment provider received it. Re-sending without an idempotency key will create a duplicate authorization.

async function authorizePayment(
  checkoutSessionId: string,
  amount: number,
  paymentMethodId: string,
  paymentClient: PaymentClient
): Promise<AuthorizationResult> {
  // Idempotency key tied to the checkout session.
  // Same key on retry = same result, no double-auth.
  const idempotencyKey = `auth-${checkoutSessionId}`;

  return paymentClient.authorize({
    amount,
    currency: "USD",
    paymentMethodId,
    idempotencyKey,
    captureMethod: "manual",
  });
}

After authorization succeeds, persist the order synchronously in the same database transaction that records the authorization token. If the persist fails, you have an orphaned authorization: cancel it in a compensating job.

Order State Machine

An order is not a single state. It branches based on fulfillment outcomes.

type OrderStatus =
  | "pending_payment"
  | "payment_authorized"
  | "confirmed"
  | "partially_fulfilled"
  | "fulfilled"
  | "partially_cancelled"
  | "cancelled"
  | "partially_refunded"
  | "refunded";

interface OrderTransition {
  from: OrderStatus;
  to: OrderStatus;
  trigger: string;
}

const VALID_TRANSITIONS: OrderTransition[] = [
  { from: "pending_payment",      to: "payment_authorized",   trigger: "payment.authorized" },
  { from: "payment_authorized",   to: "confirmed",            trigger: "order.confirmed" },
  { from: "payment_authorized",   to: "cancelled",            trigger: "order.cancelled" },
  { from: "confirmed",            to: "partially_fulfilled",  trigger: "shipment.partial" },
  { from: "confirmed",            to: "fulfilled",            trigger: "shipment.complete" },
  { from: "confirmed",            to: "cancelled",            trigger: "order.cancelled" },
  { from: "partially_fulfilled",  to: "fulfilled",            trigger: "shipment.complete" },
  { from: "partially_fulfilled",  to: "partially_cancelled",  trigger: "item.cancelled" },
  { from: "fulfilled",            to: "partially_refunded",   trigger: "refund.partial" },
  { from: "fulfilled",            to: "refunded",             trigger: "refund.full" },
  { from: "partially_refunded",   to: "refunded",             trigger: "refund.full" },
];

function transition(
  order: Order,
  trigger: string
): OrderStatus {
  const valid = VALID_TRANSITIONS.find(
    (t) => t.from === order.status && t.trigger === trigger
  );
  if (!valid) {
    throw new Error(
      `Invalid transition: ${order.status} + ${trigger}`
    );
  }
  return valid.to;
}

Store every transition in an order_events append-only table. This gives you a full audit trail and lets you reconstruct order state by replaying events, which is useful for debugging and for powering customer service tooling.

Fulfillment Workflows

Split Shipments

When an order contains items from multiple warehouses, or when a warehouse partially stocks an order, you need split shipments. A single order can produce multiple Shipment records.

interface Shipment {
  id: string;
  orderId: string;
  warehouseId: string;
  items: ShipmentItem[];
  status: "pending" | "packed" | "shipped" | "delivered" | "failed";
  trackingNumber: string | null;
  carrierId: string | null;
  labelUrl: string | null;
  shippedAt: Date | null;
  estimatedDelivery: Date | null;
}

interface ShipmentItem {
  orderItemId: string;
  variantId: string;
  quantity: number;
}

The order status rolls up from shipment statuses: if all shipments are delivered, the order becomes fulfilled. If some are delivered and others are pending, the order is partially_fulfilled. Implement this as a computed update triggered whenever a shipment status changes.

Carrier Integration

Carrier APIs (FedEx, UPS, USPS, regional carriers) have inconsistent interfaces. Abstract them behind a common interface early:

interface CarrierAdapter {
  createLabel(shipment: Shipment, rateId: string): Promise<LabelResult>;
  getRates(shipment: Shipment): Promise<ShippingRate[]>;
  trackShipment(trackingNumber: string): Promise<TrackingEvent[]>;
  voidLabel(labelId: string): Promise<void>;
}

Each carrier gets its own adapter. Rate shopping (selecting the cheapest or fastest carrier per shipment) is then a matter of calling getRates() across all configured adapters and picking the best option per your business rules.

Tracking Updates

Carriers push tracking events via webhooks, but the reliability varies widely. Some carriers only provide polling APIs. Build a dual-path system: consume webhooks when available, fall back to a scheduled polling job for carriers that do not push.

Store raw tracking events and derive a normalized timeline:

interface TrackingEvent {
  shipmentId: string;
  carrierId: string;
  rawEventCode: string;
  normalizedStatus: "in_transit" | "out_for_delivery" | "delivered" | "exception";
  location: string | null;
  timestamp: Date;
  receivedAt: Date;
}

Use receivedAt (when your system received the event) separately from timestamp (when the carrier recorded it). Late-arriving events (common with USPS) can arrive out of order: sort by timestamp when presenting the timeline, use receivedAt for internal processing logic.

Returns and Refunds

Return Authorization

A return starts with an RMA (Return Merchandise Authorization). The RMA records what the customer is returning and why:

interface ReturnRequest {
  id: string;
  orderId: string;
  items: ReturnItem[];
  reason: "damaged" | "wrong_item" | "not_as_described" | "changed_mind" | "other";
  resolutionType: "refund" | "exchange" | "store_credit";
  status: "pending" | "approved" | "received" | "inspected" | "resolved";
  returnLabelUrl: string | null;
}

interface ReturnItem {
  orderItemId: string;
  quantity: number;
  condition: "unopened" | "opened" | "damaged" | null;  // null until inspected
}

Auto-approve returns within the return window for common reasons (changed mind, wrong item). Route damaged-goods returns through manual inspection queues if the value warrants it.

Refund Processing

Refunds trigger payment captures (partial or full) and, depending on the return type, may also trigger inventory restocking. Coordinate these with an outbox pattern to avoid partial failures:

async function processRefund(
  returnRequestId: string,
  db: Database,
  paymentClient: PaymentClient
): Promise<void> {
  await db.transaction(async (tx) => {
    const returnReq = await tx.findReturnRequest(returnRequestId);
    const order = await tx.findOrder(returnReq.orderId);

    const refundAmount = calculateRefundAmount(returnReq, order);

    // Write refund record and outbox event atomically
    await tx.insertRefund({
      orderId: order.id,
      amount: refundAmount,
      returnRequestId,
      status: "pending",
    });

    await tx.insertOutboxEvent({
      type: "refund.initiate",
      payload: { orderId: order.id, amount: refundAmount, authorizationId: order.paymentAuthorizationId },
      processAfter: new Date(),
    });

    await tx.updateReturnRequest(returnRequestId, { status: "resolved" });
    await tx.updateOrderStatus(order.id, transition(order, "refund.partial"));
  });

  // Outbox worker picks up refund.initiate and calls paymentClient.refund()
}

Do not call the payment API inside the database transaction. If the payment call succeeds but the transaction rolls back, you have processed a refund without updating your records. Use the outbox: write intent transactionally, process the side effect outside the transaction in a durable worker.

Consistency Challenges

The Payment-Order Consistency Problem

The most common consistency bug in order systems: payment authorized, order not created (or vice versa). Handle it with a two-phase approach:

  1. Create a CheckoutSession record when checkout begins. Store the payment authorization token on it.
  2. In a separate step, convert the CheckoutSession into an Order.
  3. Run a reconciliation job every few minutes that finds CheckoutSession records with an authorization token but no corresponding order (older than two minutes). For each: attempt to create the order, or if that fails, void the authorization.
async function reconcileOrphanedAuthorizations(
  db: Database,
  paymentClient: PaymentClient
): Promise<void> {
  const orphaned = await db.query(`
    SELECT cs.*
    FROM checkout_sessions cs
    LEFT JOIN orders o ON o.checkout_session_id = cs.id
    WHERE cs.payment_authorization_id IS NOT NULL
      AND o.id IS NULL
      AND cs.created_at < NOW() - INTERVAL '2 minutes'
    LIMIT 50
  `);

  for (const session of orphaned) {
    try {
      await createOrderFromSession(session, db);
    } catch {
      await paymentClient.voidAuthorization(session.paymentAuthorizationId);
      await db.updateCheckoutSession(session.id, { status: "failed" });
    }
  }
}

Inventory Oversell

Two customers can both see “1 item in stock” and both complete checkout successfully if you are not careful. The reservation pattern described above handles this when consistently applied. The failure mode is a race between the reservation check and the payment authorization: in the time between “reserve succeeded” and “payment authorized”, another process can also reserve the same item.

The reservation is the lock. As long as you check remaining stock against active (non-expired) reservations atomically (using SELECT FOR UPDATE or equivalent), oversell is prevented.

Eventual Consistency Across Services

In a microservices layout, you typically have separate services for orders, inventory, payments, and fulfillment. Events flow between them via a message broker. The failure mode is an event being processed twice (message broker delivers at-least-once) or a service being down when an event arrives.

Two requirements: consumers must be idempotent, and event processing must be exactly-once from the consumer’s perspective. Store processed event IDs in a table and use an ON CONFLICT DO NOTHING pattern to deduplicate:

async function handleOrderConfirmed(
  event: OrderConfirmedEvent,
  db: Database
): Promise<void> {
  const inserted = await db.query(`
    INSERT INTO processed_events (event_id, processed_at)
    VALUES ($1, NOW())
    ON CONFLICT (event_id) DO NOTHING
    RETURNING event_id
  `, [event.id]);

  if (inserted.rows.length === 0) return; // Already processed

  await decrementInventory(event.orderId, event.items, db);
}

Tradeoffs

DecisionOption AOption BWhen to prefer A
Cart storageRedis (ephemeral)Postgres (persistent)Guest-heavy traffic, no remarketing needs
Inventory reservationSoft hold with expiryHard decrement at checkout startAny concurrent checkout traffic
Payment flowAuthorize then captureImmediate capturePhysical goods with shipping delay
Tax calculation on failureFail checkoutFallback rateCompliance-heavy jurisdictions (US sales tax)
Refund triggerOutbox (async)Direct API call in transactionAll production systems
Tracking updatesWebhook + polling fallbackPolling onlyCarrier supports webhooks reliably
Order stateDenormalized status columnEvent-sourced from order_eventsHigh audit/debugging requirements

Production Considerations

Idempotency everywhere. Every external call (payment, carrier label, tax API) needs an idempotency key. Tie it to the checkout session or order ID, not to a random UUID generated at call time.

Expose reservation expiry to the user. Show a countdown timer when inventory is reserved during checkout. If the timer expires, re-check availability before payment.

Monitor the orphaned authorization reconciler. Emit a metric for each session it processes. A spike indicates a reliability problem in your order creation path.

Test partial fulfillment explicitly. Most checkout tests cover the happy path. The partially_fulfilled state has the most edge cases: what happens when the second shipment is cancelled after the first ships? What refund does the customer get?

Keep the state machine in one place. If order status transitions are scattered across event handlers, you will eventually have an order in an impossible state. One function, one list of valid transitions, one place to audit.

Separate read models from write models. Order history queries (customer’s past orders, admin search) have very different access patterns than order mutations (checkout, fulfillment updates). Materialize a read-optimized projection rather than querying the orders table with complex joins under load.

Order systems are among the most consequential pieces of infrastructure a commerce company runs. Every inconsistency between payment and order state has a customer impact and, often, a financial one. The patterns here (reservations, idempotency keys, outbox events, explicit state machines) are not over-engineering: they are the minimum viable safeguards for a system that handles real money.

More in System Design

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
System Design ·

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL

A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
System Design ·

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale

A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
System Design ·

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation

Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
System Design ·

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally

A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.