Checkout is where an e-commerce backend stops being “a shopping list” and starts being “a real transaction.” In this lesson we’ll implement (and understand) the exact workflow our codebase uses to convert an open cart into a pending order, while keeping the data consistent even if two requests happen at the same time.
You’ll see how the checkout logic is organized across a repository layer and a service layer, and how a Next.js route turns service results into a consistent HTTP response. By the end, you’ll know exactly where “snapshotting” happens, why we use database locks during checkout, and what rules the system enforces before it will create an order.
A cart is editable, temporary, and “live.” An order is a historical record that should not change just because product prices or inventories change later.
In our project, snapshotting happens by copying each cart_items row into a new order_items row at the moment of checkout. The copied data includes the product_id, quantity, and the unit_price_cents that the cart item had at that moment. That gives us a reliable receipt-like record for the order.
Checkout is implemented across three layers:
- Repository layer:
src/lib/repositories/ordersRepo.tsTalks to PostgreSQL, performs transactional work, and snapshots cart items into order items. - Service layer:
src/lib/services/ordersService.tsConverts repository errors into API-friendlyServiceResults and enforces order-state rules for pay/cancel. - Route handler:
src/app/api/carts/[id]/checkout/route.tsValidates the URL param, calls the service, and returns standardized success/error envelopes.
Let’s walk through each part, starting with the repository, because that’s where the most important checkout guarantees are enforced.
This first part of src/lib/repositories/ordersRepo.ts defines row shapes (what Postgres returns) and maps them into our domain types (Order and OrderItem). These helpers keep the rest of the repo code clean and ensure our API deals with consistent, typed objects.
OrderRowandOrderItemRowdescribe the exact columns we read from PostgreSQL. This repo works directly with SQL, so having explicit row shapes prevents accidental field mismatches and makes mapping predictable.mapOrderRow()is where the DB-facingstatus: stringbecomes a safe domain value. If the status in the database isn’t one of our supported values (, , , ), we default to so downstream code doesn’t crash on unknown strings.
Next, the repository provides read operations for orders. getOrderById also fetches the order’s items and attaches them to the returned Order.
listOrders()implements pagination usingLIMITandOFFSET. The caller suppliespageandpageSize, and the repository converts that into an offset so results are stable and predictable.- Orders are returned sorted by
created_at DESC, which is what most “My Orders” screens want: newest orders first. getOrderById()returnsnullif the order doesn’t exist instead of throwing. That makes “not found” handling a normal control-flow path at the service layer.- After mapping the order itself,
getOrderById()fetches and assigns them to . This means the service (and the API) can treat as the full order-with-items object without doing additional joins.
Checkout can fail for multiple business reasons (cart missing, not open, empty, etc.). In this repo, those conditions are represented with a dedicated error type so the service layer can translate them into clean API errors.
- The snapshot row interfaces are “transaction-scoped shapes” for checkout queries. They’re smaller than full domain types because checkout only needs specific columns to make decisions.
CheckoutErrorCodelists the explicit, expected failure modes for checkout. This is important because it distinguishes “normal business failures” (like an empty cart) from “unexpected server failures.”CheckoutErrorcarries a machine-friendlycodeand optionaldetails. Thatdetailsfield is especially useful for cases like inventory errors, where you may want to return structured info about which item failed.- This pattern keeps the repo focused on database correctness, while leaving the service layer responsible for turning errors into HTTP responses.
This is the heart of checkout: createOrderFromCart in src/lib/repositories/ordersRepo.ts. It runs inside a single database transaction and uses row locks to prevent race conditions.
- Everything runs inside
withTransaction(...). That means either all checkout steps succeed (cart is validated, order created, items snapshotted, cart updated) or none of them do. This prevents half-finished orders or carts that get “stuck” in inconsistent states.
Once an order exists, status transitions are persisted with a simple update helper. The service layer decides when it’s legal to transition; the repo just performs the write.
setOrderStatus()updates thestatusand bumpsupdated_atin one statement, which keeps auditing consistent and avoids needing multiple queries.- Returning
RETURNING *lets us return the updated order immediately, which is convenient for API handlers that want to respond with the updated resource. - It returns
nullif no order exists with that ID. This keeps “not found” separate from “conflict,” and allows the service layer to decide which HTTP code to return.
Now let’s look at src/lib/services/ordersService.ts. This layer is responsible for two things:
- Mapping repository errors (including
CheckoutErrorand Postgres errors) into a consistentServiceResult. - Enforcing business rules for state transitions (pay/cancel) before calling
setOrderStatus.
Mapping checkout errors and exceptions
mapCheckoutError()converts repo-level checkout failures into API-style errors with HTTP statuses. That’s why “empty cart” becomes a 409 Conflict instead of a 500—nothing crashed, the request just violates a business rule.- is mapped to a 409 with , which is designed for richer client feedback (for example, telling the user which item caused the issue). Even though current checkout doesn’t throw this yet, the mapping ensures the API contract is ready.
Orders move through statuses, but not every transition is allowed. In this project, checkout creates a pending order, and then we can pay or cancel it depending on its current state.
payOrderService()enforces a strict rule: only pending orders can be paid. If an order is alreadypaid,shipped, orcancelled, paying would either be nonsensical or dangerous (double charge), so we return a 409 Conflict.- The service re-fetches the order first to decide whether the transition is legal. This keeps the rule in one place and avoids “blind updates” that might overwrite a newer status.
cancelOrderService()allows cancelling most statuses exceptshippedand . That means can be cancelled, and (as currently written) —which may or may not match how a real store works, but it is the exact behavior enforced by these files.
Finally, the Next.js route handler exposes checkout at POST /api/carts/:id/checkout. This is implemented in src/app/api/carts/[id]/checkout/route.ts.
- The route uses the modern Next.js pattern for dynamic route params:
const { id } = await context.params;. That’s whyRouteContexttypesparamsas aPromise. isUUID(id)performs a fast, early guard so invalid IDs never reach the database layer. This improves error clarity and reduces unnecessary load.- The route delegates business logic to
checkoutService, then converts theServiceResultinto a standardized HTTP response usingsuccess(...)and .
In this lesson you learned how checkout works in this codebase (not a generic e-commerce example):
- The repository
createOrderFromCart()performs checkout inside a transaction and usesFOR UPDATElocks to prevent race conditions. - Snapshotting is implemented by copying
cart_itemsintoorder_items, preservingunit_price_centsand quantities as they existed at checkout time. - The service layer translates
CheckoutErrorand Postgres errors into consistentServiceResultobjects and enforces state transition rules for paying and cancelling. - The Next.js route
POST /api/carts/:id/checkoutvalidates params, calls the service, and returns a standardized success/error envelope with the correct status codes.
From here, the practices should feel much more grounded: when you debug checkout behavior, you’ll know exactly which layer to inspect—route, service, or repository—and why that layer owns that responsibility.
