Design Yelp — search, view, and review local businesses

Design Yelp

Junior-friendly Yelp walkthrough: search/view/review requirements, Business Service vs Review Service, average rating with optimistic locking, UNIQUE one-review constraint, geo + full-text + category indexes (ES vs PostGIS), neighborhood polygons, and interview bars by level.

Understanding the problem

Yelp is an online platform for searching and reviewing local businesses, restaurants, and services. If you haven't used it, ask the interviewer clarifying questions — then lock a small FR set so you don't boil the ocean.

User searching coffee near them and getting rated cafes.
"Coffee near me" — name + place + stars on the results list.
01Search

Name · lat/long · category.

02View

Business + reviews.

03Review

1–5 ★ + optional text.

04Deep

Avg ★ · geo index · UNIQUE.

Pair with Delivery framework, Proximity search, Elasticsearch, Scaling reads, and Database indexing.

Functional requirements

Some interviewers hand you FRs; otherwise propose them. Here's the set this walkthrough (and many interviewers) steer toward:

Non-functional requirements

Core entities

Start with a short list; flesh columns in HLD. Align with the interviewer:

Business, User, and Review entities.
Business · User · Review — one review per (user, business) comes later.
  • Business — name, location, category, average rating (denormalized later).
  • User — searches and leaves reviews.
  • Review — rating + optional text, tied to user and business.

API

One endpoint family per FR. Paginate any large list.

GET /businesses?query&location&category&page -> Business[]

GET /businesses/:businessId -> Business
GET /businesses/:businessId/reviews?page= -> Review[]

POST /businesses/:businessId/reviews
{ "rating": number, "text"?: string }

HLD — search for businesses

Users land on search: any mix of name/term, location, and category.

Client to API Gateway to Business Service to Database with Business schema — search only.
Yelp High-Level Design — search(term, loc, category) through Business Service.
  1. Client GET /businesses with optional query params.
  2. Gateway routes to Business Service.
  3. Service queries the DB (we'll upgrade indexes in the deep dive).
  4. Paginated results return via the gateway.

HLD — view a business

From results, user opens a business: GET /businesses/:id (+ reviews). No new service yet — same Business Service, join (or second query) for reviews in the same DB.

Business Service handles search and view against Database with Business and Reviews tables.
Yelp High-Level Design — search + view(businessId); Reviews still in the same DB.

HLD — leave a review

Introduce a Review Service — not because microservices dogma says so, but because write patterns differ sharply from search/view.

API Gateway routes to Business Service and Review Service sharing Database with Business and Reviews schemas.
Yelp High-Level Design — Review Service for review(...); shared Database.
  1. Client POST /businesses/:id/reviews.
  2. Gateway → Review Service.
  3. Persist review; return confirmation.

Deep dive — average rating on search results

Result cards show avg ★ — computing AVG() with a JOIN on every search won't survive 100M DAU.

1) On-the-fly AVG

SELECT b.business_id, b.name, AVG(r.rating) AS average_rating
FROM businesses b
JOIN reviews r ON b.business_id = r.business_id
GROUP BY b.business_id;

Simple, but JOINs get expensive, recalculates when nothing changed, and hammers the read path.

2) Cron precompute

Review and Business services with Avg Rating Cron Job periodically updating avgRating.
Periodic Update with Cron Job — avgRating on Business, refreshed offline (can be stale).

Job fills average_rating periodically. Fast reads — but a fresh 5★ may not show for hours. Bad UX for low-review businesses.

3) Synchronous update (recommended)

Business and Review services with avgRating and numRatings on Business schema.
Synchronous Update with Optimistic Locking — avgRating + numRatings updated on each review write.

Store avgRating and numRatings on Business. On each review: (old × n + new) / (n + 1) — a few CPU cycles, fine inline.

Race: two writers read the same n, both write — last write wins and drops a vote. Fix with optimistic locking (version column, or fail if numRatings changed since read) and retry.

Deep dive — one review per user per business

App-layer check versus database UNIQUE constraint.
Enforce UNIQUE(user_id, business_id) at the database — closest to persistence.

App-layer check (weak)

Read reviews, reject if user already reviewed. Other services/backfills ignore your check; concurrent double-submit can still insert two rows.

DB constraint (right)

ALTER TABLE reviews
ADD CONSTRAINT unique_user_business UNIQUE (user_id, business_id);

Second insert fails; handle the error for the client. One winner under concurrency on the same instance. Put data constraints as close to persistence as possible.

