Remix Checkout Implementation

Welcome to the Orders course! 👋 Up to now, your backend has been all about building and managing a cart—creating it, adding items, adjusting quantities, and computing totals. Checkout is the moment your system becomes a true e-commerce backend: we turn an editable cart into an immutable order, and we do it in a way that stays correct even if multiple requests hit the server at the same time.

In this lesson you’ll follow the checkout flow end-to-end in Remix: the route validates input and translates service results into HTTP responses, the service maps repository checkout failures into consistent ServiceResults, and the repository performs the checkout transaction with row locks so inventory and cart state can’t be corrupted under concurrency.

Checkout As a Transaction, Not a “Normal Write”

Checkout is special because it’s not just “insert a row.” It’s a coordinated sequence of steps that must all succeed together:

  • confirm the cart exists and is eligible to checkout
  • confirm the cart has items
  • confirm inventory exists for all cart items
  • decrement inventory
  • create an order and snapshot items into order_items
  • mark the cart as checked out so it cannot be checked out twice

If any part fails, nothing should be partially applied. That’s why checkout lives inside a single database transaction, with row locks (FOR UPDATE) to prevent concurrent checkouts or concurrent inventory changes from producing inconsistent state.

Snapshotting: Why Orders Copy Cart Items

Carts are temporary and editable. Orders are historical and should remain stable.

At checkout time, we “snapshot” the cart by inserting one order_items row per cart_items row. Each order_items row stores:

  • product_id
  • quantity
  • unit_price_cents (the price snapshot taken when the item was added to the cart)

This means the order total and receipt remain correct even if the product’s price changes later. The cart might change, but the order shouldn’t.

Route: POST /api/carts/:id/checkout

Checkout is exposed through a dedicated Remix action route:

app/routes/api.carts.$id.checkout.ts

The action’s responsibility is intentionally narrow:

  • validate the cart id (params.id) using isUUID
  • allow only POST
  • call checkoutService(id)
  • map failures using the service-provided httpStatus
  • return 201 on success because an order resource was created

Here is the route implementation:

  • isUUID(id) runs before any service or DB call. Invalid UUIDs are treated as malformed requests (400), not “not found,” because the client didn’t even provide a valid identifier.
Service: Mapping Checkout Errors Into API-Friendly Results

The service layer is what allows routes to stay simple. Routes call services and rely on ServiceResult to include the correct httpStatus.

In this course, checkout failures are intentionally modeled as CheckoutError thrown by the repository. The service catches that and converts it into a structured fail(...) result via mapCheckoutError(e).

This code lives in src/lib/services/ordersService.ts:

  • The mapping is deliberate and stable: repository error codes are converted into API-level error codes and HTTP status codes that routes can forward directly.
  • CART_NOT_FOUND maps to 404 because the cart resource is missing. This is a “not found” outcome, not a conflict.
  • CART_NOT_OPEN, , and map to because the request conflicts with the current business state: the cart is sealed, has no items, or stock can’t satisfy the checkout.
Repository: createOrderFromCart and Transactional Checkout Guarantees

The core of checkout lives in:

src/lib/repositories/ordersRepo.ts

This function runs inside withTransaction(...), and that is what guarantees checkout behaves atomically: either everything succeeds, or nothing is applied.

Below is the full checkout implementation, and then we’ll break it down by the key invariants and the locking strategy.

Locking the Cart Row: Preventing Double Checkout

The first invariant is “this cart must exist and must be open,” and we enforce it while locking the cart row:

  • FOR UPDATE locks the cart row so no other transaction can simultaneously check it out or change its status until this transaction completes.
  • If two checkout requests hit the same cart at the same time, one will acquire the lock first. The other will wait, then re-check the status and fail with CART_NOT_OPEN once the first has marked it checked out.
  • The repository throws specific error codes (CART_NOT_FOUND, CART_NOT_OPEN) so the service can map them cleanly to 404 and 409, respectively.
Locking Cart Items and Rejecting Empty Carts

