Design a personalized deals and promotions service
Weekly deals per shopper, nearby-store alerts, and a sale that goes live for everyone at once.
- Ranking pipelines — Scoring thousands of candidates in stages, cheap first, expensive last.
- Push notifications — Reaching a device that is asleep, through APNs and FCM.
- Geospatial indexing — Geohashes, quadtrees and S2 cells: turning 'nearby' into a range query.
- Caching strategies — Read-through, write-through, write-back, TTLs and invalidation.
- Batch versus streaming (MapReduce and friends) — Exact answers over everything at night, or approximate answers now.
01The design, in the six-step framework
1. Requirements
Functional
- Merchants create promotions: a discount, the SKUs or stores it applies to, a start and end time.
- Each shopper gets a personalised weekly deals page ranked for them.
- Nearby-store alerts: when a shopper is near a store with a relevant deal, send one notification, not many.
- A scheduled sale goes live for everyone at the same second and ends on time.
- Out of scope: coupon redemption at checkout, merchant billing, fraud on promotions.
Non-functional
- 200 million shoppers; 50 million weekly active; 5 million active promotions; 2 million stores.
- Deals page in under 300 ms; a sale start visible within 1 s everywhere.
- Alerts within 2 minutes of entering a store's radius; at most 3 alerts per shopper per day.
- Promotion data strongly consistent for merchants; the shopper view may lag a few seconds except at a scheduled start.
- Location data for alerts is coarse and short-lived.
Weekly page: 50 M shoppers, each opening it a few times a week, about 20 M opens a day ≈ 230 per second average, a few thousand at peak; cheap if the page is precomputed.
Personalisation: ranking 5 M promotions per shopper on demand is 5 M × 50 M = 2.5 × 1014 scorings a week, impossible; retrieval must narrow to a few hundred candidates per shopper (by category affinity and location) before ranking, and the result is computed once a week per shopper plus small daily refreshes.
Alerts: 50 M shoppers reporting coarse location every 5 minutes is 170,000 reports a second; matching each against nearby stores is a cell lookup.
A promotion record is 1 KB; 5 M promotions is 5 GB, fully cacheable in every region.
2. Core entities
- Promotion: promo_id, merchant_id, discount, scope (SKUs, categories, stores), starts_at, ends_at, state, version.
- Store: store_id, merchant_id, location, cell.
- Shopper profile: shopper_id, category affinities, home cell, notification budget used today.
- Personal deals list: shopper_id, week, ranked promo ids, built_at.
- Alert record: shopper_id, promo_id, store_id, sent_at.
3. API
POST /promotions,PUT /promotions/{id}: merchant tools; validated and versioned.GET /shoppers/{id}/deals?week=…: the personalised list with live promotion details merged in.POST /shoppers/{id}/location: coarse cell and timestamp, every few minutes while the app is open.GET /promotions/{id}: current details and state; served from cache.PUT /shoppers/{id}/alert-preferences: opt in, categories, daily cap.
4. High-level design
A shopper's week:
- Sunday night the batch ranker builds each shopper's list: retrieve candidates by category affinity and home area, score with the ranking model, store the top 50 promo ids in the personal lists store.
- Opening the page reads the list (one key-value read) and merges live promotion details from the regional cache, dropping promotions that have ended or changed scope; a daily refresher adds new promotions matching the shopper's affinities.
- The app posts a coarse location every few minutes; the proximity matcher looks up stores in the shopper's cell and neighbours, intersects their promotions with the shopper's list, checks the daily cap and recent alerts, and hands at most one alert to the notification system.
- A merchant schedules a sale; the promotion service stores it with starts_at, and the cache replicas receive the record ahead of time with its start, so becoming live is a local time check rather than a propagation event.
5. Deep dives
How does a scheduled sale start at the same second for everyone?
| Option | Behaviour | Trade-off |
|---|---|---|
| Flip a flag at start time and propagate | Caches learn of the change seconds to minutes later | Different users see different states; a race at the start |
| Pre-publish with a start timestamp; evaluate locally | Every cache holds the promotion before it starts and compares starts_at with its clock | Needs clocks within a second; the content is visible to anyone reading the cache early, so hide it from APIs until start |
| Short TTL on everything | Caches refetch often | Load scales with TTL, and still not simultaneous |
Pre-publish and evaluate the time locally; the API filters by starts_at ≤ now so the data is in place before it is visible. Ends are handled the same way, which also means an ended deal disappears everywhere at once without a purge.
Why is the personalised list precomputed rather than ranked on request?
The arithmetic rules out per-request ranking over 5 M promotions. Weekly precomputation runs as a batch over all shoppers in a few hours on a modest fleet, and the result is small (50 ids per shopper, 10 GB total). The cost is staleness: a promotion created on Wednesday would wait until Sunday, so the daily refresher runs the same retrieval and scoring only for promotions created since the last build, merging them into existing lists. Read-time merging with the live cache handles the other direction, promotions that ended or were withdrawn.
How are alerts kept to one useful message, not spam?
A shopper walking down a street passes twenty stores. The matcher ranks the candidate alerts by the promotion's score in the shopper's list and the store's distance, sends only the best, records it, and suppresses the same promotion for 7 days and any alert for the same shopper for 2 hours; the daily cap of 3 is enforced with a counter in the shopper profile. Location is reduced to a cell of about 200 m before it leaves the device and is kept only for the matching window, which keeps the privacy cost proportional to the feature.
What does the merchant-side consistency need, and how does the cache follow?
Merchant edits go to the promotions database with a version; the cache is populated from the database's change stream with the version, and the API treats a cached record older than its known version as stale and refetches. A merchant withdrawing a deal expects it gone within seconds, which the change stream delivers; the weekly lists are not touched, because the read-time merge drops any promotion whose cached state is not live.
What happens when the weekly build fails or a region's cache is cold?
The lists store keeps last week's list until the new one is written, so a failed build leaves shoppers with a slightly older page rather than an empty one, and the read-time merge still drops ended deals. A cold regional cache is warmed from the database change stream from the beginning of the current period, about 5 GB, in minutes; until then the API serves from the nearest warm region. The one thing never served stale is a scheduled start, because the start time travels with the record and is checked against the clock at read time.
6. What a strong answer sounds like
- Lead with the arithmetic that forbids per-request ranking and the weekly-plus-daily precomputation it implies.
- Explain pre-publishing with a start timestamp as the answer to a simultaneous sale start.
- Describe alert budgeting and suppression as product rules enforced in the matcher.
- Expect follow-ups on how to measure deal quality (click and redemption rates feeding the ranker), on regional caches, and on what happens to lists when a shopper moves city.
- A common mistake is to treat this as a notification problem only; the ranking and the scheduled-start consistency are what the interviewer probes.