Welcome! 👋 In this lesson, you’ll add the first two “entry points” for the cart system: creating a cart and fetching a cart by ID. These endpoints are the foundation for everything that comes next—adding items, updating quantities, and eventually checkout.
You’ll see how this codebase treats a cart as a real backend resource with its own identity, status, and timestamps. You’ll also learn how our cart reads are hydrated: when you fetch a cart, the repository returns the cart plus items and computed totals so client code can render the cart without doing extra math or special-casing.
Previously…
In the previous lesson, you implemented product lifecycle endpoints like PATCH and DELETE (archive) for /api/products/:id. You validated URL params up front, delegated the “meaning” of the operation to the service layer, and returned consistent response envelopes using success(...) and error(...).
We’ll reuse that same pattern here for carts: validate the incoming id, call a service, and let the service decide whether the outcome is NOT_FOUND, CONFLICT, or a successful cart object.
A cart is not just “a list of products.” In this project, a cart has:
- A stable
id(UUID), so it can exist even before it has items. - A
statuslifecycle:"open","checked_out","abandoned". - Timestamps (
created_at,updated_at) managed by the database. - A consistent read shape that includes
itemsandtotals, so clients don’t need special “empty cart” branching.
That consistent read shape is built in the repository at src/lib/repositories/cartsRepo.ts, which composes the cart from multiple queries and computes money totals in cents.
Before we insert or read carts, the repository defines helpers that translate raw database values into domain-safe ones.
This helper lives in src/lib/repositories/cartsRepo.ts. It ensures that whatever string comes back from the database is converted into one of our known cart statuses.
- The database column
statusis a string, but the rest of the app expects a limited set of meaningful states. This function is the “boundary guard” that prevents unexpected strings from leaking into the domain layer. - Falling back to
"open"is a safe default: it keeps the cart usable instead of crashing a request because of bad data. It also makes your API behavior more resilient if the database ever contains legacy or malformed values. - Doing this mapping in the repository keeps the rest of the codebase simpler. Every service and route can assume
cart.statusis one of the allowed statuses.
Cart items are stored in cart_items, and the repository converts a row into a domain CartItem.
- This is a “shape normalizer”: the database row format is close to what we want, but mapping explicitly makes it clear what fields are part of the domain contract.
- Keeping this mapping in one place prevents subtle inconsistencies later (for example, if a column name changes or a field is added). You update the mapper once, and every caller benefits.
- Notice that
unit_price_centsis stored on the cart item. That’s intentional: it acts like a price snapshot, so totals can be computed from what was recorded in the cart—not from whatever the product price might become later.
Creating a cart is pure persistence: insert a row, return the inserted cart in domain shape.
Inserting the cart row:
This function lives in src/lib/repositories/cartsRepo.ts and is the only place that knows how to write to the carts table.
- The repository does not generate IDs. It accepts
idas an argument so that higher layers (services) control identity creation, while the repository focuses on “store and retrieve.” - The SQL uses parameter binding (
$1) instead of string interpolation. That keeps the query safe and consistent and prevents injection issues. RETURNING *is important because Postgres can fill in defaults (like status and timestamps). Returning the inserted row lets us send back a complete cart object immediately without a follow-up query.- The returned
statusis normalized throughmapCartStatus, which means even freshly inserted rows are guaranteed to match domain expectations.
Fetching a cart by ID is where the repository does the most work: it returns a cart with items and computed totals.
Loading the base cart row:
This first chunk of getCartById fetches the cart itself and returns null if it doesn’t exist.
- Returning
nullis a deliberate repository choice: repositories don’t decide HTTP behavior. They simply describe “it exists” or “it doesn’t,” and the service layer interprets that into404 Not Found. - Splitting out a
basecart object makes the function easier to reason about. You establish the cart’s identity and metadata first, then attach computed data (items,totals) afterward. mapCartStatusis applied during reads too, so the cart is always safe to consume regardless of what raw string the DB returned.
Next, we load all cart items for this cart, ordered by creation time.
- Ordering by
created_at ASCmakes the response stable: repeated reads return items in a predictable order, which makes UIs easier to render and test. - The cart items query is separate from the cart row query because
cartsandcart_itemsare different tables. This is a common pattern in normalized schemas: you fetch the “parent” row and then the “child” rows. - At this point, the items are still “raw” rows. We’ll map them to domain objects and attach product info next.
Cart items contain only product_id, but clients typically need product display data (like name, sku, status). So we fetch the products for all items and build a map.
- We query products in one go, based on the set of
product_ids in the cart. This avoids doing one query per item, which would become slow as carts grow. productMapturns the array into O(1) lookups (productMap.get(productId)), so attaching products to items is efficient and clean.- Product fields are normalized defensively:
currencyis forced to"USD"andstatusis coerced into"active"/"archived"with a safe default. This prevents inconsistent DB strings from breaking client assumptions.
Finally, we build items with attached products and compute totals in cents.
- Each returned item includes its persisted fields plus an optional
product. Theproductcan beundefinedif something is inconsistent (for example, a product row was removed or not returned). - Allowing
productto be missing is defensive: the API can still return the cart rather than failing the whole request. That’s often better UX than a hard 500 for “partial data” issues. - Notice we do not recompute
unit_price_centsfrom the product. That snapshot makes totals stable over time and avoids “my cart total changed because the product price changed” surprises.
Now the totals calculation:
subtotal_centsis the sum ofquantity * unit_price_centsacross all items. Doing this in cents avoids floating-point rounding errors that happen when using dollars as decimals.
Taxes are a classic example of “derived business logic” that you want centralized. If tax were calculated in multiple places (UI, routes, services), it’s easy for rounding rules to diverge and cause mismatched totals.
Resolving the default tax rate:
This logic lives in src/lib/money/tax.ts. It reads an environment variable (basis points) and normalizes it into a safe default.
TAX_RATE_BPSis read in basis points (bps), where 10,000 bps = 100%. This makes percentages integer-based and avoids floating point representation issues.- If the env var is missing, invalid, or negative, we return
0. That’s a safe fallback that prevents tax math from breaking cart reads in development or misconfigured environments. - Exporting
DEFAULT_TAX_RATE_BPSmeans every caller uses the same default rate without re-parsing environment variables in multiple places.
This is the function used by getCartById to compute tax_cents from subtotal_cents.
- The early return handles three important cases: invalid subtotal, non-positive subtotal, or a zero tax rate. In all of those cases, returning
0keeps totals clean and avoids surprising negative/NaN values. - The formula
(subtotalCents * rateBps) / 10_000converts basis points into a percentage and produces a tax amount in cents. UsingMath.roundestablishes a consistent rounding rule, which is critical for money math. - Because
computeTaxCentsis pure (same inputs → same output), it’s easy to trust and test. That’s exactly what you want for financial computations that affect customer-facing totals.
The service layer is where we decide what repository outcomes mean (e.g., “null cart” → NOT_FOUND). It also owns responsibilities like generating cart IDs.
Creating carts via the service:
This code lives in src/lib/services/cartsService.ts. It generates a UUID and delegates to the repository’s createCart.
randomUUID()is called here—not in the repository—because identity creation is “business coordination,” not persistence. The repository stays reusable and deterministic: given an ID, it inserts.- The function returns a
ServiceResult<Cart>, which standardizes success and failure across the whole backend. Routes don’t have to guess how to interpret exceptions or missing values. - The
try/catchfunnels all thrown errors intohandleException, which maps Postgres errors when possible and otherwise returns a structuredINTERNAL_ERROR. This keeps route code clean and prevents duplicated error handling everywhere.
getCartService(id) is the service-level “read by ID” entry point. It converts a null repository result into a typed, HTTP-aware failure.
- The repository returns
Cart | null, but the service upgrades that into a meaningful result: “missing cart” becomes{ code: "NOT_FOUND", httpStatus: 404 }. - This matters because the route can now be extremely consistent:
if (!result.ok) return error(result.error...). The route doesn’t need special “if null then 404” logic. - Keeping NOT_FOUND logic here also makes cart loading reusable. Any future workflow (adding items, updating items) can call
getCartByIddirectly or useensureCartOpenpatterns without re-implementing error semantics.
Now we wire carts into Remix API routes. In this project, these are in app/routes/, and they return consistent envelopes using success(...) and error(...).
The route app/routes/api.carts.ts handles creating a new cart. It only supports POST.
Enforcing method + delegating to the service:
- The route checks
request.methodfirst. This makes the endpoint predictable: anything other thanPOSTgets a405with a clear message, rather than accidentally doing the wrong thing. createCartService()returns aServiceResult, so the route can forward failures without guessing. If a DB error occurs, the service will already have mapped it into a structured error with an HTTP status.
The route app/routes/api.carts.$id.ts is a loader-only endpoint (read-only) that fetches a cart by ID and returns the hydrated cart.
Validating the URL param and forwarding the service result:
params.id ?? ""guarantees you pass a string into validation. It’s a small defensive move that avoids accidentally validatingundefinedand producing confusing errors.isUUID(id)happens at the route boundary because URL parameters are untrusted input. An invalid UUID is treated as a 400 Bad Request, not a 404, because the client didn’t even send a valid identifier.- The service call
getCartService(id)returns either anokcart or a structured failure (likeNOT_FOUNDwith 404). The route simply forwards those values into the standarderror(...)envelope. - Returning
success(cart.value)sends the fully hydrated cart, including and as built in . That means the API client can render totals immediately without doing any money math itself.
You now have the two core cart read/write entry points fully wired:
-
src/lib/repositories/cartsRepo.tsowns persistence and hydration, including:-
createCart(id)inserting the row -
getCartById(id)composingitems+ computing totals:subtotal_cents = Σ(quantity * unit_price_cents)tax_cents = computeTaxCents(subtotal_cents)total_cents = subtotal_cents + tax_cents
-
-
src/lib/money/tax.tscentralizes tax logic so totals are computed consistently and safely in cents. -
src/lib/services/cartsService.tscoordinates meaning and identity:createCartService()generates UUIDs and returnsServiceResultgetCartService(id)converts missing carts intoNOT_FOUND(404)
-
app/routes/api.carts.tsexposes and returns on success.
