Welcome back! 👋 Now that your cart can gain items via “add to cart,” the next step is letting clients manage those items: change quantities and remove a line entirely.
In this lesson, you’ll implement the two core mutations that every cart needs: PATCH to update a line item’s quantity, and DELETE to remove a line item. Along the way, you’ll see how the repository provides precise “row-level” operations, how the service layer enforces business rules (cart state, product availability, inventory), and how the route maps those outcomes into consistent HTTP responses without hardcoded status codes.
Previously, adding items created rows in cart_items, and cart reads automatically computed totals from those rows. Managing items builds directly on that: when quantities change or rows are deleted, the cart totals naturally change on the next GET /api/carts/:id.
A cart line item is uniquely identified by:
cart_id(the cart that owns it)id(the item row’s ID)
That means every “manage” operation needs to be scoped by both values. Scoping by both is not just correctness—it’s security and integrity: you never want to update or delete an item from the wrong cart.
In this lesson, you’ll cover three repository capabilities that make safe mutations possible:
getCartItemByCartAndId(cartId, itemId)→ read a specific item (ornull)updateCartItemQuantity(cartId, itemId, quantity)→ update quantity withUPDATE ... RETURNING(ornull)deleteCartItem(cartId, itemId)→ delete withDELETE ... RETURNING(returnsboolean)
Then you’ll wire those into service rules and expose them through:
app/routes/api.carts.$id.items.$itemId.ts
The service layer often needs to detect “missing item” before it tries to update or delete. That’s why the repository provides a targeted lookup.
This code lives in src/lib/repositories/cartsRepo.ts and returns either the cart item or null.
- The key detail is the
WHERE cart_id = $1 AND id = $2scope. This guarantees you only ever retrieve an item that belongs to the specified cart, which prevents cross-cart access bugs. - Returning
nullis intentional: the repository is “SQL-in / data-out.” It doesn’t decide HTTP behavior; it gives the service enough information to turn missing rows into a404. mapCartItemRowkeeps the returned shape consistent with the rest of the domain layer, so services and routes don’t work with raw DB rows.
This function is especially valuable because it lets the service check business rules (like product availability) using the item’s product_id before applying updates.
When updating quantity, we want a single database statement that both performs the update and returns the updated row. That’s what RETURNING * gives us.
This function lives in src/lib/repositories/cartsRepo.ts.
- The update is scoped by both
cart_idandid. This prevents accidental updates to an item row that happens to exist but belongs to a different cart. updated_at = now()ensures the timestamp reflects the server-side mutation time. Clients don’t control timestamps, and you want consistent auditability.RETURNING *removes the need for a follow-upSELECT. You update and retrieve the new state in one round trip, which is both simpler and more efficient.- Returning
nullwhen no row is returned provides a clean “not found” signal. The service can convert that into a404without trying to infer from affected row counts manually.
This repository function is intentionally “dumb” about inventory and product status—those are business rules and belong in the service layer.
For deletes, we want a clear signal: “did we actually delete something?” The simplest way here is DELETE ... RETURNING * and then return true when a row existed.
This function lives in src/lib/repositories/cartsRepo.ts.
- Again, scoping by
(cart_id, id)is essential. It ensures the delete cannot remove a row outside the cart being mutated. - Using
RETURNING *lets you detect whether a row was actually deleted. If nothing matched, Postgres returns no rows, andBoolean(rows[0])becomesfalse. - Returning a boolean is a deliberate repository-level contract: the repository answers the persistence question (“did a row exist and get deleted?”), and the service decides what that means (usually
404iffalse).
This sets up a clean service flow: check cart open → check item exists → attempt delete → confirm delete.
Before you update anything, the request body must be validated. For this endpoint, the only allowed update is quantity, and the shape must effectively be:
Validation lives in src/lib/services/cartsService.ts. It returns a typed value on success and a clear message on failure.
- Accepting
input: unknownforces the function to treat the body as untrusted, which is the right stance for API code. - The validator requires
quantityto be an integer and at least1. This prevents invalid states like zero-quantity rows (which would behave like “soft delete” but without actually deleting). - The return shape is a clear union:
{ ok: false, message }makes route error mapping straightforward and consistent. - The output value is reconstructed as
{ quantity: v.quantity! }, ensuring the rest of the system works only with validated data instead of the raw input object.
In practice, this validator keeps the route simple: parse JSON → validate → call service.
The service method updateItemService(cartId, itemId, input) enforces the rules that make the update meaningful and safe:
- Cart must exist and be open (404 / 409)
- Cart item must exist for that cart (404)
- Product still exists and is purchasable (missing/archived → 409)
- Inventory must be sufficient (409)
This code lives in src/lib/services/cartsService.ts.
- The call to
ensureCartOpenis the gatekeeper for cart lifecycle. A checked-out or abandoned cart is a real cart, but it’s not valid to mutate—so the service returns409 CONFLICT. getCartItemByCartAndId(cartId, itemId)ensures the item exists and belongs to the cart. If it’s missing, we return404, which is the clean “you can’t update what doesn’t exist” outcome.
Deleting also needs lifecycle enforcement and “missing item” checks. The service returns { deleted: true } on success, which makes the API response predictable.
This code lives in src/lib/services/cartsService.ts.
- The same cart lifecycle gate (
ensureCartOpen) applies here. If the cart is not open, deleting items would break the “checked out cart is immutable” rule, so409is returned. - The explicit “does the item exist?” check makes error messages consistent and intentional. It also lets you fail fast before doing a delete operation.
- The repository delete returns a boolean, so the service can cleanly map
falseto404. This is especially useful for handling concurrency (e.g., two delete requests in a row). - Returning
{ deleted: true }provides a stable response contract: clients don’t need to interpret an empty response body to know what happened.
The mutation endpoint for managing line items is:
app/routes/api.carts.$id.items.$itemId.ts
This route validates both IDs, supports PATCH and DELETE, and returns consistent envelopes for every outcome.
Here’s the full route:
-
The route validates both
params.idandparams.itemIdwith . If either is invalid, it returns immediately. This prevents ambiguous errors and avoids useless DB queries.
Once PATCH or DELETE succeeds, the cart totals will automatically reflect the change on the next read:
- Updating quantity changes
subtotal_centsbecause it changesquantity * unit_price_cents. - Deleting an item removes a term from the subtotal sum entirely.
- Tax and total are recomputed from the new subtotal whenever the cart is fetched.
This is one of the main benefits of computing totals at read time: you avoid storing redundant totals that could become inconsistent.
You now have full control over cart line items:
-
The repository provides precise, cart-scoped operations (
get,update,delete) that returnnull/booleansignals so services can handle “missing item” cleanly. -
validateUpdateItemensures the only acceptable update is{ "quantity": <int>=1 }, producing clear validation messages. -
The service layer enforces business rules for updates and deletes:
- cart exists + open (404 / 409)
- item exists (404)
- product available (409)
- inventory enforced for updates (409)
-
The route validates both UUID params, implements PATCH and DELETE flows, and maps failures using the service-provided
httpStatusfor consistent, professional API behavior.
With these endpoints in place, your cart API behaves like a real system: clients can add items, adjust quantities safely, remove items, and always rely on predictable responses and correct totals.
