Adding Cart Line Items

Welcome back! 👋 Now that we can create carts and fetch them in a stable, predictable shape, it’s time to make a cart useful by adding items to it.

In this lesson you’ll follow the full “Add to cart” flow end-to-end: the API route parses and validates the request, the service layer enforces business rules (like inventory limits), and the repository performs an add-or-increment write safely inside a transaction. This is the exact behavior you see in real e-commerce backends when a user clicks “Add to cart” multiple times.

Previously…

In the last lesson, we treated the cart as a first-class backend resource: you created carts and fetched them back fully “hydrated” with items and computed totals. That stable response shape is what makes today’s work clean—once we add cart_items rows, GET /api/carts/:id automatically becomes meaningful because totals can now be computed from those line items.

What a “line item” represents

A cart line item is one row in cart_items. It captures:

  • Which product was added (product_id)
  • How many (quantity)
  • What price was used when it was added (unit_price_cents)

That last field is a snapshot: it preserves stability if product prices change later. Our totals logic (from the previous lesson) uses this snapshot when computing subtotal and tax.

The API endpoint for adding items

The main entry point for “Add to cart” is:

  • POST /api/carts/:id/items

This is implemented in src/app/api/carts/[id]/items/route.ts. The handler is deliberately strict: it validates the cart ID, safely parses JSON, validates the body shape, then delegates to the service layer and converts the result into a consistent HTTP response.

Route setup and imports:

This first part shows what the route depends on and how Next.js provides dynamic route params in this codebase.

  • success and error are the project’s response helpers, so every route returns a consistent envelope instead of hand-rolling NextResponse formatting in each file.
  • parseJson(req) is used instead of req.json() directly so the route can handle malformed JSON without crashing. That keeps error handling consistent and prevents “unexpected 500s” for simple client mistakes.
  • RouteContext is important: in this project, context.params is a Promise. That’s why the handler does const { id } = await context.params instead of accessing synchronously.
Reading and validating the cart ID

Now we step into the POST handler and handle route params early.

  • The cart ID comes from the dynamic route segment [id], so validating it up front is a cheap way to reject bad requests before touching the database.
  • Returning 400 for an invalid UUID is a key contract: this is a client error (“your URL is wrong”), not a missing resource.
  • This handler keeps the “happy path” readable by failing fast. As soon as something is invalid, it returns immediately with a standard error envelope.
Safely parsing JSON and validating the request body

Next the route reads the JSON body, but does it defensively, then validates the payload’s fields.

  • parseJson(req) returns a result object instead of throwing. That means malformed JSON becomes a clean 400 response, not a runtime exception.
  • Notice the route returns error(bodyResult.code, bodyResult.message, ..., 400) when parsing fails. This preserves the helper’s standardized error code/message while still making it an HTTP 400.
  • validateAddItem is intentionally run after parsing succeeds. By the time the service runs, it can trust that product_id and quantity are present and correctly shaped.
Calling the service and translating results into HTTP

Finally, the route delegates business logic to the service layer and maps service failures into HTTP errors.

  • The route does not guess which HTTP status should be used for service failures. Instead, it trusts result.error.httpStatus, which centralizes that decision in the service layer.
  • This is why the service returns ServiceResult<T>: it’s not just “success or failure”—it also carries the correct HTTP mapping and structured details if needed.
  • Returning 201 on success is intentional. Even if the item already existed and the operation incremented quantity, it’s still a successful “add item” mutation.
Validation: protecting the service layer from bad input

Validation for “add item” lives in src/lib/services/cartsService.ts. Even though it’s in the service file, it’s designed to be reusable: routes can call it before invoking service functions.

Validating product_id and quantity:

  • input is unknown because the route is passing untrusted data coming from the network. This forces us to validate before returning a typed value.
  • isUUID ensures we don’t pass garbage IDs into database lookups. Without this, a malformed ID can cause confusing errors and noisier logs.
  • isInt(v?.quantity, { min: 1 }) is enforcing a cart invariant: quantities must be whole numbers and must be at least 1. This prevents “0 quantity” rows and blocks negative or fractional values that would corrupt totals.
Repository: add-or-increment with a transaction

The repository function addOrIncrementCartItem lives in src/lib/repositories/cartsRepo.ts. This is where we actually write to cart_items, and it’s built specifically to prevent duplicates and race conditions.

One function that either updates or inserts:

  • Everything runs inside withTransaction, which is the critical ingredient for correctness. Without a transaction, two concurrent “add item” requests could both not see an existing row and both insert duplicates.
  • The “uniqueness idea” here is (cart_id, product_id): one cart should have at most one row per product. That’s why the select checks those two fields together.
  • If the row already exists, the update increments quantity and sets updated_at = now(). This makes “add twice” behave like the user expects: it increases quantity rather than creating separate lines.
Service: enforcing business rules for adding items

The service function addItemService in src/lib/services/cartsService.ts is where we enforce rules that are not purely “shape validation,” such as:

  • the cart must exist and be open
  • the product must exist and not be archived
  • inventory must be sufficient
  • adding more of an already-present product must still respect inventory limits

Coordinating cart state, product availability, and inventory:

  • The first two lines establish a pattern you’ll see throughout the backend: load the resource, then enforce lifecycle rules. ensureCartOpen makes sure the cart exists and has status === 'open', returning a for missing carts and a for non-open carts.
How this connects back to fetching carts

After POST /api/carts/:id/items succeeds, the cart becomes meaningful when you fetch it:

  • items will now contain at least one line item.
  • totals will reflect quantity * unit_price_cents, and tax will be computed from the subtotal (as you saw in the previous lesson’s getCartById).

That’s the payoff of the stable cart shape you built earlier: once writes happen, reads automatically become richer without changing the read endpoint.

Recap

In this lesson you implemented the classic “Add to cart” behavior using clean layering:

  • src/app/api/carts/[id]/items/route.ts validates the cart ID, safely parses JSON, validates the body, and maps service results into HTTP responses.
  • validateAddItem protects the entire stack by rejecting invalid product_id and quantity before business logic runs.
  • addItemService enforces real business rules: cart lifecycle, product availability, and inventory constraints.
  • addOrIncrementCartItem performs the core add-or-increment write inside a transaction so you don’t create duplicates under concurrency.

Next up, once adding works, updating and deleting items become straightforward—because the route/service/repo patterns stay the same, and only the rules change.

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