System Design Notes All designs

Location & Matching

Yelp — Local Search & Reviews

Search businesses by name, category, and location; view a business page with paginated reviews; leave one 1–5★ review. The hard parts: low-latency geo + keyword search, keeping a business's average rating fresh and instantly readable, and enforcing one review per user.

Requirements

Functional

Non-functional

Scale & back-of-the-envelope

API design

GET  /businesses?category&location&name&page&limit   -> Partial<Business>[]
GET  /businesses/:id                                  -> Business
GET  /businesses/:id/reviews?page&limit              -> Review[]    # split + paginated
POST /businesses/:id/reviews   { rating, text }       -> Review | 409  # auth; idempotent on (user,business)

Search returns a Partial<Business> card (id, name, category, avgRating, distance, thumbnail); userId comes from the auth token, never the body. Prefer cursor pagination for reviews ordered by createdAt.

High-level design

Client → API Gateway → Business Service (search + detail, read-optimized) and Review Service (post/list reviews) → Postgres with PostGIS (geo) + GIN full-text; photos in S3.

flowchart LR
    Client["Client web / mobile"] --> GW["API Gateway"]
    GW --> BS["Business Service"]
    GW --> RS["Review Service"]
    BS --> Cache[("Redis Cache")]
    BS --> DB[("Postgres: PostGIS GiST + full-text")]
    RS --> DB
    DB --> Obj[("Object Store S3: photos")]
      

Why one Postgres? 20 GB metadata + 1 TB reviews fits a primary + read-replicas, and PostGIS gives geo and full-text and ACID for the uniqueness constraint in one place — the simplest design meeting < 500 ms and HA.

Deep dive · fresh, accurate average rating

Computing AVG(rating) over thousands of reviews on every search result is far too expensive at 20–30k QPS. Denormalize avgRating, numRatings, ratingSum on the Business row and update incrementally in the same transaction as the review insert — exact and up-to-the-second; search reads a precomputed column.

flowchart LR
    NewReview["New review write"] --> RS["Review Service"]
    RS --> Upd["Atomic update ratingSum + numRatings"]
    Upd --> DB[("Business row: avgRating denormalized")]
    Search["Search / view read"] --> Cache[("Redis: business + avgRating")]
    Cache -->|cache miss| DB
    DB -->|backfill| Cache
      

Use ratingSum / numRatings (not repeated averaging) to avoid float drift; recompute nightly as a self-healing backstop. The UPDATE ... SET ratingSum = ratingSum + :rating is an atomic read-modify-write (row lock), so concurrent reviews can't lose an increment. Cache hot businesses in Redis and update-through on a new review.

Deep dive · geospatial + complex search

A "complex query" combines keyword (name/description), category filter, and geo radius/nearest, ranked, under 500 ms. Three geo-index strategies:

flowchart TD
    Q["Search: lat/long + category + name"] --> R["Search Service"]
    R --> GH["Option A: Geohash prefix"]
    R --> QT["Option B: Quadtree"]
    R --> PG["Option C: PostGIS GiST / R-tree"]
    GH --> M["Filter by radius, rank by distance and rating"]
    QT --> M
    PG --> M
    M --> Res["Paginated results"]
      
Geohash Quadtree PostGIS (chosen)
Density Fixed grid Adaptive Adaptive (tree)
Edge cases Boundary (query 8 neighbors) Rebalancing Handled natively
Ops Simple, shardable Custom code Just an index in existing Postgres

Recommendation: start with PostGIS (correct radius/kNN + lives with the review uniqueness constraint and full-text). Scale beyond one DB by moving search to Elasticsearch (geo_point + BM25) fed by CDC while Postgres stays the system of record. Rank by a blend of distance + rating + confidence (Bayesian/Wilson) + keyword relevance.

Deep dive · one review per user

The primary defense is a DB unique constraint UNIQUE(userId, businessId) — this makes correctness independent of races and double-clicks (an app-level check-then-insert has a TOCTOU race).

sequenceDiagram
    actor U as User
    participant RS as Review Service
    participant DB as Postgres
    U->>RS: POST /businesses/123/reviews {rating, text}
    RS->>DB: INSERT review UNIQUE(userId, businessId)
    alt duplicate review
        DB-->>RS: unique constraint violation
        RS-->>U: 409 Conflict
    else accepted
        DB-->>RS: ok
        RS->>DB: UPDATE ratingSum, numRatings, avgRating (same tx)
        RS-->>U: 201 Created
    end
      

The avg-rating update must run in the same transaction so a business never increments numRatings without a committed review. Sharding caveat: keep (userId, businessId) co-located — sharding reviews by businessId preserves per-business uniqueness; by userId would break it.

Data model

Business  id PK; name, category, description, address, lat/long (PostGIS geog),
          s3link, avgRating, numRatings, ratingSum    # denormalized
Review    id PK; userId FK, businessId FK, rating 1..5, text?, createdAt
          UNIQUE (userId, businessId)                  # one review per user
Indexes   GiST(geog), GIN(to_tsvector(name||' '||description)), btree(category)

Read vs write path

Reads (~20–40k peak, < 500 ms) scale via Redis + read replicas + optional Elasticsearch + CDN for photos. Writes (~50–100 peak) hit the primary only, doing the atomic incremental aggregate + unique constraint. Consistency: eventual for aggregates, strong for the review row itself.