The system design question
Design a review system for DoorDash. Customers can review a specific item on a restaurant's menu, others can upvote or downvote reviews, and reviewers get a reward based on how good their review is.
Agree on the scope (5 minutes)
Say up front what you're covering.
| We'll design | We'll skip |
|---|---|
| Writing a review of one menu item | Photo and video uploads in detail |
| Upvoting and downvoting reviews | Searching review text |
| Showing the best reviews on a menu item fast | Spam and abuse models in detail |
| Rewarding reviewers for good reviews, exactly once | How rewards get spent |
Then agree on what "good" means:
- Fast to read: reviews show up on the menu page with no noticeable delay, even for the busiest restaurants.
- One vote per person: a customer can vote once per review, and change their mind, but never count twice.
- Counts can lag a little: a vote count that's a few seconds behind is fine. A missing vote isn't.
- Fair rewards: each good review gets its reward once, and people can't game it with fake votes.
Rough numbers (5 minutes)
These are assumptions, but reasonable ones: 20 million orders a day, about 5% of them lead to a review, each review gets about 10 votes, and the menu pages that show reviews get about 300 million views a day. Peaks are about 5 times the average.
- ~1M
- new reviews a day
- ~600
- votes per second at a peak
- ~20K
- review reads per second at a peak
- ~400 GB
- of reviews a year
| Question | Rough math | What it means |
|---|---|---|
| How many reviews? | 20 million orders × 5% | About 1 million a day, roughly 12 a second. Writing reviews is easy |
| How many votes? | 1 million reviews × 10 votes ÷ 86,400 seconds × 5 | About 600 votes a second at a peak overall, but they bunch up on a few popular reviews |
| How many reads? | 300 million views ÷ 86,400 × 5 | About 20K a second at a peak. Reads are where the load is, so they come from a cache |
| How much storage? | 1 million × about 1 KB × 365 | About 400 GB a year of reviews. Votes are smaller but there are 10 times more of them |
The overall design (10 minutes)
- After an order's delivered, the customer can review any item from it. The review service checks the order really had that item, then saves the review.
- A vote saves one row per voter per review. Saving it also puts a vote event on a queue.
- A counting worker reads vote events in batches and updates each review's upvote and downvote counts.
- A ranking worker keeps a short list of the best reviews for each menu item, and puts it in a cache. Menu pages read from that cache.
- Once a review has been up for a while, a reward job scores it and adds a reward to the reviewer's balance, using a key so it can only happen once.
What the apps need to do
| Action | Who does it | What's sent |
|---|---|---|
| Write a review | Customer app | Order ID, menu item ID, rating, text |
| Vote on a review | Customer app | Review ID, up or down, or clear the vote |
| Get reviews for an item | Menu page | Restaurant ID, menu item ID, sort order, page cursor |
| Get my rewards | Customer app | Customer ID |
What we store and where
| Data | Where it goes | Why |
|---|---|---|
| Reviews | Database, split by restaurant ID | A menu page needs every review for one restaurant, so keep them together |
| Votes | Database, one row per voter and review | A unique rule on voter and review stops double votes |
| Vote counts | Columns on the review row, updated in batches | Reading a count shouldn't mean counting votes |
| Top reviews per item | In-memory cache | Read about 20K times a second and changes slowly |
| Reward ledger | Relational database | Money-like data needs transactions and a unique rule per reward |
Step 1: Tables and votes (10 minutes)
Start with the tables. A review has an ID, the restaurant, the menu item, the order it came from, the author, a rating, text, a status, and the upvote and downvote counts. Index it by restaurant and menu item, so the menu page finds an item's reviews in one lookup.
A vote row is the review ID, the voter, and up or down. Make the review ID and voter together unique. A second tap just updates the same row, and switching from up to down changes it. That's idempotency without any extra work.
Split the data by restaurant ID. One restaurant's reviews and votes all live together, so the menu page never has to ask several machines. It's a sharding choice, and the size fits a NoSQL or relational store either way. We'd pick relational for the unique rules.
Hot reviews
Say a popular restaurant gets featured and one review gets thousands of votes a minute. If every vote updates the count on the same row, those updates line up behind each other.
So don't update the count on every vote. Save the vote row, put an event on the queue, and let a worker add up a few seconds of events and apply them in one update. A thousand votes become one write. If one review is still too hot, split its count into a few counters and add them up when reading.
Step 2: Showing the best reviews fast (10 minutes)
Sorting by net votes (upvotes minus downvotes) favors old reviews that have had time to collect votes. Sorting by the share of upvotes favors a review with one upvote and nothing else. A common fix is to use a confidence-adjusted score: treat a review with few votes as uncertain, and only rank it high once enough votes agree.
Don't work that out on every page view. When a review's counts change, the ranking worker recomputes its score and updates that item's top 20 or so reviews. That list goes in a cache, keyed by restaurant and menu item.
Most menu pages only show the top few reviews and an average rating, so nearly every read is a cache hit. Scrolling further pages through the database is fine, because few people do it.
Updating the cached list on every vote would churn it. Updating it every few seconds from the batched counts is plenty, since nobody notices if a review moves up a spot a little late.
Step 3: Rewarding reviews exactly once (10 minutes)
Decide when a review is judged. Votes keep coming in for days, so wait a settling window, say 7 days after it's posted. Then a daily job scores each review that just hit 7 days: its votes, whether it's from a real order, and maybe its length or a photo.
The job writes rewards to a ledger. Each reward has a key, like the review ID plus 'week one reward', with a unique rule. If the job crashes halfway and runs again, the rows it already wrote are rejected, so nobody gets paid twice. That's idempotency again, this time for money.
The reviewer's balance is the sum of their ledger rows, cached for reading. Never just add to a balance column without the ledger row behind it, or a retry can double it.
Stopping people gaming it
Rewards make fake votes worth something. Only count votes from customers with a real order history, give less weight to brand-new accounts, and flag reviewers whose votes mostly come from the same small group of accounts. Let the settling window double as a review period, so flagged rewards can be held before they're paid.
Choices you should be able to defend
| We chose | Instead of | Because | We'd reconsider if |
|---|---|---|---|
| Batching vote counts | Updating the count on every vote | A hot review becomes one write every few seconds instead of thousands | Customers needed counts to tick up instantly |
| Splitting data by restaurant | Splitting by review ID | A menu page reads from one place | A single restaurant got too big for one machine |
| A cached top list per item | Sorting on every page view | Reads are 20K a second and the list barely changes | Each customer saw a different, personalized order |
| Rewards after a 7-day window | Rewarding as votes come in | Scores have settled, and there's time to catch fake votes | Reviewers complained rewards felt too slow |
What could go wrong (5 minutes)
| What happens | What we do about it |
|---|---|
| The counting worker falls behind | Counts lag a bit. The votes are safe in the database, so nothing's lost. Add workers |
| The cache goes down | Menu pages fall back to the database for the top reviews, with a short limit on how many reviews they load |
| The reward job crashes halfway | Run it again. The unique key skips rewards that were already written |
| A vote ring targets one reviewer | Discount votes from new or linked accounts, and hold the reward for a human check |
| A restaurant gets a flood of reviews | Batching keeps writes small, and the rate limit per customer stops one person flooding it |
Follow-up questions (5 minutes)
How would you rank reviews differently for each customer?
Keep the cached top list as a starting set, then reorder it on read using a few customer signals, like dietary preferences. Don't build a separate list per customer.
What if a reviewer edits their review?
Keep the votes, but save the edit history. If the edit changes the review a lot, reset the reward clock so people can't post something good, collect a reward, then swap it.
How would you show an item's average rating?
Keep a running sum and count of ratings per menu item, updated by the same batch worker. The average is one division at read time.
How the 60 minutes add up
| Minutes | What you're doing |
|---|---|
| 0–5 | Agree on what you're building |
| 5–10 | Rough numbers |
| 10–20 | Draw the overall design |
| 20–30 | Tables and votes |
| 30–40 | Showing the best reviews fast |
| 40–50 | Rewarding reviews exactly once |
| 50–55 | What could go wrong |
| 55–60 | Follow-up questions |
Key concepts used here
- Relational vs non-relational databasesPick a database based on how you'll actually use it.
- Sharding and consistent hashingSplit data across machines and keep it balanced.
- Caching strategiesMake reads faster, and figure out what to do when data changes.
- Idempotency and retriesMake sure a retried request doesn't happen twice.
- Message queues and event streamsKeep services from depending on each other, and handle traffic spikes.
Further reading
We wrote this sample answer ourselves.
Practice Loop is independent and isn't affiliated with or endorsed by DoorDash.