"Design an endpoint to create an order" sounds like a five-line answer. Route, controller, save, done. The reason interviewers like it is that the five-line version skips everything that actually matters in production: the right status codes, where the slow work goes, and what happens when the same request arrives twice. Here's how I'd build it and explain it.
The route
POST /orders.
POST because creating a resource is neither safe nor idempotent. The path is the plural resource name, and creating against the collection is the REST convention for "make a new one." That part really is the easy bit, so don't spend your interview minutes there.
What the controller does, and what it doesn't
The controller handles the HTTP layer and nothing else. It authenticates, authorizes, permits params, hands the actual work to a service, and turns the result into a response.
class OrdersController < ApplicationController
before_action :authenticate_user!
def create
result = OrderCreator.call(user: current_user, params: order_params)
if result.success?
render json: OrderSerializer.new(result.order), status: :created
else
render json: { errors: result.errors }, status: :unprocessable_entity
end
end
private
def order_params
params.require(:order).permit(:product_id, :quantity)
end
end
Notice what's not in there. No Stripe call, no mailer, no transaction, no line-item math. Those belong in OrderCreator. If you ever want proof that a controller is too fat, count how many reasons it would have to change. This one changes only when the HTTP contract changes.
Status codes are part of the design
This is where the junior answer usually returns 200 for everything and moves on. The status code is how the client knows what happened without parsing your body, so it's worth getting right.
Return 201 Created on success, not 200, and include the new order in the body. A Location header pointing at the new resource is a nice touch.
Return 422 Unprocessable Entity when validation fails, with a structured error body so the front end can show messages next to the right fields.
Return 401 Unauthorized when there's no valid auth, and 403 Forbidden when the user is logged in but not allowed to do this. Those two get conflated constantly, and keeping them straight signals you've actually built APIs.
Return 400 Bad Request when the input is malformed, like the whole order key is missing.
Where the real work goes
The service creates the order and its line items inside a transaction, so they commit together or not at all. The slow and external stuff, charging the card, sending the confirmation email, syncing to a CRM, goes to background jobs. A user shouldn't wait on an SMTP server, and a third party having a bad day shouldn't take your order endpoint down with it. I covered the transaction-versus-external-call reasoning in more depth in my MVC post, but the short version is that a charge can't be rolled back, so it lives outside the transaction.
The part almost everyone forgets: duplicate requests
A user double-taps the buy button. Or the network hiccups and the client retries. Either way the same POST hits you twice, and a naive endpoint cheerfully creates two identical orders and charges the card twice.
The fix is an idempotency key. The client generates a unique key per logical request and sends it in a header. You store that key with a unique constraint, and if a second request shows up with the same key, you return the original result instead of creating anything new.
def create
existing = IdempotencyKey.find_by(key: request.headers["Idempotency-Key"])
return render(json: existing.response_body, status: existing.status) if existing
result = OrderCreator.call(user: current_user, params: order_params)
# ...store the key with the result, then render
end
This is exactly how Stripe's own API handles retries, which is a good thing to mention because it shows you've read how a serious payments API solves the same problem.
Rounding it out
A few things I'd name to show I'm thinking past one endpoint. A consistent JSON error shape across the whole API so clients parse errors one way. Pagination and filtering on the list endpoint, GET /orders, because it will get slow once a customer has thousands. And versioning under /api/v1/ so a future breaking change doesn't break every client at once.
The short version
POST /orders with a thin controller that authenticates, permits params, and calls a service. 201 on success, 422 on validation errors, and the right 401 versus 403 for auth. Real work in a transaction, slow work in background jobs, and an idempotency key so a double-tap or a retry doesn't create a second order. The route was never the hard part. Everything after it is.
Top comments (0)