Managing Shopping Carts

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.

// src/lib/repositories/cartsRepo.ts
function mapCartStatus(status: string): Cart["status"] {
  return ["open", "checked_out", "abandoned"].includes(status)
    ? (status as Cart["status"])
    : "open";
}
  • 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.

// src/lib/repositories/cartsRepo.ts
function mapCartItemRow(ci: CartItemRow): CartItem {
  return {
    id: ci.id,
    cart_id: ci.cart_id,
    product_id: ci.product_id,
    quantity: ci.quantity,
    unit_price_cents: ci.unit_price_cents,
    created_at: ci.created_at,
    updated_at: ci.updated_at,
  };
}
  • 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.

// src/lib/repositories/cartsRepo.ts
export async function createCart(id: string): Promise<Cart> {
  const rows = await query<CartRow>(
    `INSERT INTO carts (id) VALUES ($1) RETURNING *`,
    [id],
  );
  const r = rows[0];
  return {
    id: r.id,
    status: mapCartStatus(r.status),
    created_at: r.created_at,
    updated_at: r.updated_at,
  };
}
  • 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.

// src/lib/repositories/cartsRepo.ts
export async function getCartById(id: string): Promise<Cart | null> {
  const rows = await query<CartRow>("SELECT * FROM carts WHERE id = $1", [id]);
  if (!rows[0]) return null;

  const base: Cart = {
    id: rows[0].id,
    status: mapCartStatus(rows[0].status),
    created_at: rows[0].created_at,
    updated_at: rows[0].updated_at,
  };

  // ...items and totals are added below
  • 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.

// src/lib/repositories/cartsRepo.ts
  const items = await query<CartItemRow>(
    `SELECT ci.* FROM cart_items ci WHERE ci.cart_id = $1 ORDER BY ci.created_at ASC`,
    [id],
  );
  • 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.

// src/lib/repositories/cartsRepo.ts
  const productRows = await query<
    Pick<Product, "id" | "sku" | "name" | "price_cents" | "currency" | "status">
  >(
    `SELECT id, sku, name, price_cents, currency, status FROM products WHERE id IN (
      SELECT product_id FROM cart_items WHERE cart_id = $1
    )`,
    [id],
  );

  const productMap = new Map(
    productRows.map((p) => {
      const normalized: Pick<
        Product,
        "id" | "sku" | "name" | "price_cents" | "currency" | "status"
      > = {
        ...p,
        currency: "USD",
        status: ["active", "archived"].includes(p.status)
          ? (p.status as Product["status"])
          : "active",
      };
      return [normalized.id, normalized];
    }),
  );
  • 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.
  • Even though price_cents exists on the product, we still keep unit_price_cents on the cart item. The product’s price is useful for display, but totals are computed from the cart item snapshot.

Hydrating cart items and computing totals

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

// src/lib/repositories/cartsRepo.ts
  const cartItems: CartItem[] = items.map((ci) => ({
    id: ci.id,
    cart_id: ci.cart_id,
    product_id: ci.product_id,
    quantity: ci.quantity,
    unit_price_cents: ci.unit_price_cents,
    created_at: ci.created_at,
    updated_at: ci.updated_at,
    product: productMap.get(ci.product_id),
  }));
  • 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:

// src/lib/repositories/cartsRepo.ts
  const subtotal = cartItems.reduce(
    (acc, it) => acc + it.quantity * it.unit_price_cents,
    0,
  );
  const tax = computeTaxCents(subtotal);
  const currency = cartItems[0]?.product?.currency ?? "USD";
  • 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 is computed from the subtotal using computeTaxCents(subtotal). This keeps tax as a pure derived value instead of something stored and potentially drifting out of sync.
  • Currency is chosen from the first product if present, otherwise "USD". That gives empty carts a stable currency value and avoids returning null/undefined to clients.

And this is the returned hydrated cart shape:

// src/lib/repositories/cartsRepo.ts
  return {
    ...base,
    items: cartItems,
    totals: {
      subtotal_cents: subtotal,
      tax_cents: tax,
      total_cents: subtotal + tax,
      currency,
    },
  };
}
  • items is always present (it will be an empty array if there are no items), and totals is always present with numeric cents fields. This stability is intentional: clients don’t need special “empty cart” logic.
  • total_cents is derived as subtotal_cents + tax_cents. That ensures your “final” displayed price always matches the same calculation rule everywhere.
  • Returning a hydrated cart from the repository means every route or service that fetches a cart gets the same rich, client-friendly shape automatically.

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.

