Capstone: Design a Food Delivery Platform
Before this, it helps to know: Design TinyURL, Design a Notification System, Kafka in Depth, Redis in Depth.
Read a little, play a little. No scary maths, and no rush.
How this capstone works
This chapter puts the whole track together on one realistic system: a food delivery platform, where customers order from restaurants and couriers deliver. Instead of reading a finished design, you build it in milestones. Each one opens with a "Try it first" prompt: sketch your answer before unfolding the discussion. Every milestone links to the lessons and drills it draws on, so you can go back when something is unclear.
Plan on 45 to 90 minutes with paper, the way you would prepare for a real design review.
Milestone 1: Requirements and estimates
Try it first: requirements, then the numbers
Functional: customers browse and search nearby restaurants, order and pay; restaurants accept or reject and mark food ready; the platform assigns a courier; customers track the courier live; everyone gets notifications at each step.
Non-functional: placing an order must never charge twice or lose an order; tracking updates within a few seconds; browsing stays up even if parts of the system fail; the system runs across several cities with lunch and dinner peaks.
Estimates (one large country):
- 2 million orders a day; with most of them in two 2-hour peaks, about 140 orders a second at peak.
- 100,000 couriers online at peak, sending a location every 5 seconds: 20,000 location updates a second.
- Each active order is watched by a customer: tens of thousands of open tracking connections.
- Menus and restaurant pages are read far more than they change.
The shape: order placement is low-volume but must be correct; location updates are high-volume but each one is disposable; browsing is read-heavy and cacheable. Three different problems, three different designs.
Milestone 2: Placing an order exactly once
Try it first: the customer taps "Place order" and the network drops. What happens?
The app does not know whether the order was created, so it retries. Without protection, that creates two orders and two charges.
- The app generates an idempotency key when the customer taps the button and sends it on every retry. The order service stores it with the order; a repeat returns the existing order. (Idempotency)
- The order service then needs to tell the rest of the system: payments, the restaurant, analytics. Writing the order to its database and then publishing an event is two writes; a crash between them loses the event. The fix is a transactional outbox: insert the event into an
outboxtable in the same transaction as the order, and let a relay (or change data capture, such as Debezium's outbox router) publish it to Kafka. Delivery becomes at-least-once, so consumers stay idempotent. - Practice: the dual-write drill.
Milestone 3: Payment and the restaurant, as a saga
Try it first: payment succeeds, then the restaurant rejects the order. What now?
There is no transaction spanning the payment provider, the payment service and the restaurant service. Something has to undo the payment.
Model the order as a saga: a sequence of local steps, each with a compensating action.
- Authorize payment (compensation: void or refund).
- Send to the restaurant and wait for accept or reject, with a timeout (compensation: cancel at the restaurant).
- Request a courier (compensation: release the courier).
- Capture payment when the food is picked up.
An orchestrator (an order workflow service holding the saga's state) is easier to follow and monitor for a flow this important than choreography (each service reacting to the previous one's events). Every step and every compensation must be idempotent, because the orchestrator retries after timeouts. Authorizing first and capturing at pickup also means a rejected order usually needs only a void, not a refund.
Practice: the saga drill.
Milestone 4: Couriers, location and dispatch
Try it first: where do 20,000 location updates a second go, and how do you find nearby couriers?
The data is small, replaced constantly, and only the latest value matters. That points away from a relational table and toward an in-memory store.
- Store each courier's latest position with
GEOADD, and find candidates withGEOSEARCHby radius around the restaurant. Shard by city: dispatch never needs couriers from another city. (Redis in Depth) - Geo members do not expire on their own. Track last-seen times in a second sorted set and prune couriers not heard from in, say, 30 seconds, or a courier whose app crashed keeps getting offers.
- Customers watching an order get updates pushed over WebSockets or SSE; tracking servers subscribe to updates for the orders their connected customers watch. One-way updates make SSE a good fit. (Realtime)
- Location history (for disputes and ETA models) goes to a stream or log asynchronously, not on the dispatch path.
Milestone 5: Browsing, menus and search
Try it first: what can be cached, and what must stay fresh?
Menus and photos change rarely; item availability ("sold out") and restaurant opening status change during service.
- Restaurant pages and menus: CDN for images, a cache for menu data with invalidation when a restaurant edits its menu. (Caching Basics, CDN)
- Fast-changing flags (open, busy, sold out) are small and read at checkout: keep them out of long-lived caches, or give them a short TTL and re-check at order time.
- Search for restaurants and dishes runs on a search index fed by changes from the restaurant service, filtered by location. It is eventually consistent, so checkout always re-validates price and availability. (Search)
- Practice: the stale-price drill.
Milestone 6: Notifications
Every step emits an event (accepted, picked up, arriving), and the notification service turns them into pushes, SMS and emails, with idempotency keys per event and short time-to-lives for messages like "your courier is outside". (Design a Notification System)
Milestone 7: Failure, scale and the final picture
Try it first: list what fails, and what the customer sees each time
A good answer separates "must work" (placing and paying for orders) from "should work" (search, recommendations, live map), with a planned fallback for each.
- Degrade, don't fail. If search is down, show cached nearby restaurants. If the live map lags, show the last known position and the ETA. Ordering must stay up. (Failures, Retries and Rate Limits)
- Peaks. Lunch and dinner are predictable: scale ahead of them. Rate-limit retries from apps, and queue non-urgent work (analytics, emails) so it can lag.
- Data. Orders and payments in a relational database (sharded by city or region as it grows), with replicas for reads. Events in Kafka with replication factor 3 and
acks=all. (Kafka in Depth) - Observability. Track the business numbers, not just CPU: orders per minute by city, time from order to restaurant accept, courier assignment time, payment failure rate. Alert on symptoms. (Observability)
Check your design
Before reading on, compare your sketch with this list. A strong design:
- prevents duplicate orders and charges with an idempotency key from the app;
- never loses an event between the database and the broker (outbox or CDC);
- has a compensation for every saga step, and makes every step idempotent;
- keeps courier locations in memory, sharded by city, and removes stale couriers;
- pushes tracking updates instead of having apps poll;
- knows what is cached, what is re-validated at checkout, and what must never be stale;
- names a fallback for every non-essential dependency;
- monitors business metrics per city, not just servers.
What interviewers expect at each level
- Early career: a sensible set of services, a database for orders, a way to find nearby couriers, and notifications, with the main flow explained clearly.
- Mid-level (2–5 years): idempotent ordering, asynchronous events, a geospatial index for couriers, push-based tracking, caching with clear freshness rules, and rough numbers for each path.
- Senior: the outbox and saga with compensations, authorize-then-capture, stale courier handling, sharding by city, degraded modes for each dependency, peak planning, and business-level monitoring, plus a clear story of what was traded away and why.
Remember this
- Separate the paths: correct (ordering, payments), fast and disposable (locations), cached (browsing), reactive (everything driven by events).
- Idempotency keys, the transactional outbox and sagas are how you get "exactly once" behavior without distributed transactions.
- Location data lives in memory with explicit freshness checks.
- Decide fallbacks before outages. Ordering must keep working when search does not.
Check your understanding
5 questions · first-try answers earn the most XP
My notes
Saved in this browser. Highlight a line above and save it, or write it in your own words.
Nothing saved yet. Your highlights will live here.
References
Finished reading?
Ticking it here also ticks the chapter in the sidebar, the section count and your streak — it is all one number.
Try a design drill
End of Design Problems
Take the section checkpoint
A short mixed quiz on all 5 chapters, to check you are ready to move on.
Related chapters
Spotted a mistake or want a topic covered? Report an issue