Designing a Local Delivery Service
Modeled on Gopuff: rapid convenience-store delivery from 500+ micro distribution centers. The design splits cleanly into a fast, high-volume availability read path and a strongly-consistent ordering write path.
(1) Availability is the union of nearby inventory. Because all inventory lives in a DC, you first find DCs that can deliver within 1 hour, then sum their stock — you never scan the whole catalog. (2) Reads and writes have opposite priorities. Availability lookups are enormous in volume and latency-sensitive; orders are rare but must be strongly consistent. The whole design is these two paths optimized separately.
1Understanding the Problem
A service that delivers convenience-store goods via rapid delivery from 500+ micro distribution centers (DCs).
Functional requirements — deliberately narrow
- Query availability of items deliverable in 1 hour, by location. Effective availability = the union of inventory across all nearby DCs.
- Order multiple items at once.
Explicitly out of scope: payments, driver routing/deliveries, search & catalog APIs, cancellations/returns. The system is strictly about availability and ordering.
The emphasis is aggregating availability across local DCs and letting users order without double-booking. Other e-commerce problems care about catalog and search; this one deliberately doesn't, so you spend interview time where it's interesting.
Non-functional requirements — the shape of the challenge
1. Availability is fast (< 100 ms) — it backs use-cases like search, so it must feel instant.
2. Ordering is strongly consistent — two customers must never buy the same physical product.
3. Scale: 10k DCs, 100k items in the catalog across DCs.
4. Volume: O(10M orders/day).
(Out of scope: privacy/security, disaster recovery.)
Out-of-scope items come from questions like "do we need to handle privacy?" It's fine to assert "I'll leave privacy out of scope to start" — the interviewer will correct you if that's wrong. Don't wait for permission to scope.
2Core Entities & the API
Default approach: build for the functional requirements first, ignoring scale; reintroduce the non-functional concerns one by one in the deep dives. Start with the nouns, then the API, then the boxes.
2.1 · The Item vs. Inventory distinction
Think class vs. instance (OOP). An Item is a catalog type (e.g. Cheetos) — all a customer cares about. Inventory is a physical instance of that item at a specific DC. You aggregate Inventory to compute how many of an Item a given user can actually get.
| Entity | What it is |
|---|---|
| Item | A type of product (Cheetos). What customers see in a catalog. |
| Inventory | A physical instance of an item, located at a DC. Summed to get availability. |
| DistributionCenter | A physical location storing inventory; used to decide what's reachable. |
| Order | A collection of Inventory ordered by a user (+ shipping/billing). |
Start from the most concrete/physical entities (items, users, DCs) and work up toward the abstract ones (orders, carts). Working concrete → abstract keeps you from overlooking something.
The API — just two endpoints
Keep it minimal to avoid burning interview time (a common mistake). One endpoint checks availability by location; one places an order.
// 1) Availability by location (keyword optional), paginated GET /availability?lat&long&keyword&page -> Item[] (with quantities) // 2) Place an order POST /orders { items:[...], lat, long, ...billing/shipping }
lat/long is passed to both APIs. Before the backend processes an order it must re-confirm the inventory is close enough to deliver within 1 hour — location isn't just a search filter, it's part of order validation. Pagination on availability keeps payloads sane.
3High-Level Design · Availability
Availability is a two-step query, and both steps must be fast to hit the < 100 ms budget.
- Find nearby DCs — DCs close enough to deliver in 1 hour. Since all inventory lives in a DC, this prunes the search dramatically before touching inventory.
- Sum their inventory — query inventory for those DC IDs, join to Items for name/description, return the union with quantities.
Request flow: Availability Service asks Nearby Service for serviceable DCs, then queries inventory for those DC IDs and sums the result.
The Nearby Service takes a lat/long and returns DCs within 1 hour. A first cut just measures distance with simple math — Euclidean, or the more accurate Haversine formula (accounts for Earth's curvature) — and thresholds it. That gives DCs within X distance as the crow flies, which doesn't yet satisfy "1 hour of drive time" — revisited in the drive-time deep dive.
Most e-commerce keeps the catalog separate from inventory (different consumers and workloads) and adds a search index like Elasticsearch. Here they're kept in one Postgres database to simplify and match the scoped requirements — but noting the ideal separation to the interviewer shows awareness.
4Ordering Without Double-Booking
Placing an order requires strong consistency: two users must not buy the same physical product. Latency matters less here — users tolerate more delay on checkout than on browsing. The three operations — check inventory, record the order, decrement inventory — must happen atomically.
A recurring system-design theme. To stop two users claiming the same inventory you need some form of locking: hold the inventory while you check it and record the order, so only one user can succeed at a time.
4.1 · GoodTwo data stores + a distributed lock
Separate databases for orders and inventory. On an order: lock the inventory records, create the order, decrement inventory, release the lock. Upside: you can pick the best store for each job (e.g. key-value for inventory, relational for orders).
Partial failure: if the service crashes after creating the order but before decrementing inventory, a later user can buy inventory already promised — you must sweep and reverse these.
Deadlock: two orders with overlapping items — User 1 holds the lock for A, User 2 holds B, each waits for the other. Neither proceeds.
Choose this path only if you can address both.
4.2 · GreatA single Postgres transaction
Put orders and inventory in the same database and lean on Postgres's ACID guarantees. One transaction at isolation level SERIALIZABLE makes the whole thing atomic — if two users race for the last unit, one transaction simply fails to commit.
BEGIN; -- isolation: SERIALIZABLE -- a. check inventory for A, B, C > 0 -- b. if any out of stock → transaction fails -- c. mark inventory A, B, C as "ordered" -- d. insert rows into Orders (and OrderItems) COMMIT; -- e. commit; success → return the order
When atomicity across operations is the requirement, colocate that data in one ACID store. Cross-store transactions are possible but add complexity/overhead you don't want to spend interview time on. Leaning into the existing Postgres keeps the system simple and correct.
You couple the scaling of inventory and orders and can't use a specialized store per use-case. Also, all-or-nothing orders: if one item goes out of stock the whole order fails. Return a meaningful error — but failing is preferable to shipping a nonsensical partial order (e.g. a device without its battery).
Putting it together
Three services; one Postgres partitioned by region. Availability reads from replicas; Orders writes to the leader with atomic transactions to avoid double-writes.
5Deep Dive · Drive-Time-Aware Nearby DCs
A straight-line distance can lie: a DC across a river or a border may be close in miles but far in drive time, and traffic shifts travel times. Since the requirement is 1 hour of drive time, we need something better than crow-flies distance.
BadSimple SQL distance
Store DC lat/longs, compute Euclidean/Haversine distance to the user, threshold it. Problem: ignores traffic, road conditions, and the fact that a city may hold several DCs.
BadTravel-time service against all DCs
DCs rarely change (they're buildings), so sync the DC table into service memory every ~5 min, then call an external travel-time estimation service for every DC. Problem: far too many queries — most DCs are nowhere near deliverable in an hour.
GreatPrune first, then estimate travel time
Stage 1 — cheap prune: keep the in-memory DC table (synced ~5 min); take a generous fixed radius (~60 miles, the most you could plausibly drive in an hour) to get a small candidate set.
Stage 2 — accurate: pass only those candidates to the external travel-time service for the real estimate.
This mirrors the geospatial "coarse filter then refine" idea — do the expensive, accurate computation only on a small, already-plausible set.
6Deep Dive · Fast, Scalable Availability
6.1 · Estimate the read volume first
Every availability request hits the database — potentially a lot of load. Back into the number from the 10M orders/day requirement:
# 10M orders/day; ~10 page views per user before buying; # only ~5% of shoppers actually purchase Queries = 10M / 100k sec/day × 10 / 0.05 = 20k queries/second
Quantifying a suspected bottleneck sharpens the interview: it gives you and the interviewer a shared basis for weighing trade-offs and shows you make reasonable assumptions. 20k QPS is clearly sizeable — it forces the scaling conversation.
6.2 · Three moves to handle 20k QPS
GreatQuery inventory through a cache
Add Redis in front. Availability service checks the cache for a set of inputs; on a hit, return it; on a miss, read the DB and write the result back. A low TTL (~1 min) keeps results fresh.
The cache must not go stale when stock changes. The Order Service must expire affected cache entries whenever it writes to the inventory table. (TTL bounds staleness; explicit invalidation handles the fresh purchase.)
GreatRead replicas + partition by region
Because you always read a nearby cluster of DCs, group them by a region ID = first 3 digits of the zipcode and partition inventory on it. Now a query touches mostly 1–2 partitions instead of the whole dataset.
Layer on read replicas for availability (a little inconsistency is tolerable on reads). Orders still need strong consistency, so their transactions go to the Postgres leader; availability reads go to replicas.
You now manage replica sizing against traffic and partition balance so no single replica/partition is overloaded.
7What's Expected at Each Level
| Level | Focus | The bar for this problem |
|---|---|---|
| Mid-level | ~80% breadth / 20% depth | Clearly define API endpoints and data model; build both routes (availability + orders). A "bad" solution is OK if the discussion is good — no need to jump straight to great. |
| Senior | ~60% / 40% | Speed through the high-level design, then optimize the critical paths in detail: atomic order transactions and availability scaling. Read volume and easy partitioning should jump out. |
| Staff+ | ~40% / 60% | Breeze past the basics; dive deep into 2–3 areas with unique insight and proactivity. Handle follow-ups of increasing difficulty. A good sign: the interviewer leaves having learned something. |
✦Community Debates & Nuances
The page's comment thread surfaces genuine disagreements the write-up doesn't fully resolve. These are worth knowing — they're exactly the follow-ups an interviewer might press.
Several readers noticed Ticketmaster treats a DB status-lock as sub-optimal and prefers a Redis lock with TTL — yet here the single Postgres transaction is the "great" answer. The clarifying insight from the thread: Ticketmaster locks a unique resource (a specific seat: available or not, 1 or 0), where losing a booking is unacceptable and popular seats are hotly contested. Gopuff inventory is a shared, mass-quantity resource — being off by one (9 vs 10) rarely matters as long as stock > 0, so heavyweight reservation locks aren't warranted.
A well-received comment pushes back: the business goal is to get people to spend. Blocking a 20-item checkout because one bag of chips went out of stock will crater the conversion funnel. Real grocery apps instead offer substitutions or a per-item refund, and accept occasional checkout stock-outs — especially since warehouse miscounts/damage mean even "locked" inventory isn't guaranteed. Author Stefan Mai concedes the point: in the real world you'd add partial-checkout functionality; the interview version is synthetic for a 1-hour format. Preserve this as an open tension, not a resolved answer.
Some readers propose decoupling orders and inventory with a Saga / Outbox + CDC + Kafka flow: record the order as "Ordered," stream to workers that decrement inventory with row-level locks, then mark "Confirmed" or "Failed" with a compensation/reconciliation step (idempotent ReturnOrder, a scheduler to sweep stuck "InProgress" orders). It enables independent scaling/sharding of the two tables. Counter-view (including a Top-1% commenter): "feels over-engineered" for a simple quantity = quantity − 1. Both appear as legitimate positions.
Sync vs async: most agree ordering should look synchronous — users expect an immediate "order placed." A common pattern is "looks sync but is actually async + polling" (return an order_id, poll /orders/{id}/status), sometimes via Temporal/Saga. Data model: the write-up confirms Inventory is one physical instance per row (a million gum packs = a million rows); commenters question this vs. a simple quantity field — the source states one-row-per-instance but the trade-off is debated.
★Active Recall
Explain the Item vs. Inventory distinction and why it matters.
Item is a catalog type (Cheetos) — what customers care about; Inventory is a physical instance of that item at a specific DC. It matters because "availability" is computed by summing Inventory across nearby DCs, while the customer only ever queries by Item. Class vs. instance. (§2.1)
Walk through the two steps of an availability query and why each must be fast.
1) Nearby Service maps lat/long → DCs deliverable within 1 hour (prunes the search since all inventory lives in DCs). 2) Query inventory for those DC IDs, join Items for names, sum quantities, return the union. Both steps must be fast because the end-to-end budget is < 100 ms. (§3)
Why choose a single Postgres transaction over two stores + a distributed lock for ordering?
Atomicity across check-inventory / record-order / decrement is the requirement. One ACID store with a SERIALIZABLE transaction gives this for free — a losing racer just fails to commit. Two stores + distributed lock introduce partial-failure sweeps and deadlock risk. Colocating the data avoids that complexity. (§4)
What two failure modes plague the distributed-lock approach?
(1) Partial failure: crash after creating the order but before decrementing inventory → inventory promised twice, needs a sweep to reverse. (2) Deadlock: two orders with overlapping items each hold one lock and wait for the other. (§4.1)
Reconstruct the 20k QPS estimate from the 10M orders/day requirement.
10M orders/day ÷ 100k sec/day × 10 page-views-per-user ÷ 0.05 (only 5% of shoppers buy) = 20k queries/second. The ×10 and ÷0.05 convert purchases into total availability reads. (§6.1)
How do you keep the availability cache fresh, and why is a TTL alone not enough?
Use a short TTL (~1 min) to bound staleness, but also have the Order Service explicitly expire affected cache entries when it writes inventory — otherwise a just-completed purchase wouldn't reflect until the TTL lapses. (§6.2)
What is the partition key for inventory, and why does it work?
Region ID = first 3 digits of the zipcode. Because availability always reads a nearby cluster of DCs, partitioning on region means a query hits mostly 1–2 partitions instead of the entire dataset. (§6.2)
Why is a Redis TTL lock the "great" answer for Ticketmaster but not for Gopuff?
Ticketmaster locks a unique, scarce resource (a specific seat) where losing a booking is unacceptable and contention is high. Gopuff inventory is shared and mass-quantity — off-by-one rarely matters as long as stock > 0 — so a plain ACID transaction suffices and reservation locks are overkill. (Debates)
Why prune to ~60 miles before calling the travel-time service?
Calling the external travel-time service for all 10k DCs is wasteful — most can't plausibly deliver in an hour. A cheap fixed-radius prune (~60 mi, the most you'd drive in an hour) yields a small candidate set; only those get the expensive, accurate travel-time estimate. (§5)
↺Quick Revision
Must remember
- Availability = union of nearby-DC inventory.
- Read path: < 100 ms. Write path: strongly consistent.
- Scale: 10k DCs, 100k items, 10M orders/day.
- Read volume ≈ 20k QPS.
- Partition key = zip prefix (region ID).
Rules & decisions
- Atomicity → one ACID store, SERIALIZABLE txn.
- Nearby: prune ~60 mi, then travel-time service.
- Cache: short TTL + write-invalidation.
- Reads → replicas; writes → leader.
Key distinctions
- Item (type) vs Inventory (physical instance @ DC).
- Gopuff (shared, mass qty) vs Ticketmaster (unique, scarce).
- Crow-flies distance vs drive time.
Common mistakes / traps
- Distributed lock → partial-failure & deadlock.
- Travel-time call against all DCs.
- Cache with no write-invalidation.
- Over-engineering with Saga/Kafka at this volume.
Solution ladders (bad → great)
- Ordering: two stores + distributed lock (fragile) → single Postgres SERIALIZABLE transaction.
- Nearby DCs: simple SQL distance → travel-time vs all DCs → prune to radius, then travel-time on candidates.
- Availability scale: direct DB → cache (TTL + invalidation) + read replicas + partition by region.
Study notes compiled from the Hello Interview "Local Delivery Service (Gopuff)" breakdown. This page has no video; the Community Debates section preserves genuine, unresolved disagreements from the page's comment thread rather than resolving them.