Design a book price aggregator
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
Show the current price of a book across many online sellers, keeping prices fresh while respecting each seller's API limits.
Requirements
We are building a service that, given a book (ISBN), returns the current price from many online bookstores, and can optionally buy it from the cheapest store if the price is at or below a buyer's limit, while never exceeding any store's API quota.
Clarify first: how many stores (dozens, not thousands), whether stores push price changes or we must poll (assume poll only), how stale a shown price may be (minutes), and whether we hold money (assume we charge the buyer and place the order on the store with our own account).
Functional requirements
- Quote: for an ISBN (and condition: new/used), return each store's price, shipping, stock and quote age, plus the best total.
- Bid: "buy ISBN X if total ≤ $Y" — the system finds the cheapest qualifying store and purchases, or reports that no store qualifies.
- Bid status: the caller can poll or receive a webhook for the outcome (
purchased,no_match,failed). - Partial results: return what came back by the deadline and mark stores that timed out.
Non-functional requirements
- Quote p99 under 800 ms; cache hits under 50 ms.
- Respect each store's limit (for example 5–50 requests/s per API key) with zero 429 storms.
- A shown price is at most 10 minutes old for browsing; a purchase always re-checks the live price.
- A bid charges the buyer at most once and creates at most one store order. Correctness beats latency here.
- One slow or broken store must not slow the whole quote.
Back-of-the-envelope estimation
- 30 stores, 5M distinct ISBNs in catalog, 2M quote requests/day ≈ 25/s average, 250/s peak.
- Popularity is skewed: the top 50k ISBNs take ~80% of quotes, so a cache hit rate of 80–90% is realistic.
- Cache misses at peak: 250 × 0.15 ≈ 40 ISBNs/s × 30 stores = 1,200 store calls/s. Combined store budget is about 30 × 20 = 600/s, so misses alone can exceed quota; coalescing and prioritizing fetches is required, not optional.
- Bids: 50k/day ≈ 0.6/s average, 10/s peak. Small volume, high correctness.
- Price cache: 5M ISBNs × 30 stores × ~100 B ≈ 15 GB if every pair were cached; in practice the hot set is under 2 GB in Redis.
API design
Public:
GET /v1/books/{isbn}/quotes?condition=new&max_age=600
→ 200 {isbn, quotes: [{store, price_cents, shipping_cents, currency, in_stock,
fetched_at, source: "cache"|"live"}],
missing: [{store, reason: "timeout"|"rate_limited"|"down"}],
best: {store, total_cents}}
POST /v1/bids Idempotency-Key: <uuid>
{isbn, condition, max_total_cents, currency, shipping_address_id, payment_method_id}
→ 202 {bid_id, status: "pending"}
GET /v1/bids/{bid_id}
→ 200 {bid_id, status: "pending"|"purchased"|"no_match"|"failed"|"needs_review",
store, total_cents, store_order_id, updated_at}
Internal store adapter (one implementation per store, same interface):
GetPrice(isbn, condition) → {price_cents, shipping_cents, in_stock, etag} | error{retryable}
PlaceOrder(isbn, max_total_cents, client_order_ref) → {store_order_id, total_cents} | error
GetOrder(client_order_ref) → {state, store_order_id} | not_found
CancelOrder(store_order_id) → ok | error
client_order_ref is our bid id. Stores that accept a client reference let us deduplicate retries and look orders up after a timeout; for stores that don't, the adapter keeps its own dedupe table.
Data model
Access patterns: read all store quotes for one ISBN (hot); upsert one (store, ISBN) price; read one bid by id; scan bids stuck in an in-between state.
-
Price cache (Redis) — hash
price:{isbn}:{condition}with one field per store:{price, shipping, stock, fetched_at}. OneHGETALLserves a quote. Freshness is judged per store fromfetched_at; the whole key expires after 24 h. Popular books are refreshed proactively. -
Price history (Postgres or a columnar store) —
price_observation(isbn, store, condition, price_cents, observed_at), partitioned by month. For analytics and dispute evidence, not the hot path. -
Bids (Postgres) —
bid(id PK, user_id, isbn, condition, max_total_cents, status, chosen_store, quoted_total_cents, store_order_id, payment_auth_id, idempotency_key UNIQUE(user_id, key), created_at, updated_at), index(status, updated_at)for the reconciler. -
Bid events (Postgres) — append-only
bid_event(bid_id, seq, type, payload, at); every state transition is written in the same transaction as the bid row update. -
Store config —
store(id, rate_limit_rps, burst, timeout_ms, supports_client_ref, enabled).
Postgres for bids because we need transactions, unique constraints and conditional updates (UPDATE … WHERE status = 'pending'). Redis for prices because they are disposable and read at high rate.
Core invariants:
- A bid moves forward only:
pending → authorized → ordering → purchased | failed | needs_review, orpending → no_match. - At most one store order per bid; the buyer is captured only for a confirmed order.
- Total charged ≤
max_total_cents.
High-level design
flowchart LR
C[Client] --> API[Quote and Bid API]
API --> PC[(Redis price cache)]
API --> FO[Fan-out service]
FO --> RL[Per-store rate limiter]
RL --> AD[Store adapters]
AD --> S1[Bookstore A]
AD --> S2[Bookstore B]
AD --> S3[Bookstore N]
FO --> PC
API --> BW[Bid workflow]
BW --> DB[(Postgres bids)]
BW --> PAY[Payment provider]
BW --> AD
REF[Refresh scheduler] --> FO
REC[Reconciler] --> DB
REC --> AD
-
Quote, cache hit. The API reads
price:{isbn}. If every enabled store has a fresh entry, it returns in one Redis round trip. - Quote, cache miss or stale. The API asks the fan-out service for the missing stores only. Fan-out coalesces with any in-flight fetch for the same (store, ISBN), takes a token from that store's rate limiter, calls the adapter with the store's timeout, writes results to Redis, and returns what arrived before an overall 700 ms deadline. Late answers still land in the cache for the next reader.
-
Conditional purchase.
POST /bidsstores the bid (idempotency key) and returns 202. The bid workflow gets live prices from all stores (bypassing cache), ranks qualifying stores by total, authorizes the buyer's card formax_total_cents, places the order on the best store withclient_order_ref = bid_id, then captures the actual total and voids the rest. If the best store refuses (price moved, out of stock), it tries the next one. - Background refresh. The scheduler re-fetches hot ISBNs before they expire, using only spare rate-limit budget.
-
Reconciliation. A job scans bids stuck in
orderingand asks the store what actually happened.
Why this is hard: the user wants fresh prices from many stores fast, but each store caps us, some are slow or down, and a purchase spans two outside systems (card and store) that we cannot put in one transaction.
Deep dives
Rate limits and freshness on a fixed budget. Each store has a token bucket in Redis sized to ~80% of its published limit (headroom for clock skew and their own counting). Calls are split into three priority lanes sharing the bucket: bid re-checks (always first), user-facing cache misses, then background refresh. Refresh only takes tokens when the bucket is above 50%. Refresh order is by popularity × staleness, so the top 50k ISBNs stay under 10 minutes old while the long tail is fetched on demand. If a store returns 429, the limiter halves its rate for that store for 60 s (AIMD) and then grows back.
Request coalescing for hot books. When a new bestseller launches, thousands of users ask for the same ISBN at once. The fan-out keeps a single-flight map keyed by (store, isbn): the first request makes the call, later ones wait on the same future. Across fan-out instances, a short Redis lock fetch:{store}:{isbn} (TTL = store timeout) does the same. Result: one call per store per ISBN per refresh window, regardless of demand.
Downstream failure and partial results. Each store call has its own timeout (p99 of that store + margin, capped at 600 ms) and a circuit breaker: after 50% errors over 20 calls it opens for 30 s and the quote marks that store down instantly. The aggregator never waits on the slowest store; at the deadline it returns what it has, falls back to a cached value up to 24 h old labeled with its age, and lists the rest under missing. Health stats (error rate, p99, 429 rate) per store feed routing: a degraded store is skipped for bids unless it is the only one under the buyer's limit.
| On timeout | User sees | Risk |
|---|---|---|
| Drop the store | Fewer options | Misses the true cheapest |
| Serve stale cache with age | Full list | Price may have moved; fine for browsing |
| Wait for all | Complete, slow | One store sets p99 for everyone |
Pick stale-with-age for quotes, and always live for bids.
Purchase atomicity across card and store. No distributed transaction is available, so the bid workflow is a saga with each step recorded before it is taken:
-
pending → authorized: authorize card formax_total_centswith idempotency keybid_id. -
authorized → ordering: write the target store, then callPlaceOrder(…, client_order_ref = bid_id). - Success → capture the store's actual total (≤ max),
purchased. Clean refusal → try next store or void the auth and markno_match. - Timeout or 5xx → the outcome is unknown. Do not try another store (could buy twice). Call
GetOrder(bid_id)with backoff; if found, continue as success; if confirmed absent after N tries, move on.
Every transition is a conditional update (WHERE status = 'ordering' AND chosen_store = $s) so a retried worker cannot act twice. Card auths expire in ~7 days, which bounds how long a bid can sit unresolved.
Reconciliation and ambiguous outcomes. Stores without order lookup are the hard case. The reconciler runs every 5 minutes over ordering bids older than 2 minutes and, nightly, matches our records against each store's order export (by client_order_ref or by ISBN + amount + time window). Mismatches go to needs_review: store order without capture → capture or cancel the order; capture without store order → refund. The bid_event log and price history are the evidence for buyer disputes ("you showed $12, charged $14": we show the live re-check at purchase time).
Trade-offs
- Polling with caching gives coverage of every store but shows prices up to 10 minutes old; store webhooks would be fresher but few stores offer them.
- Reserving budget for bids and user misses means the long tail is often stale; we give up uniform freshness to protect latency where users are.
- Serving stale-with-age keeps quotes complete and fast but can show a price that is gone; the bid path pays for a live re-check instead.
- Authorizing the full
max_total_centsup front ties up the buyer's funds briefly but guarantees we never place an order we can't pay for. - Refusing to fail over to another store after an unknown outcome makes some bids slower (or
needs_review) in exchange for never buying two copies. - One adapter per store is more code to own, but it isolates each store's quirks, limits and error shapes from the core.
Interviewer expectations
Mid-level (L4)
- Clear fan-out to stores with per-call timeouts and a price cache keyed by ISBN.
- Knows each store has a rate limit and puts a token bucket in front of it.
- Returns partial results instead of failing when one store is slow.
Senior (L5)
- Does the quota math and sees that misses alone can exceed the combined budget, then adds coalescing, priority lanes and popularity-based refresh.
- Treats the purchase as a saga with idempotency keys, conditional state updates and a separate live-price check.
- Circuit breakers and health-based routing per store.
Staff (L6+)
- Handles the unknown-outcome case explicitly: no failover after a timeout, order lookup by client reference, reconciliation against store exports.
- Defines the invariants (one order per bid, charge ≤ limit) and shows how every step preserves them.
- Discusses cost and contract levers: negotiating higher limits for top stores, AIMD on 429s, and how freshness SLOs differ by popularity tier.