Huge congrats for making it this far. 🎉 You’ve built a full “mini e-commerce backend” flow: carts → items → checkout → orders → retrieval. What’s left now is the part that makes orders feel “alive”: state transitions.
In this final lesson, you’ll implement the two most important “actions” on an order in this codebase:
- Pay an order:
POST /api/orders/:id/pay - Cancel an order:
POST /api/orders/:id/cancel
You’ll see the same layering pattern you’ve used throughout the course:
- Routes validate input and enforce HTTP method rules.
- Services enforce business rules like “only pending orders can be paid.”
- Repositories persist state changes with a safe
UPDATE ... RETURNING ....
Orders have a status field with a small set of allowed values:
pending(created at checkout)paidshippedcancelled
This project intentionally uses action routes (/pay, /cancel) instead of a generic “update status” endpoint. That’s because transitions are not “free-form edits”—they’re business actions with rules:
- Paying is only valid from
pending. - Canceling is blocked if the order is already
shippedor alreadycancelled.
Those rules belong in the service layer, so every caller follows the same policy.
Learners will implement setOrderStatus(id, status) in src/lib/repositories/ordersRepo.ts.
The requirements are very specific:
-
Run an update:
UPDATE orders SET status = $1, updated_at = now() WHERE id = $2 RETURNING *
-
If no row is returned, return
null(so the service can turn it into a404) -
If a row is returned, map it with
mapOrderRow(...)so the rest of the app sees domain data
Here’s what that looks like in this repo style:
RETURNING *is doing a lot of work for you: you don’t need to perform a second query to re-fetch the updated order, which keeps transitions fast and consistent.- The
rows[0] ? ... : nullshape is a deliberate contract: repositories don’t decide HTTP status codes; they simply report “updated” vs “not found.” - Mapping via
mapOrderRowkeeps the “no raw DB rows beyond the repo” rule intact. Services/routes never deal withOrderRowdirectly.
This is where the “state machine” logic lives. Learners will implement both transition services in src/lib/services/ordersService.ts, and these rules are what make the API safe and predictable.
Requirements:
- If the order doesn’t exist → 404
- Only allow paying when
order.status === "pending" - Otherwise return 409 with a message like
Cannot pay order in status <status> - If allowed, call
setOrderStatus(id, "paid")and return the updated order - Keep the failure shape consistent (
fail({ code, message, httpStatus }))
A clean implementation looks like this:
- The service reads first (
getOrderById) because the transition depends on the current status. - Returning
409 CONFLICTis important: the request is valid (UUID exists), but the system state makes it invalid (e.g., it’s alreadypaid). - The second
NOT_FOUNDcheck after looks redundant, but it’s a good correctness habit: it handles edge cases where the record disappears between the read and the update.
Requirements:
- If missing → 404
- Reject
status === "shipped"andstatus === "cancelled"with 409 - Otherwise call
setOrderStatus(id, "cancelled") - Keep the error envelope consistent
Implementation:
- This rule set intentionally allows canceling in other states (including
pendingand evenpaidin the current simplified rules). Whether that’s “realistic” isn’t the point here—the point is the service is the single source of truth for policy. - Like pay, cancel returns
409for invalid transitions rather than throwing.
Finally, learners implement the two Remix action routes:
app/routes/api.orders.$id.pay.tsapp/routes/api.orders.$id.cancel.ts
Both follow the same requirements:
-
Validate
idwithisUUID(id)- invalid →
400 VALIDATION_ERROR
- invalid →
-
Enforce POST-only
- non-POST →
405 Method Not Allowed
- non-POST →
-
Call the matching service
-
On failure:
error(code, message, details, httpStatus)using the service-providedhttpStatus -
On success:
success(updatedOrder)with 200
isUUIDvalidation happens before any service calls, so garbage IDs never reach the database.- The route does not invent any HTTP mapping rules. The service is responsible for selecting
404vs409, and the route just forwardsorder.error.httpStatus. - Success uses
success(order.value)with default200 OK, which fits “state transition” semantics (we updated an existing resource, we didn’t create a new one).
- This mirrors the pay route on purpose. Same flow, same validation, same error mapping—only the service function differs.
- That consistency is what keeps the API easy to maintain and easy to extend (e.g., adding
/shiplater would follow the exact same pattern).
This final lesson ties together the philosophy you’ve been using the whole time:
- Routes are strict about request shape (UUIDs, query params, allowed methods).
- Services are strict about business state (what transitions are allowed).
- Repositories are strict about data persistence (one SQL update, mapped back into domain objects).
When each layer owns its responsibility, you avoid the most common backend failure mode: “random logic everywhere,” where routes hardcode statuses and repositories start enforcing business rules.
By the end of this lesson, you have a complete minimal order lifecycle:
-
Checkout creates a
pendingorder. -
Retrieval lets you list orders and fetch order details.
-
Transition endpoints let clients:
- pay:
pending → paid - cancel: anything except
shipped/cancelled → cancelled
- pay:
And every endpoint consistently returns either:
success(data)on successerror(code, message, details?, httpStatus)on failure
That’s the finishing touch that makes this Orders API feel like a real production-style backend.
