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.
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
idis 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.
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
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(...)anderror(...)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.validateUpdateProductlives in the service module because validation rules are domain logic you want to reuse and test, not something you want duplicated in multiple routes.isUUIDis used at the route boundary because the URL is untrusted input; validating early prevents meaningless database queries and keeps error messages clear.
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 ifparams.idis missing for some reason; this avoids passingundefineddeeper 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.methodis how Remix handles multiple write operations in a single file—oneactionfunction, multiple behaviors. - Returning
405for unsupported methods makes the route defensive and predictable (clients get an explicit “not allowed” instead of surprising behavior).
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.okis false, the helper already knows what kind of error it is (for exampleINVALID_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.
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_countmust be an integer ≥ 0). - Returning a
VALIDATION_ERRORwith status 400 keeps “client mistakes” clearly separated from “server failures,” which helps debugging and makes API behavior more professional.
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.
updateProductServiceis 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 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: unknownforces you to validate. This is the correct mindset for API development: treat all external input as hostile until proven safe. outis your “clean result.” Downstream code usesout, 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 withif (!valid.ok) return error(...).
Now we validate string-ish fields. Notice the difference:
namemust be a non-empty stringdescriptioncan be string or null (allowing clients to clear it)
- Checking
!== undefinedis how PATCH semantics work: “undefined means not provided,” so we don’t validate or update it. - The
namerule prevents empty names from being stored, which is a realistic domain constraint for products. - Allowing
description: nullsupports a real UI case: clearing a description intentionally, rather than forcing it to always be a string.
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: 0prevents 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.
Enums enforce “only known values.” That’s how you prevent inconsistent string values from creeping into your database and breaking assumptions elsewhere.
currencyis intentionally constrained right now; it keeps the system simple early and avoids introducing multi-currency complexity before carts/orders exist.statuscontrols 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.
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.
nullfrom 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.
PATCH means the set of fields changes per request, so the SQL must be assembled dynamically while still remaining parameterized and safe.
fieldscollects the SQL “assignments” (name = $1,price_cents = $2, etc.), andvaluescollects the matching values in the exact same order.idxtracks 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.
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.
Finally, the query runs and returns the updated row.
values.push(id)adds the final parameter for theWHERE id = $Nclause, 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.
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.
- 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
nullwhen no row matches enables the service to convert “missing product” into a 404 consistently.
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.
