Course Content
Object-Oriented Design Interview
14 sections · 29 lessons
Grocery Store: checkout, inventory and promotions in code
The model is settled: a catalogue, per-store stock, and pricing behind a service. This lesson draws it with the one boundary that matters, writes the checkout transaction and the low-stock notification — both places where the naive version is subtly wrong — and then works through the extensions, ending with the offline till, where you have to give up a requirement and say so.
The class diagram
The drawing has one job beyond showing classes: it must show the boundary between the point-of-sale side and the inventory side, because that boundary is the design decision this problem is about.
Why the boundary is the point
Drawing the boundary forces a question that a flat class diagram hides: how much does the till depend on the inventory system?
Three calls cross it. That number is the design. It means:
- The till can be tested with a fake inventory.
- If inventory is slow, there are exactly three places to add a timeout or a fallback (the checkout code below).
- A future move of inventory to a separate service is a change to three call sites, not a rewrite. That is the honest bridge to System Design Interview, and worth saying.
Two things deliberately absent
No Customer class. In a supermarket, most customers are anonymous. Adding a customer only becomes necessary with loyalty, which is cut. Say so — "no customer entity, because without loyalty a sale doesn't need one; it would attach to Order if loyalty came back."
No Shelf or Aisle. Physical layout has no behaviour this design depends on. It is the DisplayBoard of the parking lot class diagram — a real thing in the world with no place in this model.
Checkout and inventory update, in code
Two things to write: the checkout transaction, and the low-stock notification. Both are places where the naive version is subtly wrong.
The naive checkout
1public Order checkout(Cart cart, PaymentMethod method) {2 Order order = Order.from(cart);3 payment.charge(order.total(), method); // take the money4 for (CartLine line : cart.getLines()) {5 inventory.decrement(line.getProduct(), line.getQuantity()); // then decrement6 }7 return order;8}Two failures.
Stock can go negative. Nothing checked availability, so four tills each selling the last two packets of coffee all succeed. Requirement N1 is violated.
The order is wrong if either step fails. Payment succeeds and the decrement throws: the customer paid and the books say the stock is still there. Payment fails after a decrement in a different ordering: stock vanished for a sale that never happened.
Reserve, pay, commit
The repair is three phases, and it is the same shape as the seat hold in Preventing double booking, in code.
1public Order checkout(Cart cart, PaymentMethod method) {2 List<Reservation> reservations = new ArrayList<>();3 try {4 // 1. RESERVE — take the stock out of circulation without committing it5 for (CartLine line : cart.getLines()) {6 reservations.add(inventory.reserve(line.getProduct(), cart.getStore(),7 line.getQuantity()));8 }910 // 2. PAY — outside any lock, because it is slow and external11 PaymentResult result = paymentGateway.charge(cart.total(), method);12 if (!result.isSuccessful()) {13 reservations.forEach(inventory::release);14 throw new PaymentDeclinedException(result.getReason());15 }1617 // 3. COMMIT — the sale is real; stock leaves for good18 Order order = Order.from(cart, result);19 reservations.forEach(inventory::commit);20 return orderRepository.save(order);2122 } catch (InsufficientStockException e) {23 reservations.forEach(inventory::release); // roll back partial reservations24 throw e;25 }26}Three properties this gives you, each worth stating:
- Stock is protected from the moment of scan, not from the moment of payment.
- The payment call happens with no lock held, so a slow gateway blocks nobody. Same rule as the movie booking design: never hold a lock across an external call.
- Every failure path releases the reservations, so an abandoned checkout does not strand stock.
The reservation itself, and the concurrency question
Raise this before the interviewer does — this problem always ends here.
"Eight tills are hitting the same
InventoryItemfor popular products, soreserveis the contended operation and it has to be atomic. Let me be specific about that."
1public class InventoryItem {2 private Quantity onHand;3 private Quantity reserved;45 public synchronized Reservation reserve(Quantity qty) {6 if (available().isLessThan(qty)) throw new InsufficientStockException(product, qty);7 reserved = reserved.plus(qty); // check and act, atomically8 return new Reservation(this, qty, clock.now());9 }1011 public synchronized void commit(Quantity qty) {12 onHand = onHand.minus(qty);13 reserved = reserved.minus(qty);14 if (onHand.isLessThan(reorderPoint)) notifyLowStock();15 }1617 public synchronized void release(Quantity qty) { reserved = reserved.minus(qty); }18}Locking per InventoryItem rather than over the whole inventory is the right granularity, and the reason is the number from Requirements: contention is on a few popular products, so per-product locks almost never collide while a global lock would serialise every till in the store.
With a database rather than in-memory objects, the same shape becomes a conditional update: UPDATE inventory SET reserved = reserved + ? WHERE product_id = ? AND on_hand - reserved >= ?, and zero rows updated means insufficient stock. Mention both — one shows you can write the locking, the other shows you know where it really lives.
Low stock: Observer, justified against polling
When stock crosses the reorder point, someone must be told. Two options:
Polling. A job every fifteen minutes scans every InventoryItem in every store and reports the ones below their threshold. For 30,000 products across 20 stores that is 600,000 rows scanned every fifteen minutes to find perhaps forty rows that changed. It is also up to fifteen minutes late.
Observer. The moment commit pushes stock below the threshold, publish an event.
1public interface LowStockListener { void onLowStock(LowStockEvent event); }23private void notifyLowStock() {4 LowStockEvent event = new LowStockEvent(product, store, onHand, reorderPoint);5 for (LowStockListener listener : listeners) {6 try { listener.onLowStock(event); }7 catch (RuntimeException e) { log.warn("listener failed", e); } // one bad listener8 } // must not fail a sale9}The justification to say out loud: "I'll use Observer rather than polling, because the event is rare — a product crosses its reorder point a handful of times a day — and polling scans everything to find nothing 99% of the time. The cost is that the checkout path now runs listener code, so I catch per listener and, in production, I'd hand the event to a queue rather than run listeners inline."
That last clause is the important one. Observer's real risk here is that a slow listener slows down a sale, and naming the fix — publish to a queue, do not execute inline — is what separates knowing the pattern from having used it.
Extensions
1. Discounts and promotions as composable rules
Real promotions are messy: 10% off one category, buy-two-get-one-free, ₹50 off over ₹500, member-only prices, and two of them at once with a rule about which wins.
The wrong answer is a discountPercent field on Product. The right shape is the one from the file search filter design — small rule objects that combine:
1public interface PromotionRule {2 Optional<Discount> applyTo(Cart cart); // empty if this promotion does not apply3}45public class PercentOffCategoryRule implements PromotionRule { … }6public class BuyNGetMFreeRule implements PromotionRule { … }7public class ThresholdAmountRule implements PromotionRule { … }Then the genuinely hard part, which you should raise yourself: stacking. Two promotions that both apply need a rule — best-for-customer, best-for-store, or first-match-only. Say that promotion priority and exclusivity are business decisions that belong in configuration, not in code, and that the engine applies rules in priority order and marks consumed lines so one item is not discounted twice.
Naming the double-discount bug before it is asked about is a strong move.
2. Multi-store stock transfer
Store A is out of coffee; store B has forty. A transfer is not one operation but a small state machine: REQUESTED → IN_TRANSIT → RECEIVED, with stock decremented at A on dispatch and incremented at B on receipt — never both at once, because the stock is genuinely on a van in between and must be visible somewhere.
Introduce a StockTransfer entity holding both stores, the product, the quantity, and the state. Point out that "in transit" is real inventory that neither store can sell, which is why it needs its own representation rather than being modelled as a decrement plus an increment.
3. Barcode scanning and weighed goods
A scanner is an input device, so it belongs behind an interface — one line, and it lets the till be tested without hardware. The interesting part is what a barcode means:
- A standard product barcode identifies the product; quantity comes from how many times it was scanned.
- A scale-printed barcode encodes the product and its weight or price in the digits, so a single scan carries a quantity.
That makes CartLine.quantity a Quantity with a unit, not an int — which is the detail caught back in Requirements. Being able to say "this is why quantity is not an integer" ties the requirement to the model, and that link is what interviewers are checking.
4. Offline-capable tills
The best extension question on this problem, because the answer has to be honest about a trade-off you cannot escape.
The network to the inventory service goes down. The store cannot stop selling. So:
- The till buffers transactions locally and keeps selling from a cached price list.
- Reservations are impossible while offline, so stock can go negative — the store sells the last two packets of coffee twice, from two different tills.
- On reconnection, the till replays its buffered sales, and inventory reconciles: stock is corrected, and negative levels are flagged for a manual count.
State the trade-off plainly: "Offline mode gives up requirement N1 — stock will occasionally go negative — in exchange for the store staying open. That is the right trade for a supermarket, because a closed till costs real money and an oversold product costs an apology. I'd bound the damage by keeping a local reservation cache per till, so at least one till cannot oversell itself."
That paragraph is a complete senior answer: it names the requirement being sacrificed, gives the business reason, and bounds the damage.