The system design question
Design a service where a user gives an ISBN and a max price. It asks lots of bookstores for their price, picks the best one, and places the order. Some stores are slow or fail. Focus on the API, the data model, and how you handle bad stores.
Agree on the scope (5 minutes)
Say up front what you're covering.
| We'll design | We'll skip |
|---|---|
| The API a user calls with an ISBN and a max price | Taking the user's payment (assume a payment service) |
| Asking many stores for prices, with a time limit | Shipping and tracking |
| Picking a store and placing the order | Search by title or author |
| Tracking which stores are slow or broken | Each store's own systems |
Then agree on what "good" means:
- Answers in seconds: the user gets a result within a few seconds, even if some stores never reply.
- One order, never two: a retry or a crash must never buy the same book twice.
- Respects the max price: we never order above what the user said they'd pay, shipping included.
- Gets around bad stores: one slow or broken store can't slow down or break everyone's requests.
Rough numbers (5 minutes)
These are assumptions, made up but reasonable: 200 bookstores we can call, 1 million buy requests a day, and peaks about 5 times the average around big sales.
- ~12
- buy requests a second, on average
- ~60
- buy requests a second at a peak
- ~12K
- store calls a second at a peak
- ~3 s
- time limit for collecting prices
| Question | Rough math | What it means |
|---|---|---|
| How many requests? | 1 million ÷ 86,400 seconds, then × 5 at a peak | About 60 a second. Small for us |
| How many store calls? | 60 requests × 200 stores | About 12K calls a second, but each store only sees about 60. Store limits matter more than our own size |
| How much order data? | 1 million orders × about 2 KB with every quote × 365 days | Under 1 TB a year. One relational database copes fine |
The overall design (10 minutes)
- The user sends an ISBN, a max price and a request ID they made up. We save a purchase request and return its ID right away.
- The fan-out asks every healthy store that might stock the book for a price, all at once, and stops waiting after about 3 seconds.
- Each answer is saved as a quote. When the time's up, or every store has answered, we pick the cheapest quote that's within the max price.
- We place the order with that store using our own order ID as the store's request key, so a retry can't buy twice.
- If that store fails, we move to the next best quote. The user polls the request, or gets a callback, to see how it ended.
What the apps need to do
| Action | Who does it | What's sent |
|---|---|---|
| Create a purchase request | User's app | ISBN, max price, shipping address, request ID |
| Get a purchase request | User's app | Purchase request ID. Returns its state, the quotes and the order if there is one |
| Cancel a purchase request | User's app | Purchase request ID. Only works before an order's placed |
| Get a price (outgoing) | Us, to each store | ISBN. Store replies with price, shipping and stock |
| Place an order (outgoing) | Us, to one store | ISBN, agreed price, address, our order ID as the request key |
What we store and where
| Data | Where it goes | Why |
|---|---|---|
| Purchase requests | Relational database | ISBN, max price, user, request ID (unique per user), and state: collecting, ordering, ordered, failed or cancelled |
| Quotes | Same database | One row per store per request: price, shipping, when it came in, and how long it lasts |
| Orders | Same database | One row per attempt to buy: store, price, our order ID, the store's order ID, and state |
| Stores | Same database | Each store's API details, timeout, rate limit, and whether it's on or off |
| Store health | In-memory cache | Recent error rate and response times per store, for the circuit breaker |
| Price cache | In-memory cache | Recent quotes by ISBN and store, kept for a few minutes |
Step 1: Asking stores without waiting on the slow ones (12 minutes)
Send all the price calls at once, not one after another, and give the whole round one deadline, say 3 seconds. Whatever's back by then is what you choose from. A store that answers later is just ignored for this request. Waiting for everyone means your speed is the speed of your worst store.
Each store also gets its own short timeout and its own small pool of connections. That way one store that hangs can't use up every connection and block calls to the others. People call this a bulkhead.
Keep a circuit breaker per store. If a store's error rate or timeouts jump, stop calling it for a minute, then let a few test calls through. If those work, turn it back on. This saves time on every request and stops us hammering a store that's already struggling.
Respect each store's limits with rate limiting on our side. Popular ISBNs get asked about a lot, so a short cache of recent quotes, a few minutes long, cuts calls a lot. Prices change, so the order step always checks the price again.
Which stores to ask
Some stores publish a stock list or tell us when they don't carry a book. Remember 'not stocked' answers for a while and skip those stores next time. It's an easy way to cut calls without missing much.
Step 2: Placing the order exactly once (10 minutes)
Two things can cause a double order: the user retrying, and us retrying the store after a timeout. The user's request ID handles the first. It's unique per user in the database, so a repeat gets the existing purchase request back instead of a new one. See idempotency.
For the store, write the order row with our own order ID and state 'placing' before calling. Send that ID as the store's request key. If the call times out, we don't know if it worked, so we retry with the same ID, or ask the store about that ID. We only move to the next store once we're sure this one failed.
The purchase request is a small state machine: collecting, then ordering, then ordered or failed. Each step only moves forward, and the update checks the current state, like 'set ordering only if it's still collecting'. Two workers can't both start ordering for the same request.
Step 3: The API and the data model (8 minutes)
Make creating a purchase return fast, with an ID, and let the user poll it or get a callback. Collecting prices and ordering can take a few seconds or more, and you don't want a phone holding a connection open while you retry a store.
Keep quotes as their own rows rather than squeezing them into the request. That lets you show the user what each store offered, explain why one was picked, and later work out which stores are usually cheapest or slowest.
A relational database fits well here. The data's small, it's all linked by IDs, and the state changes need simple transactions. See relational vs non-relational. Store prices as whole cents with a currency, never as floats.
Choices you should be able to defend
| We chose | Instead of | Because | We'd reconsider if |
|---|---|---|---|
| One deadline for all quotes | Wait for every store | Users get an answer in seconds, and one slow store can't hold everyone up | The cheapest stores were reliably the slow ones, so a few extra seconds saved real money |
| Async API with polling or a callback | One long blocking call | Ordering can take a while with retries, and a blocking call ties up connections | Almost every request finished in under a second |
| Short price cache | Always ask live | Far fewer calls to stores for popular books | Stores changed prices every few seconds, so cached quotes kept failing at order time |
| Relational database | A key-value store | Small linked data with state changes that need transactions | Volume grew far past what one database could hold, which is unlikely here |
What could go wrong (5 minutes)
| What happens | What we do about it |
|---|---|
| A store never answers | The deadline passes and we pick from the stores that did. Its circuit breaker counts the timeout |
| A store returns nonsense, like a price of 0 | Check every quote's shape and range, drop bad ones, and count them as errors for that store |
| The chosen store refuses the order or the price went up | Mark that order failed and try the next best quote that's still within the max price |
| Our service crashes while ordering | The request is left 'ordering' with an order row. A sweeper checks with the store using our order ID and finishes it |
| No store is under the max price | The request ends as failed with a clear reason, and the user can see the quotes we got |
Follow-up questions (5 minutes)
What if the user wants the book whenever the price drops under their max?
Keep the request open as a watch. Re-check prices on a schedule, more often for popular books, and order the first time a quote fits.
How do you add a new store?
Write an adapter that turns their API into our quote and order shapes, and add a row to the stores table. Start it with a low rate limit until its health looks good.
What if we have to charge the user before ordering?
Hold the max price on their card first, order, then capture the real amount. If every store fails, release the hold.
How the 60 minutes add up
| Minutes | What you're doing |
|---|---|
| 0–5 | Agree on what you're building |
| 5–10 | Rough numbers |
| 10–20 | Draw the overall design |
| 20–32 | Asking stores without waiting on the slow ones |
| 32–42 | Placing the order exactly once |
| 42–50 | The API and the data model |
| 50–55 | What could go wrong |
| 55–60 | Follow-up questions |
Key concepts used here
- Caching strategiesMake reads faster, and figure out what to do when data changes.
- Idempotency and retriesMake sure a retried request doesn't happen twice.
- Relational vs non-relational databasesPick a database based on how you'll actually use it.
- Rate limitingStop too much traffic with token buckets and sliding windows.
Further reading
We wrote this sample answer ourselves.
Practice Loop is independent and isn't affiliated with or endorsed by Databricks.