Welcome back! 👋 Now that checkout can safely create orders, the next step is making those orders easy to retrieve in a clean, client-friendly way. In a real system, “checkout” is only half the story—you also need “My Orders” lists, order detail pages, and backend admin views that can load an order and its line items reliably.
In this lesson you’ll implement the read side of the Orders API end-to-end: the repository performs the SQL reads and maps database rows into domain objects, the service layer applies pagination defaults and returns consistent ServiceResults, and the Remix routes validate input and translate service results into standard success(...) / error(...) envelopes.
In the previous lesson, you implemented checkout at POST /api/carts/:id/checkout, including a transactional repository workflow that snapshots cart items into order_items, computes totals, decrements inventory, and seals the cart as checked_out. That means orders now exist in the database—so in this lesson, we’ll focus on the “read path”: listing orders and fetching a single order with its items.
There are two main read endpoints:
GET /api/orders→ returns a paginated list of orders (newest first)GET /api/orders/:id→ returns a single order, includingitems
The most important design goal is consistency: routes should never handle raw SQL rows, never guess status codes, and never implement business rules. Instead:
- repositories do SQL + mapping
- services do defaulting + result shaping
- routes do validation + HTTP response envelope
Before we even talk about pagination or fetching by ID, the repository establishes a pattern: DB rows are not returned directly. Everything gets mapped into domain-shaped objects using mapOrderRow and mapItemRow.
This code lives in src/lib/repositories/ordersRepo.ts:
OrderRowandOrderItemRowdescribe the database shape, which is important because repositories work at the SQL boundary and must be explicit about column names and types.mapOrderRownormalizes the order’sstatusstring into a safe domain value. If the database contains an unexpected status, the code defaults to"pending"to avoid leaking invalid states to the rest of the system.
The list endpoint is backed by listOrders(page, pageSize) in src/lib/repositories/ordersRepo.ts. The key requirements for learners are:
- sort by newest first:
ORDER BY created_at DESC - paginate with
LIMIT+OFFSET
Here is the repository implementation:
offset = (page - 1) * pageSizeis the standard pagination formula: page 1 starts at offset 0, page 2 starts atpageSize, and so on. This makes paging predictable and easy for clients to reason about.ORDER BY created_at DESCensures the newest orders appear first, which matches typical “My Orders” UX and avoids confusing clients by reshuffling order results.LIMIT $1 OFFSET $2keeps pagination efficient and consistent. Even if the dataset grows, the query always returns at mostpageSizerecords.- Returning
rows.map(mapOrderRow)is the “no raw rows” rule in action: everything leaving the repository is already shaped as a domain .
The service layer exists so routes don’t have to implement “default page” or “limit page size” logic repeatedly.
In this codebase, listOrdersService({ page, pageSize }) applies:
- default
page = 1if missing or invalid - default
pageSize = 20if missing or invalid - clamp
pageSizeto a max of100
Here is the service implementation in src/lib/services/ordersService.ts:
- The service treats missing or invalid
pageas “page 1,” which prevents accidental client mistakes from causing errors and makes the API friendlier to consume. pageSizedefaults to20, which is a sensible tradeoff between payload size and usefulness. It avoids returning too few orders while also avoiding massive responses.Math.min(params.pageSize, 100)clampspageSizeto at most 100. This protects the database from huge scans and protects the server from returning very large JSON responses.
The list route is implemented in app/routes/api.orders.ts. This route’s job is to validate query params and translate the service result into an HTTP response.
Two query params are supported:
page(min 1)pageSize(min 1, max 100)
Validation uses parseOptionalIntParam, and invalid values must return 400 VALIDATION_ERROR.
Here is the loader implementation:
new URL(request.url).searchParamsis the standard way to access query params in a Remix loader, and it keeps query parsing purely request-driven (no global state).parseOptionalIntParam(searchParams, "page", { min: 1 })returns a structured result. If parsing fails (missing, non-integer, below min, etc.), becomes false and the route returns a clean .
Order details require more than one SQL query in this codebase:
- load the order row from
orders - load all
order_itemsfor that order - attach
itemsonto the returnedOrder
The key requirement is: return null when the order doesn’t exist, so the service can map that to a 404.
Here is the repository implementation in src/lib/repositories/ordersRepo.ts:
- The first query loads the order row itself. Returning
nullwhen missing is intentional: repositories shouldn’t invent HTTP behavior—they just report “found or not found.” mapOrderRow(rows[0])ensures the returned object is domain-shaped immediately. This avoids leaking a raw row object up the stack.- The second query loads
order_itemsfor the order and sorts them bycreated_at ASC. This produces stable ordering, which is helpful for consistent UI rendering and predictable API responses.
The service layer’s job for getOrderService(id) is to translate null into a clean 404 error, and otherwise return ok(order).
Here is the implementation in src/lib/services/ordersService.ts:
getOrderById(id)returningnullis treated as a normal “not found” outcome, and the service maps it to{ code: "NOT_FOUND", httpStatus: 404 }.- On success, the service returns
ok(order)and does not modify the returned domain object. That keeps the service contract simple and avoids duplication of mapping logic already handled by the repository. - Any unexpected failure is handled by
handleException(e), which keeps error mapping consistent across all order-related service functions.
The single order endpoint is implemented in app/routes/api.orders.$id.ts. Its responsibilities mirror the patterns you’ve used elsewhere:
- validate
params.idwithisUUID - invalid UUID →
400 VALIDATION_ERROR - valid UUID → call
getOrderService(id) - forward
httpStatusfor service failures
Here is the loader:
- The UUID validation is an important contract difference: malformed identifiers are
400, not404. A404implies the ID was valid but didn’t match a resource;400means the request itself is invalid. - This route forwards service failures as-is, including the service-provided . That keeps routes consistent and prevents accidental mismatches (like returning a 500 for a 404 case).
You now have the “read side” of the Orders API wired cleanly and consistently:
-
src/lib/repositories/ordersRepo.tslistOrders(page, pageSize)usesORDER BY created_at DESCandLIMIT/OFFSET, then maps rows into domain orders.getOrderById(id)returnsnullif missing, otherwise loads order items and attaches them asorder.items.
-
src/lib/services/ordersService.tslistOrdersService({ page, pageSize })applies defaults, clampspageSizeto 100, and returnsok(data)on success.getOrderService(id)mapsnullto a404 NOT_FOUNDand returnsok(order)when it exists.
-
Routes
GET /api/ordersvalidates and with and returns for invalid values.
