Source: https://www.hack2hire.com/question-bank/questions/system-design/69cf39cc4f38b199f9e4942f
Company: Uber
Difficulty: Medium
Problem statement
Find businesses near a location by category and rating, read and write reviews and photos, for a read-heavy audience.
Requirements
We are building a local business discovery service: people search for businesses near a point by category, rating and open-now, open a business page, and read or write reviews with photos. Reads outnumber writes by roughly 100 to 1.
Questions worth asking first: is search by map viewport, by radius, or both? Is ranking just distance and rating, or personalized? Can owners edit listings, and who settles disputes? How fresh must a new review be on the business page and in search? The answers below assume both viewport and radius, non-personalized ranking, owner edits through moderation, and "your own review shows at once, everyone else sees it within a minute".
Functional requirements
- Search businesses near a location (lat/lng plus radius, or a map bounding box), filtered by category, minimum rating, price level and open-now, sorted by relevance, distance or rating.
- Business page: name, address, hours, categories, rating summary, photos, and paginated reviews (newest or most useful first).
- Write, edit and delete your own review (1 to 5 stars plus text), one per user per business; attach up to 10 photos.
- Mark reviews useful; report abusive reviews.
- Owners and moderators update listings; duplicate places get merged.
Non-functional requirements
- Search p99 under 200 ms; business page p99 under 150 ms.
- Availability 99.99% for reads; writes may degrade (queue) during incidents but must not be lost.
- A posted review is durable once acknowledged. Aggregates (rating, count) and search index are eventually consistent within about 60 s.
- Read-your-writes: the author sees their own review immediately.
- Global, with traffic concentrated by city and by time of day (lunch and dinner peaks about 3x average).
Back-of-the-envelope estimation
- 200M businesses worldwide; 100M DAU; 5 searches and 10 business-page views per user per day.
- Search: 500M/day ≈ 6K QPS average, about 20K QPS at peak. Page views: 1B/day ≈ 12K QPS, about 40K at peak.
- Reviews: 2M/day ≈ 25/s average, about 100/s at peak. Photos: 3M/day.
- Business record ≈ 2 KB → 200M × 2 KB = 400 GB. Reviews: 1B total × 1 KB ≈ 1 TB, growing 2 GB/day.
- Photos: 3M/day × 2 MB original plus thumbnails ≈ 7 TB/day into object storage; served from a CDN.
- Search index: 200M docs × ~1 KB of indexed fields ≈ 200 GB plus replicas; fits a modest Elasticsearch cluster.
API design
All reads are cacheable GETs; writes are idempotent by client key.
GET /v1/search?lat=37.77&lng=-122.42&radius_m=2000&category=ramen
&min_rating=4&open_now=true&sort=relevance&cursor=...&limit=20
→ 200 { results: [{business_id, name, lat, lng, distance_m, rating, review_count,
price, categories, thumb_url, is_open}], next_cursor }
GET /v1/search?bbox=37.70,-122.52,37.83,-122.35&category=coffee (map viewport)
GET /v1/businesses/{id}
→ 200 {id, name, address, lat, lng, hours, categories, price, rating, review_count,
rating_histogram: {1:..,5:..}, photos: [...first 6...]}
→ 301 Location: /v1/businesses/{canonical_id} (merged place)
GET /v1/businesses/{id}/reviews?sort=newest|useful&cursor=...
→ 200 { reviews: [{id, user, stars, text, photo_urls, useful_count, created_at}], next_cursor }
PUT /v1/businesses/{id}/reviews/me Idempotency-Key: <uuid>
{ stars: 4, text: "...", photo_ids: ["p_1"] }
→ 200 { review_id, version, status: "published" | "pending_moderation" }
DELETE /v1/businesses/{id}/reviews/me
POST /v1/photos/upload-url { business_id, content_type } → { photo_id, signed_put_url }
POST /v1/reviews/{id}/useful
POST /v1/reviews/{id}/report { reason }
PUT .../reviews/me makes "one review per user per business" part of the contract instead of a check the client must remember. Cursors are opaque (encoded sort key + id), never offsets.
Data model
The relational store (PostgreSQL or MySQL, sharded) is the authority. Everything search and page reads touch is a projection that can be rebuilt from it.
| Table | Key columns | Partition / index |
|---|---|---|
businesses |
id, canonical_id, name, lat, lng, geohash6, address, categories json, hours json, price, status, updated_at | shard by id; index (geohash6) for repair jobs only |
reviews |
id, business_id, user_id, stars, text, status (published/hidden/deleted), version, created_at | shard by business_id; unique (business_id, user_id); index (business_id, created_at desc) |
review_history |
review_id, version, stars, text, edited_at | append-only edits, keyed with the review |
user_reviews |
user_id, review_id, business_id | shard by user_id; the "my reviews" path |
photos |
id, business_id, review_id, user_id, s3_key, status | shard by business_id |
business_stats |
business_id, review_count, star_sum, histogram, useful_score | shard by business_id; updated by the aggregator |
outbox |
id, aggregate_id, event_type, payload, created_at | same shard as the write |
Invariants: one live review per (business, user), enforced by the unique key. rating = star_sum / review_count counts only published reviews. A merged business keeps its row with canonical_id pointing at the survivor; ids are never reused.
Derived stores: Elasticsearch businesses index (geo_point, categories, rating, review_count, price, hours as weekly minute ranges, popularity); Redis cache of business pages and first page of reviews; S3 + CDN for photos.
High-level design
flowchart LR
C[Client] --> CDN[CDN]
CDN --> GW[API gateway]
GW --> SS[Search service]
GW --> BS[Business service]
GW --> RS[Review service]
SS --> ES[(Elasticsearch geo index)]
BS --> RC[(Redis page cache)]
BS --> DB[(Sharded SQL)]
RS --> DB
RS --> OB[Outbox relay]
OB --> K[Kafka]
K --> AG[Rating aggregator]
K --> IX[Indexer]
K --> MOD[Moderation]
AG --> DB
IX --> ES
K --> INV[Cache invalidator]
INV --> RC
C -->|signed PUT| S3[(Object store)]
S3 --> CDN
-
Nearby search. The search service turns the request into an Elasticsearch query:
geo_distance(orgeo_bounding_box) filter, term filters on category and price, range on rating, a script filter for open-now in the business's time zone, then afunction_scorethat blends text match, distance decay and a Bayesian rating. It returns ids plus the small denormalized fields the result card needs, so no database call is made per result. Common city + category queries are cached for 60 s keyed by geohash5 cell. -
Business detail. The business service reads the page from Redis (TTL 10 min, invalidated on change). On a miss it reads
businesses,business_statsand the first page of reviews from the business's shard, writes the cache, and returns. Photos come from the CDN. -
Review submission. The client uploads photos directly to S3 with signed URLs. The review service then, in one transaction on the business's shard, upserts the review, appends
review_history, and writes an outbox row. It returns 200 and the client shows the review from its own response (read-your-writes). -
Projection refresh. The outbox relay publishes
review.upsertedto Kafka, partitioned by business_id. The aggregator applies the star delta tobusiness_stats; the cache invalidator drops the page key; the indexer pushes the new rating and count to Elasticsearch in 5 s micro-batches. Moderation scores the text in parallel and can flip the review to hidden, which flows through the same path.
Deep dives
Geo indexing for nearby search.
| Option | Query | Updates | Fit |
|---|---|---|---|
| Geohash prefix in SQL | Query cell + 8 neighbors | Trivial | Fine for radius only; no text or ranking |
| Quadtree in memory | Adaptive cells, dense cities split deeper | Rebuild or locked updates | Good, but you build sharding and replication yourself |
| Google S2 / H3 cells | Cover the circle with cells | Trivial | Precise covering; still need a ranking layer |
| Elasticsearch geo_point (BKD tree) | geo_distance + filters + scoring in one query | Near real time (1 s refresh) | Pick this: geo, text, filters and ranking together |
Businesses change rarely (a few hundred thousand updates a day), so index freshness is easy. Shard the index by region (geohash prefix of the business) so a query hits one or two shards instead of all; a query near a shard border fans out to both. Expand the radius in steps (1 km, 3 km, 10 km) until at least 20 results, so rural queries aren't empty and Manhattan queries don't scan 50K documents.
Ranking by rating without gaming it. Raw average ranks a single 5-star review above 400 reviews at 4.6. Use a Bayesian mean: score = (C × m + star_sum) / (C + review_count) with m the category mean (about 3.8) and C about 10. The final score multiplies text relevance, a distance decay (Gaussian, scale tuned per density: 500 m in cities, 5 km in suburbs) and the Bayesian rating. Precompute the rating part in the indexer so the query only combines numbers.
Review writes and aggregate correctness. Rating aggregates must not double-count on retries or drift on edits. Each event carries (review_id, version, old_stars, new_stars, old_status, new_status). The aggregator keeps last_applied_version per review in the stats shard and applies a delta only if the version is newer, inside one transaction. Kafka partitioning by business_id keeps one business's events in order, so the aggregator for a hot business is single-writer. A nightly job recomputes star_sum and review_count from reviews and alerts if they differ; drift should be zero.
Edits and deletions after the fact: a review row is mutable but every change appends to review_history, so moderators and legal requests can see what was said and when. Deletion sets status = deleted and emits the same event, so aggregates and index correct themselves.
Read path at peak and hot cities. 40K page reads/s is mostly a small set: the top 1% of businesses get about 50% of views. A Redis hit rate above 95% keeps database reads under 2K/s. For lunch peaks, a warmer job runs at 11:00 local time per city and preloads the top 5K businesses per city plus their first review page. Hot keys (a viral restaurant) get a short in-process cache (5 s) in each business service pod to keep one Redis node from saturating. Cache stampedes are prevented with request coalescing: one pod refills a key while others wait up to 50 ms or serve the stale copy.
Place merges without breaking links. Duplicates appear when users add a place that already exists. A merge job picks a survivor, moves reviews (resolving unique-key clashes by keeping the newer review per user), sets canonical_id on the loser and status = merged, and emits events that recompute stats and delete the loser from the index. GET /businesses/{loser} returns 301 to the survivor, so old links, bookmarks and SEO pages keep working. Merges are reversible because the loser row and the moved review ids are logged.
Trade-offs
- Elasticsearch as the search store gives one query for geo, text and ranking, but it is a second copy that can lag; we accept about 5 to 60 s staleness on ratings in search results.
- Sharding reviews by business_id makes the business page one-shard and keeps aggregation ordered, but "all reviews by user X" needs the separate
user_reviewstable and a dual write in the same outbox flow. - The outbox pattern costs an extra table and relay process, but removes the dual-write gap between the database and Kafka.
- Async moderation lets reviews publish in under a second, at the cost of showing an abusive review for a few seconds; high-risk accounts (new, burst of reviews, flagged IP) go to
pending_moderationinstead. - Bayesian rating is fairer than a raw average but is a number users can't verify by hand; we still display the raw average and count, and use the smoothed score only for ordering.
- Region-sharded indexes make local queries cheap but make "search all of the US" expensive; we cap query areas and use a global popularity index for broad queries.
Interviewer expectations
Mid-level (L4)
- Clear requirements and estimates; recognizes the system is read-heavy.
- A working nearby query using geohash or a geo index, and a reviews table with pagination.
- Caching for business pages and CDN for photos.
Senior (L5)
- Separates the authoritative store from derived projections (search index, stats, cache) and explains how a review flows to each, with the outbox or CDC.
- Correct, idempotent rating aggregation under retries and edits; one-review-per-user enforced by a key.
- Ranking that handles low review counts; radius expansion for sparse areas; hot-key and stampede handling.
Staff (L6+)
- Operational story: index rebuilds from the source of truth with a dual-write cutover, freshness lag metrics per projection, nightly reconciliation of aggregates.
- Place merges with stable links, review history and moderation as first-class flows.
- Abuse defenses on writes (rate limits per user and IP, new-account holds, review bursts on one business) and how they trade publish latency against trust.