Updating and Archiving Products

Lesson: Updating and Archiving Products

Welcome back! 👋 You can already list products, create products, and fetch a single product by ID. Now we’ll complete the “product lifecycle” by letting a product change over time:

  • PATCH /api/products/:id → apply a partial update (only the fields you send change)
  • DELETE /api/products/:id → “delete” by archiving (soft delete), so the product is no longer active

This lesson is where your backend starts behaving like a real system: you’ll validate URL input (the id), validate body input (the patch payload), and keep responsibilities clean by delegating updates/archiving to the service layer, which then delegates SQL work to the repository.

Previously…

In the previous lesson, you implemented GET /api/products/:id in app/routes/api.products.$id.ts. You validated params.id with isUUID, then delegated to getProductService, and returned consistent envelopes with:

  • 400 when the id is malformed (bad UUID)
  • 404 when the product doesn’t exist
  • 200 when a product is found

We’ll keep that exact same “be precise about outcomes” mindset here—except now we’re handling writes, so we also need safe JSON parsing and PATCH-specific validation.

Two write operations on a single product

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

  • PATCH updates some fields without requiring the whole product object. This is how you implement “edit name” or “adjust inventory” without resending everything.
  • DELETE archives the product rather than removing it. This avoids losing history and prevents downstream references (like orders) from pointing to missing rows.

Both operations must clearly separate:

  • Invalid input → 400
  • Missing resource → 404
  • Success → 200 with the updated/archived product

Route file: app/routes/api.products.$id.ts

This route is the “single product endpoint.” Remix wires it to /api/products/:id because $id is a dynamic segment. The action handles all write methods, so we branch on request.method.

This first small block shows what the route depends on. The big idea is: routes deal with HTTP mechanics and call services—routes do not talk directly to SQL.

import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node';
import { success, error, parseJson } from '@/lib/http/response';
import {
  getProductService,
  updateProductService,
  archiveProductService,
  validateUpdateProduct,
} from '@/lib/services/productsService';
import { isUUID } from '@/lib/http/validation';
  • success(...) and error(...) enforce a consistent response envelope, which makes client code simpler because every API response looks predictable.
  • parseJson(request) is a safe JSON parsing helper that avoids “invalid JSON” causing unhandled exceptions; instead, it gives you a structured result you can branch on.
  • validateUpdateProduct lives in the service module because validation rules are domain logic you want to reuse and test, not something you want duplicated in multiple routes.
  • isUUID is used at the route boundary because the URL is untrusted input; validating early prevents meaningless database queries and keeps error messages clear.
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