Creating and Viewing Carts

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.

Carts are first-class resources

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 status lifecycle: "open", "checked_out", "abandoned".
  • Timestamps (created_at, updated_at) managed by the database.
  • A consistent read shape that includes items and totals, 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.

Repository essentials: mapping rows into safe domain values

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 status is 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.status is one of the allowed statuses.
Mapping cart item rows

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_cents is 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 carts in the repository

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 id as 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 status is normalized through mapCartStatus, which means even freshly inserted rows are guaranteed to match domain expectations.
Reading “hydrated” carts in the repository

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 null is 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 into 404 Not Found.
  • Splitting out a base cart object makes the function easier to reason about. You establish the cart’s identity and metadata first, then attach computed data (items, totals) afterward.
  • mapCartStatus is applied during reads too, so the cart is always safe to consume regardless of what raw string the DB returned.
Loading cart item rows

Next, we load all cart items for this cart, ordered by creation time.

  • Ordering by created_at ASC makes 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 carts and cart_items are 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.
Loading referenced product data and building a lookup map

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.
  • productMap turns the array into O(1) lookups (productMap.get(productId)), so attaching products to items is efficient and clean.
  • Product fields are normalized defensively: currency is forced to "USD" and status is coerced into "active"/"archived" with a safe default. This prevents inconsistent DB strings from breaking client assumptions.
Hydrating cart items and computing totals

Finally, we build items with attached products and compute totals in cents.

  • Each returned item includes its persisted fields plus an optional product. The product can be undefined if something is inconsistent (for example, a product row was removed or not returned).
  • Allowing product to 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_cents from 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_cents is the sum of quantity * unit_price_cents across all items. Doing this in cents avoids floating-point rounding errors that happen when using dollars as decimals.
Tax calculation: small helper, big consistency win

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_BPS is 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_BPS means every caller uses the same default rate without re-parsing environment variables in multiple places.
Computing tax in cents

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 0 keeps totals clean and avoids surprising negative/NaN values.
  • The formula (subtotalCents * rateBps) / 10_000 converts basis points into a percentage and produces a tax amount in cents. Using Math.round establishes a consistent rounding rule, which is critical for money math.
  • Because computeTaxCents is 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.
Service layer: coordinating identity and meaning

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/catch funnels all thrown errors into handleException, which maps Postgres errors when possible and otherwise returns a structured INTERNAL_ERROR. This keeps route code clean and prevents duplicated error handling everywhere.
Fetching carts via the service

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 getCartById directly or use ensureCartOpen patterns without re-implementing error semantics.
API routes: thin HTTP wrappers around services

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.method first. This makes the endpoint predictable: anything other than POST gets a 405 with a clear message, rather than accidentally doing the wrong thing.
  • createCartService() returns a ServiceResult, 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.
Fetching a cart by ID: `GET /api/carts/:id`

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 validating undefined and 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 an ok cart or a structured failure (like NOT_FOUND with 404). The route simply forwards those values into the standard error(...) 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.
Recap

You now have the two core cart read/write entry points fully wired:

  • src/lib/repositories/cartsRepo.ts owns persistence and hydration, including:

    • createCart(id) inserting the row

    • getCartById(id) composing items + computing totals:

      • subtotal_cents = Σ(quantity * unit_price_cents)
      • tax_cents = computeTaxCents(subtotal_cents)
      • total_cents = subtotal_cents + tax_cents
  • src/lib/money/tax.ts centralizes tax logic so totals are computed consistently and safely in cents.

  • src/lib/services/cartsService.ts coordinates meaning and identity:

    • createCartService() generates UUIDs and returns ServiceResult
    • getCartService(id) converts missing carts into NOT_FOUND (404)
  • app/routes/api.carts.ts exposes and returns on success.

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