// src/lib/money/tax.ts
function resolveDefaultTaxRate(): number {
  const raw = process.env.TAX_RATE_BPS;
  if (!raw) return 0;
  const parsed = Number.parseInt(raw, 10);
  if (!Number.isFinite(parsed) || Number.isNaN(parsed) || parsed < 0) return 0;
  return parsed;
}

export const DEFAULT_TAX_RATE_BPS = resolveDefaultTaxRate();
  • 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.

// src/lib/money/tax.ts
export function computeTaxCents(
  subtotalCents: number,
  rateBps = DEFAULT_TAX_RATE_BPS,
): number {
  if (!Number.isFinite(subtotalCents) || subtotalCents <= 0 || rateBps === 0)
    return 0;
  const tax = Math.round((subtotalCents * rateBps) / 10_000);
  return tax;
}
  • 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.

// src/lib/services/cartsService.ts
export async function createCartService(): Promise<ServiceResult<Cart>> {
  try {
    const cart = await createCart(randomUUID());
    return ok(cart);
  } catch (e: unknown) {
    return handleException(e);
  }
}
  • 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.

// src/lib/services/cartsService.ts
export async function getCartService(id: string): Promise<ServiceResult<Cart>> {
  try {
    const cart = await getCartById(id);
    if (!cart)
      return fail({
        code: "NOT_FOUND",
        message: "Cart not found",
        httpStatus: 404,
      });
    return ok(cart);
  } catch (e: unknown) {
    return handleException(e);
  }
}
  • 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:

// app/routes/api.carts.ts
import type { ActionFunctionArgs } from "@remix-run/node";
import { success, error } from "@/lib/http/response";
import { createCartService } from "@/lib/services/cartsService";

export async function action({ request }: ActionFunctionArgs) {
  try {
    if (request.method !== "POST") {
      return error("VALIDATION_ERROR", "Method not allowed", undefined, 405);
    }
    const cart = await createCartService();
    if (!cart.ok)
      return error(
        cart.error.code,
        cart.error.message,
        cart.error.details,
        cart.error.httpStatus,
      );
    return success(cart.value, 201);
  } catch (e: unknown) {
    return error(
      "INTERNAL_ERROR",
      e instanceof Error ? e.message : "Internal error",
      undefined,
      500,
    );
  }
}
  • 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.
  • success(cart.value, 201) returns a created cart with 201 Created, which communicates “a new resource was created.” This is a small detail that makes your API feel more professional and standards-aligned.
  • The outer try/catch is the route’s last-resort safety net. Even if something unexpected happens, the client still gets a consistent error envelope instead of a raw stack trace.

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:

// app/routes/api.carts.$id.ts
import type { LoaderFunctionArgs } from "@remix-run/node";
import { success, error } from "@/lib/http/response";
import { getCartService } from "@/lib/services/cartsService";
import { isUUID } from "@/lib/http/validation";

export async function loader({ params }: LoaderFunctionArgs) {
  try {
    const id = params.id ?? "";
    if (!isUUID(id))
      return error("VALIDATION_ERROR", "Invalid id", undefined, 400);

    const cart = await getCartService(id);
    if (!cart.ok)
      return error(
        cart.error.code,
        cart.error.message,
        cart.error.details,
        cart.error.httpStatus,
      );

    return success(cart.value);
  } catch (e: unknown) {
    return error(
      "INTERNAL_ERROR",
      e instanceof Error ? e.message : "Internal error",
      undefined,
      500,
    );
  }
}
  • 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 items and totals as built in getCartById. 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 POST /api/carts and returns 201 on success.

  • app/routes/api.carts.$id.ts exposes GET /api/carts/:id, validates UUIDs, and returns a hydrated cart.

Next, this foundation makes cart mutations (add/update/delete items) straightforward, because the cart shape and totals rules are already stable and reusable.

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