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 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.
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_idquantityunit_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.
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) usingisUUID - allow only
POST - call
checkoutService(id) - map failures using the service-provided
httpStatus - return
201on 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.
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_FOUNDmaps to404because 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.
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.
The first invariant is “this cart must exist and must be open,” and we enforce it while locking the cart row:
FOR UPDATElocks 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_OPENonce 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 to404and409, respectively.
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_CARTrule prevents creating orders with no items, which is usually a client flow mistake and should be a409, not a server crash. ORDER BY created_at ASCkeeps the snapshot order stable, which can matter for deterministic behavior (and makes debugging easier).
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 UPDATElocks each product row so other checkouts can’t decrement inventory concurrently and cause oversells.- Building
productMapgives 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_INVENTORYincludesdetailswithproductIdand . This is exactly what allows client UIs to show helpful messages like “Only 2 left in stock.”
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_atmakes inventory changes auditable and consistent with other mutations in the codebase.
Checkout computes totals from the cart snapshot:
subtotal_centsis computed deterministically as the sum ofquantity * 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, andtotal_centsexplicitly. 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.
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 domainOrderwithout 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, andunit_price_cents. The important part is thatunit_price_centscomes 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).
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.
In this lesson, you implemented a real checkout workflow in Remix with strong correctness guarantees:
-
The route
app/routes/api.carts.$id.checkout.tsvalidates UUIDs, allows onlyPOST, callscheckoutService, and returns201on success while relying onerror.httpStatusfor failures. -
The service
mapCheckoutError(e)converts repositoryCheckoutErrorcodes into consistentServiceResultfailures:CART_NOT_FOUND→404 NOT_FOUNDCART_NOT_OPEN→409 CONFLICTEMPTY_CART→409 CONFLICTINSUFFICIENT_INVENTORY→409 CONFLICTwithdetailspreserved
