Welcome! 👋 In this lesson we’ll add the two most important “entry points” for working with carts: creating a cart, and fetching a cart by ID in a predictable, client-friendly shape.
You’ll see how the cart is modeled as a real backend resource (not just “a list of products”), and how our codebase builds a fully “hydrated” cart response that includes items and totals—even when the cart is empty. By the end, you’ll understand exactly where persistence happens (repository), where coordination happens (service), and how the API route ties everything together.
In this project, a cart has its own identity and lifecycle:
- It has an
id(UUID), astatus(open,checked_out,abandoned), and timestamps. - It can exist before any items are added.
- When you fetch a cart, the response is intentionally stable: the API should return
itemsandtotalsconsistently so clients don’t need special “empty cart” logic.
That stability is created in the repository layer by composing the cart from multiple queries: the cart row, its item rows, and the referenced product data.
The repository file src/lib/repositories/cartsRepo.ts is where we talk to PostgreSQL. It does SQL-in / rows-out, then maps database rows into domain objects.
This first chunk defines what we expect from the database and how we normalize the status string into the domain union type.
CartRowmirrors thecartstable columns exactly, usingstringforstatusbecause Postgres returns it as text. This keeps the “DB shape” separate from the “domain shape”.mapCartStatusis a small defensive layer: if the DB ever contains an unexpected status string, we fall back to'open'instead of crashing or leaking invalid data through the API.- This mapping is important because the domain type
Cart['status']is a union, and the rest of the app assumes it only ever sees valid values.
Now here’s the core create function you’ll rely on for POST /api/carts: it inserts a row and returns a domain Cart.
- The repository does not generate IDs—it accepts an
idthat was produced by a higher layer. That separation keeps persistence simple and makes the repo reusable. - The SQL uses a parameter placeholder (
$1) so the query is safe and consistent; you never want to concatenate IDs into SQL strings. RETURNING *is doing a lot of work for us: Postgres gives back the inserted row including default values and timestamps (created_at,updated_at) that are set at the database level.- The return value is mapped into the domain
Cartand normalizes the status throughmapCartStatus, so callers never have to think about “raw” DB strings.
Fetching a cart is where the repository earns its keep. getCartById doesn’t just return a cart row—it returns a cart expanded with:
items: cart item rows, each optionally containing its referenced producttotals: computed from the cart items (including tax)
Base cart lookup and early return:
This first part loads the cart itself and returns null if it doesn’t exist.
- The “return
nullif missing” pattern is deliberate: repositories stay generic and don’t decide HTTP behavior. The route/service layer will convertnullinto a 404 when appropriate. - Building a
basecart object is a readability win. You’re separating the cart’s identity/metadata from the computed parts (items,totals) you’ll attach later. - Status is normalized here as well, so every layer above can safely assume it’s a valid domain status.
Next we load cart item rows, then load the referenced products, and build a lookup map so we can attach products onto items efficiently.
- Cart items are ordered by
created_atso responses are stable: repeated calls return items in the same order, which makes client rendering much simpler and more predictable. - Products are fetched in a separate query because cart item rows only contain
product_id. The cart response, however, wants to include product info for display and currency handling. - The
productMapavoids O(n²) lookups. Instead of scanning the product array for every item, we do O(1)Map.get(product_id)per item. - Product rows are normalized defensively: currency is forced to
'USD', and status is coerced into the domain union (active/archived) with a safe default. That protects the cart response from inconsistent DB strings.
Finally, we attach products onto each cart item and compute subtotal, tax, and total.
- Each returned
CartItemincludesunit_price_cents, which acts like a “price snapshot” for the cart line. This is important because it means totals are computed from what the cart recorded, not necessarily from the product’s current price. productis attached asproductMap.get(ci.product_id)and can beundefined. That’s intentional defensive coding: if data becomes inconsistent, the cart still loads instead of crashing the entire request.- Totals are computed in cents, which avoids floating-point rounding issues. This is a standard backend practice for money values.
- Tax is computed using
computeTaxCents(subtotal)(from@/lib/money/tax). That makes tax a pure computation: we don’t store totals in the DB; we derive them when reading the cart. - The currency rule is simple and stable: take it from the first item’s product if available, otherwise default to
'USD'. This ensures empty carts still have a useful currency value.
The service file src/lib/services/cartsService.ts is where “business coordination” lives: generating IDs, validating inputs (in later lessons), enforcing cart state rules, and translating exceptions into a consistent ServiceResult.
Creating a cart via the service:
- This is where UUID generation belongs: the service owns “how we create carts”, while the repository only knows “how to store carts”.
- The return type is
ServiceResult<Cart>rather than a bareCart, which standardizes how success and failure flow through the app. - Errors are handled centrally via
handleException, which maps Postgres errors (when possible) into consistent{ code, message, httpStatus }results. That keeps routes thin and prevents duplicated try/catch logic everywhere.
- The repository returns
Cart | null, but the service upgrades that into a clear business-level result: missing becomes a typed failure withhttpStatus: 404. - This pattern matters because it gives the API route a single, uniform way to respond: “if
result.okreturn success; otherwise return an error envelope withresult.error.httpStatus”. - Keeping “not found” logic here also makes it reusable. Any future route or workflow that needs to load a cart can call
getCartServiceand get consistent behavior.
In this lesson you established the backbone of the carts API:
src/lib/repositories/cartsRepo.tscreates carts and returns “hydrated” cart reads that includeitemsand computedtotals.getCartByIdcomposes the cart response by loading cart rows, item rows, and product rows—then computing subtotal and tax withcomputeTaxCents.src/lib/services/cartsService.tscoordinates ID creation, standardizes errors, and returns a consistentServiceResultshape that routes can handle cleanly.
Next, once the route is wired up, this foundation makes cart mutations (adding/updating/removing items) feel natural—because your cart model and response shape are already stable and predictable.
