Most backend portfolio projects are CRUD with extra steps. I wanted to build something with actual teeth, a system where the interesting work isn’t the endpoints but the handful of places where a naive implementation would quietly break under real conditions.
So I built a scaled-down version of what sits behind an app like Uber Eats or Chowdeck: a backend that coordinates customers, vendors, and riders through the full order lifecycle, live rider matching, atomic assignment under concurrency, idempotent payment webhooks, and automatic reassignment when nobody’s available.
This article isn’t a tour of every endpoint. It’s a discussion of questions I had to answer with code and why I answered them the way I did.
“What happens when two orders want the same rider at the same instant?”
This is the core of the whole dispatch problem. A customer’s order gets accepted by a vendor, and the system needs to find the nearest available rider and lock them in. Still, if two vendors accept orders within milliseconds of each other, and the pool of available riders near both is the same three people, you will get a collision eventually.
The naive fix would be to “check if the rider is available, then assign them.” But that has a race condition baked directly into its two steps. Both requests can pass the check before either one writes.
I used two atomic Redis operations instead of application-level locking:
SET order:lock:{orderId} riderId NX EX 10: an atomic compare-and-set with a self-expiring TTL, so a crashed process doesn’t leave an order permanently locked.
SREM riders:available riderId: Redis’s set-remove command returns exactly how many elements it removed. Only the caller that gets 1 back actually claimed the rider. Everyone else, even if they got there “at the same time” from the application’s perspective, gets 0 and moves to the next candidate.
“What happens when a payment provider retries a webhook?”
Payment providers don’t trust a single delivery attempt. If they don’t get a fast 200 response back, they resend, sometimes more than once. Any webhook handler that isn’t explicitly idempotent will eventually double-process a payment.
I handle this by inserting the event’s event_id into a table with a UNIQUE constraint inside the same database transaction as the status update. A duplicate delivery hits that constraint; the transaction rolls back cleanly, and the response is still a 200 (so the provider stops retrying), so it just reports duplicate: true instead of reprocessing.
This is a different idempotency mechanism from the one on order creation, which uses a client-supplied Idempotency-Key cached in Redis. That’s deliberate: order creation is client-driven (the client decides what counts as “the same request,” useful when it can’t tell if a timed-out request actually succeeded), while the webhook is server-driven (the event’s own identity is authoritative, and the guarantee has to survive concurrent delivery, which a Redis cache with a TTL doesn’t guarantee as strongly as a database constraint plus a row lock). Using one pattern everywhere would have been simpler to write and wrong for at least one of the two cases.
“Why mock the payment gateway?”
I mocked the payment provider rather than integrating a real sandbox (Paystack, Flutterwave), and I want to be upfront about why, because it’s a deliberate trade-off, not a shortcut.
The engineering that actually matters here, which is the HMAC signature validation, the idempotent processing logic above, and the payment state machine, is identical regardless of which provider sits behind it. A real integration would have meant a provider account, API keys, and a public tunnel just to receive callbacks during development. None of that demonstrates additional backend design skill; it’s setup friction. Swapping in a real provider later touches exactly two functions, initiation and the webhook handler, because the transaction logic and schema were built provider-agnostic from the start.
“What happens when no rider is available at all?”
This is the failure mode that’s easy to leave as an afterthought. If dispatch can’t find anyone, the order can’t just sit in limbo forever.
When a vendor accepts an order, a delayed timeout job gets scheduled alongside the immediate dispatch attempt. If dispatch doesn’t find anyone, the timeout fires later and hands the order to a background worker that retries matching with a fixed backoff. If every retry is exhausted and still nobody’s available, the system writes a notification for both the customer and the vendor, telling them to cancel instead of leaving the order stuck with no visibility into why.
Keeping this as a separate background worker, rather than retrying inline on the request, matters for a boring but important reason: a slow reassignment retry should never block a customer’s “place order” request. The two run as entirely separate processes.
What I’d do differently with more time
Being specific about scope boundaries is part of showing the work, not hiding gaps:
- A real payment gateway, once there’s an actual need to move money
- Routing-API-based ETAs, since the current matching is straight-line distance, not drive time
- Push notifications instead of the current DB-polled notification table
- Rate limiting on auth and the webhook endpoint
- Structured, correlated logging across the API and both workers
Full architecture diagrams, the AWS deployment shape, and the complete decision log are in the project’s README here.



Top comments (0)