Next, we lock the cart items and ensure the cart isn’t empty:

  • Locking cart items matters because checkout is computing totals and preparing to snapshot items. You don’t want quantities changing midway through checkout.
  • The EMPTY_CART rule prevents creating orders with no items, which is usually a client flow mistake and should be a 409, not a server crash.
  • ORDER BY created_at ASC keeps the snapshot order stable, which can matter for deterministic behavior (and makes debugging easier).
Locking Products, Validating Inventory, and Reporting Details

Inventory correctness is one of the most important checkout guarantees. This checkout implementation locks product rows and verifies there’s enough stock for the total requested quantity per product.

First, it locks all products involved:

  • new Set(...) ensures each product ID appears once, avoiding redundant row locking and redundant checks.
  • FOR UPDATE locks each product row so other checkouts can’t decrement inventory concurrently and cause oversells.
  • Building productMap gives you O(1) product lookups per cart item, which keeps the code simple and efficient.

Then it aggregates required quantities per product and validates inventory:

  • The aggregation step matters because there can be multiple cart item rows (or logically multiple quantities) referencing the same product. Checkout needs the total required quantity per product to validate correctly.
  • Throwing INSUFFICIENT_INVENTORY includes details with productId and . This is exactly what allows client UIs to show helpful messages like “Only 2 left in stock.”
Decrementing Inventory Inside the Same Transaction

Once inventory is validated, it is decremented inside the same transaction:

  • Doing this inside the transaction is essential: validation and decrement must be part of one atomic unit of work.
  • Because product rows are locked FOR UPDATE, no other checkout can “steal” inventory between your validation and your decrement.
  • Updating updated_at makes inventory changes auditable and consistent with other mutations in the codebase.
Computing Totals and Taxes During Checkout

Checkout computes totals from the cart snapshot:

  • subtotal_cents is computed deterministically as the sum of quantity * unit_price_cents. Using cents avoids floating-point rounding issues that are common with money.
  • computeTaxCents(subtotal) applies your configured tax policy. Tax is computed at checkout time because the order should store what tax was charged at the moment of purchase.
  • The order stores subtotal_cents, tax_cents, and total_cents explicitly. That makes the order a stable receipt that doesn’t depend on future computation rules.

Currency is selected from the product set:

  • This code uses the first cart item’s product currency as the order currency, defaulting to "USD". It assumes a single-currency system (which matches the rest of the project’s design).
  • Currency is stored on the order so clients can render totals correctly without needing to infer currency from products later.
Creating the Order and Snapshotting Order Items

Now that the checkout invariants are satisfied, the repository inserts the orders row and then inserts one order_items row per cart item:

  • The order is created with status 'pending', representing “checked out but not yet paid” in this system.
  • RETURNING * gives the inserted row back immediately so it can be mapped to the domain Order without an extra query.
  • Storing totals and currency on the order ensures the order is a stable record even if tax rules or product data change later.

Then each cart item is snapshotted:

  • Each order item gets its own new UUID because order items are a distinct resource from cart items.
  • The snapshot copies product_id, quantity, and unit_price_cents. The important part is that unit_price_cents comes from the cart snapshot, not from the product table, preserving the “price at checkout” record.
  • Because this runs inside the same transaction, you cannot end up with an order row without its item rows (or vice versa).
Sealing the Cart: Preventing Re-Checkout

Finally, the cart is marked checked_out:

  • This is the “seal the cart” step. Once checked out, the cart can no longer be mutated safely because it has already produced an order.
  • Doing this inside the transaction is crucial. It ensures there is no window where an order is created but the cart stays open, which would allow duplicate checkout attempts.
  • This update, combined with the initial cart lock, is what prevents two orders being created from the same cart.
Recap

In this lesson, you implemented a real checkout workflow in Remix with strong correctness guarantees:

  • The route app/routes/api.carts.$id.checkout.ts validates UUIDs, allows only POST, calls checkoutService, and returns 201 on success while relying on error.httpStatus for failures.

  • The service mapCheckoutError(e) converts repository CheckoutError codes into consistent ServiceResult failures:

    • CART_NOT_FOUND404 NOT_FOUND
    • CART_NOT_OPEN409 CONFLICT
    • EMPTY_CART409 CONFLICT
    • INSUFFICIENT_INVENTORY409 CONFLICT with details preserved
Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal