Managing Order Transitions

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 ....
What “Order Transitions” Mean Here

Orders have a status field with a small set of allowed values:

  • pending (created at checkout)
  • paid
  • shipped
  • cancelled

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 shipped or already cancelled.

Those rules belong in the service layer, so every caller follows the same policy.

Repository: Persisting a Status Change With setOrderStatus

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 a 404)

  • 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] ? ... : null shape is a deliberate contract: repositories don’t decide HTTP status codes; they simply report “updated” vs “not found.”
  • Mapping via mapOrderRow keeps the “no raw DB rows beyond the repo” rule intact. Services/routes never deal with OrderRow directly.
Service: payOrderService and cancelOrderService Business Rules

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.

payOrderService: pending → paid only

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 CONFLICT is important: the request is valid (UUID exists), but the system state makes it invalid (e.g., it’s already paid).
  • The second NOT_FOUND check after looks redundant, but it’s a good correctness habit: it handles edge cases where the record disappears between the read and the update.
cancelOrderService: block shipped/cancelled

Requirements:

  • If missing → 404
  • Reject status === "shipped" and status === "cancelled" with 409
  • Otherwise call setOrderStatus(id, "cancelled")
  • Keep the error envelope consistent

Implementation:

  • This rule set intentionally allows canceling in other states (including pending and even paid in 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 409 for invalid transitions rather than throwing.
Routes: POST-only Action Endpoints With UUID Validation

Finally, learners implement the two Remix action routes:

  • app/routes/api.orders.$id.pay.ts
  • app/routes/api.orders.$id.cancel.ts

Both follow the same requirements:

  • Validate id with isUUID(id)

    • invalid → 400 VALIDATION_ERROR
  • Enforce POST-only

    • non-POST → 405 Method Not Allowed
  • Call the matching service

  • On failure: error(code, message, details, httpStatus) using the service-provided httpStatus

  • On success: success(updatedOrder) with 200

Pay route: POST /api/orders/:id/pay
  • isUUID validation 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 404 vs 409, and the route just forwards order.error.httpStatus.
  • Success uses success(order.value) with default 200 OK, which fits “state transition” semantics (we updated an existing resource, we didn’t create a new one).
Cancel route: POST /api/orders/:id/cancel
  • 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 /ship later would follow the exact same pattern).
Why This Layering Matters

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.

Recap

By the end of this lesson, you have a complete minimal order lifecycle:

  • Checkout creates a pending order.

  • Retrieval lets you list orders and fetch order details.

  • Transition endpoints let clients:

    • pay: pending → paid
    • cancel: anything except shipped/cancelled → cancelled

And every endpoint consistently returns either:

  • success(data) on success
  • error(code, message, details?, httpStatus) on failure

That’s the finishing touch that makes this Orders API feel like a real production-style backend.

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