Products API: Updating and Archiving Products

Welcome back! 👋 Now that you can fetch a single product by ID, it’s time to make that product change over time—updating its fields and archiving it when it should no longer be sold.

In this lesson, you’ll focus on the single-product route at src/app/api/products/[id]/route.ts, specifically the PATCH and DELETE handlers. You’ll see how the route validates the path param, safely parses JSON, validates partial updates, and then delegates to the service layer to actually perform the update or archive.

Previously…

In the previous lesson, you implemented GET /api/products/:id, validating the UUID path parameter and distinguishing a 400 invalid ID from a 404 product not found. That same “be precise about outcomes” mindset carries forward here—except now we’re handling writes, so we add body parsing and patch validation too.

Two write operations on a single product

When working with /api/products/:id, we now support two common “write” actions:

  • PATCH /api/products/:id: apply a partial update (only the fields you send change).
  • DELETE /api/products/:id: archive the product (this project treats “delete” as an archive operation and returns the updated product).

Just like the GET-by-id endpoint, both routes clearly separate:

  • Invalid input (400 validation errors)
  • Missing product (404 not found)
  • Successful operation (200 with the updated product)
Route file: src/app/api/products/[id]/route.ts

This file defines the dynamic [id] route and implements GET, PATCH, and DELETE. In this lesson we’ll focus on PATCH and DELETE, since they introduce the most new patterns.

Shared setup: imports and route context:

This top portion establishes the shared tools the route uses: response helpers, JSON parsing, service functions, and UUID validation.

  • parseJson is a key difference from earlier routes. Instead of calling req.json() directly (and risking exceptions on malformed JSON), this helper returns a safe result object you can branch on.
  • validateUpdateProduct lives in the service file and is designed specifically for PATCH semantics: it accepts partial input and returns only the validated fields.
  • The RouteContext type matches how this project models dynamic params: context.params is a Promise, so every handler s it to access .
Updating a product: PATCH /api/products/:id

A PATCH endpoint is all about partial updates. The client might send only { "name": "New name" }, or it might send multiple fields. Your route needs to:

  1. Validate the id param (never trust the URL).
  2. Parse JSON safely (never trust the body).
  3. Validate the patch object (only allow known fields and correct types).
  4. Delegate to the service to update and return a meaningful outcome.

This first chunk handles the two biggest sources of “bad requests”: malformed IDs and malformed JSON bodies.

  • await context.params is the same pattern you used in GET-by-id. The important part is: we always validate id before doing any work.
  • The UUID validation returns a 400 VALIDATION_ERROR immediately, which prevents pointless database calls and keeps errors clear for clients.
  • parseJson(req) returns something like { ok: true, value: ... } or { ok: false, code, message }. That means invalid JSON becomes a normal branch, not an exception path.
  • On JSON parse failure, the route forwards the helper’s standardized error code/message and uses status 400. This keeps “bad JSON” consistent across endpoints.
Validating the patch and applying the update

Once we have a parsed body, we validate it as an update payload and then call the service.

  • validateUpdateProduct enforces PATCH rules: it only accepts known fields and validates each field if present. If the client sends an invalid type (like "price_cents": "free"), this returns a clear validation message.
  • The route maps any validator failure to 400 VALIDATION_ERROR. This is the route’s job: turn “bad input” into the right HTTP response.
  • updateProductService(id, valid.value) applies the update. If the product doesn’t exist, the service returns a structured NOT_FOUND error, and the route forwards it as a 404.
  • The error forwarding pattern is important: the route doesn’t guess status codes. It trusts the service’s { code, message, details, httpStatus } for domain outcomes.
  • On success, the route returns success(updated.value) (200). Returning the updated product is convenient for clients because they can immediately render the updated state without an extra GET call.
Archiving a product: DELETE /api/products/:id

In many real APIs, “delete” is implemented as a soft delete or archive—you keep the row, but mark it as inactive/archived. That’s what this project does: the route calls archiveProductService, and returns the archived product.

Validating the ID and delegating to the archive service:

  • Even though DELETE has no request body here, we still do the same critical first step: validate the UUID path param and return a 400 if it’s invalid.
  • archiveProductService(id) performs the “delete” behavior for this API. The route doesn’t implement the archive logic itself—it only coordinates.
  • If the product doesn’t exist, the service returns a NOT_FOUND error with httpStatus: 404, and the route forwards it unchanged. This mirrors the behavior you’ve already seen in GET and PATCH.
  • On success, success(archived.value) returns the archived product. This makes it easy for clients to update UI state (e.g., remove the product from active listings or show an “archived” badge).
Service functions: the quick mental model

The route is the star of this lesson, but it helps to know what the services promise so the route can stay clean.

validateUpdateProduct(input) in src/lib/services/productsService.ts:

  • This validator is built for PATCH: every field is optional, and only provided fields are validated and copied into out.
  • The output object contains only the allowed, validated fields—so the repository never receives raw client data.
  • This keeps validation consistent and reusable across any route that performs updates.
updateProductService(id, patch) and archiveProductService(id)
  • Both services follow the same pattern: call the repository, and if it returns nothing, translate that into a structured NOT_FOUND failure.
  • They return ServiceResult<Product> so the route can treat outcomes uniformly: if (!result.ok) forward the error; else return success(result.value).
  • Any thrown exceptions are handled via handleException, keeping “unexpected errors” consistent across the service layer.
Recap

In this lesson, you extended the single-product route to support writes:

  • PATCH /api/products/:id in src/app/api/products/[id]/route.ts:

    • validates the UUID path parameter,
    • uses parseJson(req) to safely handle malformed JSON,
    • validates partial updates with validateUpdateProduct,
    • delegates to updateProductService,
    • forwards structured service errors (like 404 NOT_FOUND) into API responses.
  • DELETE /api/products/:id:

    • validates the UUID path parameter,
    • archives via archiveProductService,
    • returns the archived product in a consistent success envelope.

With these handlers in place, your Products API now supports the full lifecycle of a product record: create, fetch, update, and archive—while keeping route code thin, predictable, and consistent.

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