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.
Name · lat/long · category.
Business + reviews.
1–5 ★ + optional text.
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 — 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
GET /businesseswith optional query params. - Gateway routes to Business Service.
- Service queries the DB (we'll upgrade indexes in the deep dive).
- 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.
HLD — leave a review
Introduce a Review Service — not because microservices dogma says so, but because write patterns differ sharply from search/view.
- Client
POST /businesses/:id/reviews. - Gateway → Review Service.
- 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
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)
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 (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.
Elasticsearch path
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)
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).
locationstable: name, type (city/neighborhood), polygon — sourced from public datasets.- On business create, compute which areas contain it → store
location_names[]. - 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
- Denormalized
avg_rating/num_reviewswith 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_namesfor 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.
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.
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.
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.
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.