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.

  • 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.
Updating a product: PATCH `/api/products/:id`

PATCH updates are all about partial changes. A client might send only { "name": "New name" }. Your backend must accept that, validate it, and update only that column.

This block shows the start of the action function: pull out the id, validate it, then branch based on the HTTP method.

  • params.id ?? '' ensures you always have a string to validate, even if params.id is missing for some reason; this avoids passing undefined deeper into your logic.
  • The early isUUID(id) check is critical: invalid IDs should be treated as malformed requests (400), not “not found” (404). This also prevents wasted database calls.
  • Branching on request.method is how Remix handles multiple write operations in a single file—one action function, multiple behaviors.
  • Returning 405 for unsupported methods makes the route defensive and predictable (clients get an explicit “not allowed” instead of surprising behavior).
PATCH step 1: safe JSON parsing

Before you validate fields, you need to safely read the body. The key problem: request.json() can throw if the JSON is malformed. This project uses parseJson so invalid JSON becomes a normal, controlled error response.

  • parseJson(request) returns a result object rather than throwing, which keeps your action flow consistent and prevents “bad JSON” from skipping your normal response envelope.
  • When bodyResult.ok is false, the helper already knows what kind of error it is (for example INVALID_JSON) and provides a friendly message; the route simply returns it as a 400.
  • This pattern scales well: every route that accepts JSON can reuse this helper, making malformed-body behavior consistent across your entire API.
PATCH step 2: validate the partial update payload

Now that you have parsed JSON, you still can’t trust it. PATCH payloads can include wrong types, unknown fields, or values outside allowed ranges. validateUpdateProduct is a service-level validator that checks each optional field only if it’s present.

  • This validation step is what turns “untrusted JSON” into a safe typed object (UpdateProductInput) that the rest of the system can rely on.
  • The validator is PATCH-aware: it does not require fields, but if you send a field, it must be correct (e.g., inventory_count must be an integer ≥ 0).
  • Returning a VALIDATION_ERROR with status 400 keeps “client mistakes” clearly separated from “server failures,” which helps debugging and makes API behavior more professional.
PATCH step 3: call the update service and forward results

After validation, you delegate to the service. The service returns a ServiceResult, so the route can forward failures (like NOT_FOUND) without guessing status codes.

  • updateProductService is responsible for the domain-level meaning of the update attempt: does the product exist, did the DB reject something, etc.
  • If the service returns ok: false, it includes everything needed to respond: an API error code, a message, optional details, and the correct HTTP status.
  • On success, returning the updated product is a great client experience: the UI can immediately render the latest server state without needing to issue a follow-up GET.
Patch validation in detail: `validateUpdateProduct`

PATCH validation is special: every field is optional, but any provided field must be validated. The validator also constructs a new object that includes only the allowed and validated fields.

This code block shows the overall shape of the validator: read from the untrusted v, validate, and copy into out.

  • Accepting input: unknown forces you to validate. This is the correct mindset for API development: treat all external input as hostile until proven safe.
  • out is your “clean result.” Downstream code uses out, never the raw input object, which prevents accidental reliance on unvalidated fields.
  • Returning { ok: true, value: out } establishes a consistent validation pattern that the route can handle cleanly with if (!valid.ok) return error(...).
Validator: string fields (`name`, `description`)

Now we validate string-ish fields. Notice the difference:

  • name must be a non-empty string
  • description can be string or null (allowing clients to clear it)
  • Checking !== undefined is how PATCH semantics work: “undefined means not provided,” so we don’t validate or update it.
  • The name rule prevents empty names from being stored, which is a realistic domain constraint for products.
  • Allowing description: null supports a real UI case: clearing a description intentionally, rather than forcing it to always be a string.
Validator: numeric fields (price_cents, inventory_count)

Numeric fields should never accept strings like "12" or "free". They must be integers, and they must be bounded to prevent negative or nonsense values.

  • Prices in cents are used to avoid floating point issues; requiring an integer ensures calculations remain safe and predictable.
  • min: 0 prevents invalid states like negative prices or negative inventory, which would cause downstream bugs in carts and orders.
  • This validation belongs before the repository because you don’t want invalid values reaching SQL; catching it early provides clearer error messages for clients.
Validator: enums (currency, status)

Enums enforce “only known values.” That’s how you prevent inconsistent string values from creeping into your database and breaking assumptions elsewhere.

  • currency is intentionally constrained right now; it keeps the system simple early and avoids introducing multi-currency complexity before carts/orders exist.
  • status controls lifecycle and visibility in listings. Restricting it prevents clients from setting random states like "deleted" or "inactive".
  • Enum checks are a major backend hygiene practice: they turn “stringly typed chaos” into stable, predictable domain rules.
Applying PATCH updates in the service + repository

Once your patch is validated, the service translates repository outcomes into API outcomes, and the repository constructs an UPDATE query that updates only the provided fields.

  • The service is the “meaning” layer: it decides what repository return values mean in API terms.
  • null from the repository is treated as a missing resource, which maps naturally to a 404.
  • Any thrown exceptions (including database errors) are converted to structured failures via handleException, so routes don’t have to interpret raw exceptions.
Repository: build the dynamic UPDATE statement

PATCH means the set of fields changes per request, so the SQL must be assembled dynamically while still remaining parameterized and safe.

  • fields collects the SQL “assignments” (name = $1, price_cents = $2, etc.), and values collects the matching values in the exact same order.
  • idx tracks the placeholder number so you can add fields in any sequence without breaking the parameter ordering.
  • This pattern keeps the query parameterized, which protects correctness and avoids string concatenation of user values into SQL.
Repository: handle an “empty patch” gracefully

If a client sends {}, there’s nothing to update. Instead of building invalid SQL, the repository returns the existing product.

  • This makes the endpoint stable even if the client sends no changes (intentionally or accidentally).
  • Returning the current product is often friendlier than throwing a validation error, because it makes PATCH idempotent-like and avoids surprising clients.
  • This behavior also keeps the update function safe: it never executes malformed UPDATE statements.
Repository: run UPDATE + RETURNING *

Finally, the query runs and returns the updated row.

  • values.push(id) adds the final parameter for the WHERE id = $N clause, ensuring the update targets exactly one product.
  • updated_at = now() is applied server-side so timestamp updates are consistent and cannot be faked by clients.
  • RETURNING * avoids a second query: you update and fetch the updated row in one DB round trip, which improves both performance and simplicity.
Archiving a product: DELETE `/api/products/:id`

DELETE in this project is a “soft delete” that sets status = 'archived'.

  • There’s no JSON parsing here because DELETE doesn’t need a body in this system.
  • The route stays thin: it delegates the domain meaning to the service and simply forwards the service result.
  • Returning the archived product is useful because the client can confirm the final state (status: 'archived') without additional requests.
Repository: archive is a simple UPDATE
  • This is a clean, standard soft-delete approach: keep the record, mark it archived, update the timestamp.
  • RETURNING * again gives you the updated product immediately, which is perfect for API responses.
  • Returning null when no row matches enables the service to convert “missing product” into a 404 consistently.
Recap

After this lesson, /api/products/:id supports full single-product lifecycle operations:

  • PATCH updates validated fields only, using safe parsing, strict validation, and a dynamic SQL UPDATE.
  • DELETE archives the product (soft delete), preserving history while removing it from active use.
  • Routes remain thin, and services/repositories own the domain meaning and storage details, keeping your codebase scalable as the e-commerce system grows.
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