Object-Oriented Design Interview

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.

POINT OF SALE — one checkout, one customer, secondsINVENTORY — the whole store, hours to daysSale- lines: List- payments: List+ addItem(Product, qty)+ total() Money+ complete()SaleLine- product: Product- quantity: int- unitPrice: Money+ subtotal() Money«interface»Payment+ authorise(Money)Product- sku: String- name: String- price: MoneyStockItem- product: Product- onHand: int- reorderLevel: int+ deduct(int)+ receive(int)Supplier- name: String- leadTime: DurationPurchaseOrder- lines- status11..*priced fromcountsfromreceives intoone event crosses: sale completed → deduct stockTwo subsystems, one crossing. Letting SaleLine write to StockItem directly is what turns this design into a knot at minute 35.
Drawing the boundary first, and naming the single event that crosses it, is what keeps the two halves independently changeable.

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.

Reserve, pay, commitReservethe stockTake the paymentCommit thereservationReleaseon failureDecrementing stock before payment loses goods; decrementing after loses the race.
The reservation is what makes the failure path safe — nothing is committed until money has moved.

The naive checkout

Java
public Order checkout(Cart cart, PaymentMethod method) {    Order order = Order.from(cart);    payment.charge(order.total(), method);                     // take the money    for (CartLine line : cart.getLines()) {        inventory.decrement(line.getProduct(), line.getQuantity());   // then decrement    }    return order;}

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.

Java
public Order checkout(Cart cart, PaymentMethod method) {    List<Reservation> reservations = new ArrayList<>();    try {        // 1. RESERVE — take the stock out of circulation without committing it        for (CartLine line : cart.getLines()) {            reservations.add(inventory.reserve(line.getProduct(), cart.getStore(),                                               line.getQuantity()));        }        // 2. PAY — outside any lock, because it is slow and external        PaymentResult result = paymentGateway.charge(cart.total(), method);        if (!result.isSuccessful()) {            reservations.forEach(inventory::release);            throw new PaymentDeclinedException(result.getReason());        }        // 3. COMMIT — the sale is real; stock leaves for good        Order order = Order.from(cart, result);        reservations.forEach(inventory::commit);        return orderRepository.save(order);    } catch (InsufficientStockException e) {        reservations.forEach(inventory::release);      // roll back partial reservations        throw e;    }}

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 InventoryItem for popular products, so reserve is the contended operation and it has to be atomic. Let me be specific about that."

Java
public class InventoryItem {    private Quantity onHand;    private Quantity reserved;    public synchronized Reservation reserve(Quantity qty) {        if (available().isLessThan(qty)) throw new InsufficientStockException(product, qty);        reserved = reserved.plus(qty);                       // check and act, atomically        return new Reservation(this, qty, clock.now());    }    public synchronized void commit(Quantity qty) {        onHand = onHand.minus(qty);        reserved = reserved.minus(qty);        if (onHand.isLessThan(reorderPoint)) notifyLowStock();    }    public synchronized void release(Quantity qty) { reserved = reserved.minus(qty); }}

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.

Java
public interface LowStockListener { void onLowStock(LowStockEvent event); }private void notifyLowStock() {    LowStockEvent event = new LowStockEvent(product, store, onHand, reorderPoint);    for (LowStockListener listener : listeners) {        try { listener.onLowStock(event); }        catch (RuntimeException e) { log.warn("listener failed", e); }   // one bad listener    }                                                                    // must not fail a sale}

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.

Four follow-ups on a deliberately small coreGrocery extensionsComposable discountsMulti-store transfersBarcodes and weighingOffline-capable tills
Promotions compose the way search filters do, plus one rule deciding which discount 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:

Java
public interface PromotionRule {    Optional<Discount> applyTo(Cart cart);      // empty if this promotion does not apply}public class PercentOffCategoryRule implements PromotionRule { … }public class BuyNGetMFreeRule       implements PromotionRule { … }public 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.