Adding Cart Line Items

Adding Cart Line Items

Welcome back! 👋 Now that carts can be created and fetched in a fully hydrated shape (with items and computed totals), it’s time to make them actually useful by adding line items.

In this lesson, you’ll implement the full “Add to Cart” flow: the route validates input and delegates to the service, the service enforces business rules like inventory and cart lifecycle, and the repository performs an atomic add-or-increment write using a transaction. This mirrors how real e-commerce backends handle users clicking “Add to cart” multiple times—correctly and safely, even under concurrency.

Previously, your cart reads were already computing subtotal_cents, tax_cents, and total_cents. Once line items are added, those totals automatically become meaningful—because the repository recomputes them on every read.

What a Cart Line Item Represents

A cart line item corresponds to one row in the cart_items table. It captures:

  • The product_id that was added.
  • The quantity requested.
  • The unit_price_cents at the time of addition (a snapshot).

That unit_price_cents field is critical. Even if the product’s price changes later, your cart totals are computed from what was recorded in the cart. This keeps pricing stable and predictable for the user.

Route: POST /api/carts/:id/items

The entry point for adding items is implemented in:

app/routes/api.carts.$id.items.ts

This route is responsible for:

  • Validating the :id URL parameter.
  • Allowing only POST.
  • Safely parsing JSON with parseJson(...).
  • Validating the body using validateAddItem(...).
  • Delegating to addItemService(...).
  • Mapping service failures using the service-provided httpStatus.
  • Returning 201 Created on success.

Let’s walk through it carefully.

First, the imports and route function:

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

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

    if (request.method !== "POST") {
      return error("VALIDATION_ERROR", "Method not allowed", undefined, 405);
    }

    const bodyResult = await parseJson(request);
    if (!bodyResult.ok)
      return error(bodyResult.code, bodyResult.message, undefined, 400);

    const valid = validateAddItem(bodyResult.value);
    if (!valid.ok)
      return error("VALIDATION_ERROR", valid.message, undefined, 400);

    const result = await addItemService(id, valid.value);
    if (!result.ok)
      return error(
        result.error.code,
        result.error.message,
        result.error.details,
        result.error.httpStatus,
      );

    return success(result.value, 201);
  } catch (e: unknown) {
    return error(
      "INTERNAL_ERROR",
      e instanceof Error ? e.message : "Internal error",
      undefined,
      500,
    );
  }
}
  • The route extracts params.id and immediately validates it using isUUID. If the ID is malformed, it returns a 400 VALIDATION_ERROR. This prevents meaningless database calls and clearly communicates “your URL is wrong.”
  • Only POST is allowed. Any other method returns 405. This keeps the endpoint strict and predictable.
  • parseJson(request) is used instead of directly calling request.json(). This ensures malformed JSON doesn’t crash the route and instead becomes a structured 400 error.
  • validateAddItem(...) converts untrusted input into a strongly shaped AddCartItemInput. If validation fails, the route returns 400 before hitting any business logic.
  • addItemService(...) returns a ServiceResult. The route does not guess HTTP codes—it forwards result.error.httpStatus, centralizing domain-to-HTTP mapping inside the service.
  • On success, success(result.value, 201) returns 201 Created. Even if the row was incremented instead of inserted, it is still a successful mutation.

This route stays thin. It owns HTTP mechanics, not business rules.

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