Creating and Viewing Carts

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.

A cart is a real resource, not just a list

In this project, a cart has its own identity and lifecycle:

  • It has an id (UUID), a status (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 items and totals consistently 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.

Database row types and status mapping

This first chunk defines what we expect from the database and how we normalize the status string into the domain union type.

  • CartRow mirrors the carts table columns exactly, using string for status because Postgres returns it as text. This keeps the “DB shape” separate from the “domain shape”.
  • mapCartStatus is 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.
Inserting a new cart

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 id that 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 Cart and normalizes the status through mapCartStatus, so callers never have to think about “raw” DB strings.
Repository: Fetching a “hydrated” cart with items and totals

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 product
  • totals: 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 null if missing” pattern is deliberate: repositories stay generic and don’t decide HTTP behavior. The route/service layer will convert null into a 404 when appropriate.
  • Building a base cart 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.
Loading cart items and product data

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_at so 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 productMap avoids 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.
Hydrating cart items and computing totals

Finally, we attach products onto each cart item and compute subtotal, tax, and total.

  • Each returned CartItem includes unit_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.
  • product is attached as productMap.get(ci.product_id) and can be undefined. 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.
Service layer: Coordinating IDs and shaping errors

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 bare Cart, 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.
Fetching a cart via the service
  • The repository returns Cart | null, but the service upgrades that into a clear business-level result: missing becomes a typed failure with httpStatus: 404.
  • This pattern matters because it gives the API route a single, uniform way to respond: “if result.ok return success; otherwise return an error envelope with result.error.httpStatus”.
  • Keeping “not found” logic here also makes it reusable. Any future route or workflow that needs to load a cart can call getCartService and get consistent behavior.
Recap

In this lesson you established the backbone of the carts API:

  • src/lib/repositories/cartsRepo.ts creates carts and returns “hydrated” cart reads that include items and computed totals.
  • getCartById composes the cart response by loading cart rows, item rows, and product rows—then computing subtotal and tax with computeTaxCents.
  • src/lib/services/cartsService.ts coordinates ID creation, standardizes errors, and returns a consistent ServiceResult shape 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.

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal