Design Yelp

System design Medium Uber
Reference
Start your answer

Remix this answer and make it yours, or start blank. Either way you work beside the reference, one chapter at a time, and it stays private until you publish it.

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
  1. Nearby search. The search service turns the request into an Elasticsearch query: geo_distance (or geo_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 a function_score that 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.
  2. Business detail. The business service reads the page from Redis (TTL 10 min, invalidated on change). On a miss it reads businesses, business_stats and the first page of reviews from the business's shard, writes the cache, and returns. Photos come from the CDN.
  3. 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).
  4. Projection refresh. The outbox relay publishes review.upserted to Kafka, partitioned by business_id. The aggregator applies the star delta to business_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_reviews table 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_moderation instead.
  • 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.