Deep dive — efficient complex search

This is the crux. Bounding-box inequalities + LIKE '%coffee%' → full scans. Composite B-tree on (lat, lng) still fails multi-dimensional range intuition — you need spatial indexes. Full story: Proximity search.

Location geo index, name full-text, category B-tree; ES or PostGIS stacks.
Location → geo index · Name → inverted/full-text · Category → B-tree/term.

Elasticsearch path

Database CDC into Elasticsearch with geospatial, inverted, and category indexes.
Elasticsearch — CDC from primary DB; geo + inverted text + category indexes. Not the system of record.

One bool query: match name + geo_distance + term category. ES is a search replica — sync via CDC. Don't treat ES as your primary OLTP store.

{
  "query": {
    "bool": {
      "must": [
        { "match": { "name": "coffee" } },
        { "geo_distance": { "distance": "10km", "location": { "lat": 40.71, "lon": -74.0 } } },
        { "term": { "category": "coffee shop" } }
      ]
    }
  }
}

Postgres path (often enough)

Business and Review services against Postgres with PostGIS and full text.
Postgres with Extensions — PostGIS + full text; avoid ES+CDC when data fits.

PostGIS for geo, pg_trgm for text. Businesses ≈ 10M × 1KB ≈ 10GB — ES's scale advantage may not matter. If the interviewer bans ES, discuss geohash vs quadtree, Haversine second pass, and filter order: geo first, then name/category.

Deep dive — search by city / neighborhood

Users say "Pizza in NYC" or "The Mission," not only lat/long. Cities aren't circles — you need polygons (GeoJSON / coordinate rings).

Location name maps to polygon; businesses precompute location_names list.
Map name → polygon once; precompute location_names[] on each business for fast keyword filter.
  1. locations table: name, type (city/neighborhood), polygon — sourced from public datasets.
  2. On business create, compute which areas contain it → store location_names[].
  3. Query filters on that keyword field (inverted index) — avoid polygon tests on every search.
{
  "id": "123",
  "name": "Pizza Place",
  "location_names": ["bay_area", "san_francisco", "mission_district"],
  "category": "restaurant"
}

Final design

Final design with CDC to Elasticsearch from shared Database behind Business and Review services.
Final Design — shared DB + CDC → Elasticsearch for geo/text/category search; avgRating denormalized.
  • Denormalized avg_rating / num_reviews with optimistic locking on write.
  • UNIQUE(user_id, business_id) on reviews.
  • Search via ES (+ CDC) or Postgres extensions — pick based on scale and interviewer constraints.
  • Optional precomputed location_names for neighborhood queries.

Cost and performance levers

What interviewers expect by level

Architecture by level

Three progressive sketches — beginner spine, mid optimizations, pro production depth. Use the matching diagram in the interview; say the tradeoff out loud.

Beginner Yelp: Business and Review APIs on Postgres.
Beginner architecture

Pros: Simple CRUD spine covers list/view/review FRs without boiling the ocean. Cons: Computing avg rating with JOINs on every read won't survive; keyword/geo search via SQL alone gets painful fast.

Mid Yelp: denormalized avgRating and database indexes.
Mid architecture

Pros: Denormalized avgRating makes list pages cheap; indexes (geo/category/text) buy a lot before introducing ES. Cons: Rating updates need sync/cron consistency; DB full-text still struggles with relevance and mixed geo+text queries at scale.

Pro Yelp: Elasticsearch with CDC from Postgres.
Pro architecture

Pros: ES + CDC is the right tool for geo/text/category search; Postgres stays source of truth for writes. Cons: CDC lag means search can be briefly stale; dual-write without CDC is worse — staff candidates should reject it.

Sketch of search fanning into Elasticsearch or PostGIS, view reads, and review writes denormalizing avgRating for interview Q&A.
The three paths every level below is graded against — search, view, and the review write that denormalizes forward.
Interview takeaway

Mid: working spine. Senior: own search + ratings trade-offs. Staff: simplicity from first-principles scale math.

Wrapping up

Yelp teaches read-heavy product design: keep the HLD simple, denormalize what search cards need, enforce integrity in the DB, and pick geo/full-text tooling with eyes open to consistency and scale — without inventing queues and shards you don't need.

Related: Delivery framework · Proximity search · Elasticsearch · PostgreSQL · Scaling reads · Indexing · Design Gopuff · Design Dropbox.

← Lattice