This is a classic LLD round at Amazon, Flipkart, Swiggy, and most product companies that run a coding-plus-design loop. I will walk it the way I would at a whiteboard, out loud, decision by decision.
If you remember five things
The whole answer in one glance. Everything below defends these five calls.
- Three things, kept apart. The cart holds state, the pricing engine does math, the inventory service guards stock. Blur them and every promo change forces a cart change.
- Discounts behind a Strategy interface. Each discount is one class. A new promotion is a new class added to a list, never an edit to the cart.
- Money is a value object. Never a double. Immutable, scale locked to 2, rounding decided in one place, so a cent never leaks into a finance ticket.
- The cart's stock check is optimistic. The inventory service is the authority, with an atomic conditional decrement. Saying this out loud is the line that moves the debrief.
- Checkout freezes a snapshot. Reserve stock, price against current catalog, snapshot into an immutable Order. Release on partial failure so no phantom holds leak.
1Clarify the requirements#
I never start drawing classes. I start asking questions, because "shopping cart" means five different things depending on the company.
- A cart at Amazon that holds 50 items for six months is a different animal from a quick-commerce cart at a Blinkit or an Instacart that expires in 30 minutes.
- So I pin down the scope first, because each answer moves the design.
The questions I actually ask, and what each one changes:
- Logged-in, guest, or both? Changes where the cart lives. A guest cart can sit in a cookie or localStorage; a logged-in cart has to be server-side.
- Survive across devices? Add on my phone, see it on my laptop. That pushes state to the server and usually to a shared store like Redis.
- Who owns pricing? Do we price the cart, or does a separate pricing service own discounts and tax?
- Does add-to-cart reserve inventory, or do we only check at checkout? The big one. Reserving at add-time lets an abandoned cart starve real buyers, so most e-commerce reserves only at checkout. Quick-commerce sometimes reserves earlier because stock is tiny.
For this guide I scope it like this.
Functional requirements
- Add an item, remove it, change its quantity.
- View the cart with a line-by-line breakdown and a total.
- Apply discounts: promo codes, "buy 2 get 1", percentage off.
- Validate stock before checkout and hand the cart off to an order.
- One cart per user, persisted, reachable from any device.
Non-functional requirements
- Concurrent updates to the same cart must not corrupt it. Two tabs, two devices, a double-tapped button: none of these loses a write.
- Pricing rules change often. Marketing ships a new promotion every week, so a new discount type must not mean rewriting the cart.
- Reads are frequent. People open the cart page far more than they edit it, so the total should be cheap to compute or cached.
Out of scope, said out loud so the interviewer knows these are deliberate cuts, not blind spots:
- Payment processing, catalog service internals, shipping logistics.
- I draw clean seams where each of those plugs in.
I am grading this opening before you name a single class. I want to hear you ask whether add-to-cart reserves stock, because that one answer reshapes the whole design. Jump straight to a UML diagram and you have told me you treat every cart the same, which is the thing I am trying to find out you do not.
2Identify the core entities#
Once the scope is clear, the nouns fall out. I pick entities by asking "what does this own, and what does it only reference?"
- Product. The catalog item: id, name, unit price, available stock. The cart references it, never owns it. Embed a full Product copy and a catalog price change never reaches carts already holding it.
- CartItem. A product plus a quantity inside one cart: the line item. Two of the same product is one CartItem with quantity 2, not two entries.
- ShoppingCart. The aggregate. Holds the items, knows the subtotal, knows which user it belongs to.
- Discount. A pricing rule. An interface, because "percentage off" and "buy 2 get 1 free" compute in completely different ways, and more types are coming next quarter.
- PricingEngine. Applies the active discounts and produces a final breakdown. Separate from the cart on purpose: the cart holds state, the engine does math.
- InventoryService. The gatekeeper for stock. The cart asks "can I have 3 of these?" and it answers. It is the source of truth for stock, not the cart.
- Order. What the cart becomes at checkout. The cart is mutable and temporary; the order is a frozen snapshot that must never change once placed.
The cart stores things, the engine prices things, the inventory service guards stock. Keep those three apart and the design stays flexible. Blur them and every pricing change forces a cart change, which is exactly the trap this problem is built to catch.
3Class design#
The shape, in one picture. Defend the relationships, not the boxes.
- ShoppingCart is the aggregate root. Everything about a user's current intent to buy lives under it.
- Cart owns its CartItems (composition): kill the cart and the line items die with it.
- CartItem points at a Product (association, not composition): the product outlives any single cart and is shared across thousands.
- Discount is an interface with concrete implementations; PricingEngine holds a list of them and runs them over the cart.
- Discount logic does not hang off the cart. If it did, every new promotion would edit the cart class, and that class should stay boring. Boring classes are the ones that do not break at 2 AM.
4Key implementation#
Start with Money, because using double for currency is the bug that eats weekends.
0.1 + 0.2is not0.3in floating point.- A cart off by a cent on a million orders is a real finance ticket.
- Use integer minor units (cents) or
BigDecimal. I useBigDecimalhere, scale locked to 2, explicitHALF_UP, so every call site rounds the same way.
public final class Money {
private final BigDecimal amount;
private Money(BigDecimal amount) {
this.amount = amount.setScale(2, RoundingMode.HALF_UP);
}
public static Money of(String value) { return new Money(new BigDecimal(value)); }
public static Money zero() { return new Money(BigDecimal.ZERO); }
public Money plus(Money other) { return new Money(this.amount.add(other.amount)); }
public Money times(int n) { return new Money(this.amount.multiply(BigDecimal.valueOf(n))); }
public Money percentage(int pct) {
return new Money(amount.multiply(BigDecimal.valueOf(pct)).divide(BigDecimal.valueOf(100)));
}
public BigDecimal value() { return amount; }
}
CartItem is thin. It knows its product and quantity and prices one line. Quantity is validated on the way in, so a corrupt line can never exist.
public class CartItem {
private final Product product;
private int quantity;
public CartItem(Product product, int quantity) {
if (quantity < 1) throw new IllegalArgumentException("qty must be >= 1");
this.product = product;
this.quantity = quantity;
}
public Money lineTotal() { return product.getUnitPrice().times(quantity); }
public void setQuantity(int q) {
if (q < 1) throw new IllegalArgumentException("qty must be >= 1");
this.quantity = q;
}
public Product getProduct() { return product; }
public int getQuantity() { return quantity; }
}
ShoppingCart keys items by product id, so adding the same product twice merges into one line instead of duplicating. I guard it for concurrency, justified in section 6.
public class ShoppingCart {
private final String cartId;
private final String userId;
private final Map<String, CartItem> items = new ConcurrentHashMap<>();
private final InventoryService inventory;
public ShoppingCart(String cartId, String userId, InventoryService inventory) {
this.cartId = cartId;
this.userId = userId;
this.inventory = inventory;
}
public void addItem(Product product, int qty) {
items.compute(product.getId(), (id, existing) -> {
int newQty = (existing == null ? 0 : existing.getQuantity()) + qty;
if (inventory.available(id) < newQty)
throw new InsufficientStockException(id, newQty);
if (existing == null) return new CartItem(product, qty);
existing.setQuantity(newQty);
return existing;
});
}
public void updateQuantity(String productId, int qty) {
if (qty == 0) { items.remove(productId); return; }
CartItem item = items.get(productId);
if (item == null) throw new ItemNotInCartException(productId);
if (inventory.available(productId) < qty)
throw new InsufficientStockException(productId, qty);
item.setQuantity(qty);
}
public void removeItem(String productId) { items.remove(productId); }
public Money getSubtotal() {
return items.values().stream()
.map(CartItem::lineTotal)
.reduce(Money.zero(), Money::plus);
}
public Collection<CartItem> getItems() { return items.values(); }
}
Now the piece I actually care about in an interview: discounts. The interface is tiny on purpose: can you apply, how much, what do I show the user.
public interface Discount {
boolean isApplicable(ShoppingCart cart);
Money discountAmount(ShoppingCart cart);
String label();
}
A percentage discount above a threshold, the classic "10% off orders over 1000":
public class PercentageDiscount implements Discount {
private final int percent;
private final Money minSpend;
public PercentageDiscount(int percent, Money minSpend) {
this.percent = percent;
this.minSpend = minSpend;
}
public boolean isApplicable(ShoppingCart cart) {
return cart.getSubtotal().value().compareTo(minSpend.value()) >= 0;
}
public Money discountAmount(ShoppingCart cart) {
return isApplicable(cart) ? cart.getSubtotal().percentage(percent) : Money.zero();
}
public String label() { return percent + "% off over " + minSpend.value(); }
}
And "buy N get M free" on a specific product, which prices completely differently. Free units come from how many full groups of N the user bought, so buying 7 with a buy-3-get-1 rule gives 2 free units, not 1.
public class BuyNGetMFree implements Discount {
private final String productId;
private final int buy, free;
public BuyNGetMFree(String productId, int buy, int free) {
this.productId = productId; this.buy = buy; this.free = free;
}
public boolean isApplicable(ShoppingCart cart) {
return cart.getItems().stream()
.anyMatch(i -> i.getProduct().getId().equals(productId) && i.getQuantity() >= buy);
}
public Money discountAmount(ShoppingCart cart) {
return cart.getItems().stream()
.filter(i -> i.getProduct().getId().equals(productId))
.findFirst()
.map(i -> {
int freeUnits = (i.getQuantity() / buy) * free;
return i.getProduct().getUnitPrice().times(freeUnits);
})
.orElse(Money.zero());
}
public String label() { return "Buy " + buy + " get " + free + " free"; }
}
PricingEngine composes them and never knows the concrete type, which is the whole point. Adding tax or a loyalty discount later is one new class added to the list.
public class PricingEngine {
private final List<Discount> discounts;
public PricingEngine(List<Discount> discounts) { this.discounts = discounts; }
public PriceBreakdown priceCart(ShoppingCart cart) {
Money subtotal = cart.getSubtotal();
Money totalDiscount = Money.zero();
List<String> applied = new ArrayList<>();
for (Discount d : discounts) {
if (d.isApplicable(cart)) {
totalDiscount = totalDiscount.plus(d.discountAmount(cart));
applied.add(d.label());
}
}
Money afterDiscount = Money.of(subtotal.value().subtract(totalDiscount.value()).toString());
return new PriceBreakdown(subtotal, totalDiscount, afterDiscount, applied);
}
}
Checkout turns the mutable cart into a frozen Order. Reserve stock first, then snapshot. If reservation fails partway through, release what you already took so no phantom holds leak.
public Order checkout(ShoppingCart cart, PricingEngine engine) {
List<String> reserved = new ArrayList<>();
try {
for (CartItem item : cart.getItems()) {
String pid = item.getProduct().getId();
if (!inventory.reserve(pid, item.getQuantity()))
throw new InsufficientStockException(pid, item.getQuantity());
reserved.add(pid);
}
PriceBreakdown price = engine.priceCart(cart);
return Order.from(cart, price);
} catch (RuntimeException e) {
reserved.forEach(pid -> inventory.release(pid, cart.getItems().stream()
.filter(i -> i.getProduct().getId().equals(pid))
.findFirst().get().getQuantity()));
throw e;
}
}
5Design decisions and patterns used#
Name these out loud. The interviewer is listening for the pattern words.
- Strategy is the backbone. Each Discount is a strategy; PricingEngine runs a list of them. New promotion? One class, add it to the list, touch nothing else. That is the open/closed principle doing real work, not a slide slogan.
- Composition over inheritance for the cart. It does not extend anything; it holds a map. Making
ShoppingCart extends ArrayListis the move people regret the first time they want to add a userId or guard a mutation. - Value object for Money. Immutable, equality by value,
BigDecimalscaling hidden in one place. Kills a whole category of rounding bugs because no call site picks its own rounding mode. - Aggregate separate from the services. Cart holds state, engine computes, inventory guards stock. In DDD terms the cart is the aggregate root and those services sit outside it. In practice: I can test pricing with zero inventory and test the cart with fake prices, no setup bleeding between them.
- Factory, if asked. A
DiscountFactorybuilds discounts from promo-code config in a database, so active promotions are data, not code. Marketing flips a row, the cart picks up a new rule, nobody ships a deploy.
The cart should be the most boring class in the system. All the change lives in the strategies around it.
6Handling edge cases and concurrency#
This is where the interview actually gets scored. Everything above is setup; this is the part a staff interviewer is really grading.
Reveal the answer
Two tabs, same cart.
- Both add an item. Plain
HashMapread-then-write loses one update: both read 1, both write 2. ConcurrentHashMap.computemakes the per-key update atomic, so merging quantities is safe without locking the whole cart.- Per-key atomicity is enough here because each line is keyed by product id.
The oversell race.
- The in-memory
inventory.available()check insideaddItemis optimistic, not the real guarantee. - Two carts both see "5 available" and both add 5, and now I have promised 10 units I do not have.
- The truth lives in the inventory service with an atomic decrement: a conditional
UPDATE stock SET qty = qty - ? WHERE id = ? AND qty >= ?that only succeeds if the stock is there. - The cart's check is a fast fail for the common case; the
reserveat checkout is the authority.
"The cart's stock check is optimistic, the inventory service is authoritative." Say it exactly. It tells a staff engineer you have shipped this and you know the cart is not the source of truth for stock. That line has moved more candidates from lean-hire to hire in my debriefs than any extra code.
Quantity zero and negatives.
updateQuantity(id, 0)removes the line.- Negative quantities throw.
- Adding a product that no longer exists in the catalog fails loudly, never silently creating a line pointing at a dead product.
Stale prices.
- The product price can change between add and checkout, common when a sale starts or ends.
- I always re-price at checkout against current catalog prices.
- I surface a "price changed" notice rather than silently charging the new amount. Silently charging more is a trust problem and sometimes a legal one.
Partial checkout failure.
- Reserve item A, item B is out of stock, release A. No dangling reservations.
- That try-finally in the checkout method is the difference between a clean failure and a slow leak of phantom holds.
Reservation timeouts.
- A reserved item that never gets paid for has to time out and release, or phantom "sold out" states pile up and real buyers bounce.
- A TTL on the reservation, say 10 to 15 minutes, plus a sweeper job that releases anything past its deadline.
Here is the checkout flow when it races against inventory.
Reaching for double on Money, or drawing addItem as a plain read-then-write and not noticing the lost update until I say "two tabs." Worse: insisting the cart's available() check prevents overselling. If you cannot name the inventory service as the authority, I read it as never having run this in production.
7Extensions and follow-ups#
Where the interviewer takes it when there is time left.
- Save for later and wishlist. A second collection, or a separate aggregate. Kept apart because saved items do not count toward the total or need stock checks until they move back.
- Cart merge on login. Guest cart in the browser, then the user logs in with a saved cart. Rule: union the items, sum quantities for shared products, re-validate against current stock, then clamp to available. The "same product in both" answer reveals whether you thought about it or are improvising.
- Stacking and conflicting discounts. Right now I sum every applicable discount. Real systems cap, rank, or mark some exclusive so a user cannot stack a 20% code on a clearance price. A
DiscountResolverpicks the best non-conflicting combination, a small optimization problem and a good deeper signal. - Tax and shipping. Both slot into
PriceBreakdownas line items computed after the discount. Tax is often its own strategy keyed by region, because a cart shipping to Karnataka and one to Delhi can tax differently. - Persistence and scale. For a cross-device cart, store it in Redis keyed by user id with the items as the value, and keep a database as the durable copy. Reads stay fast because the hot path never touches disk, and the cart survives a restart.
"Two tabs add the last item at once. How do you not lose a write?"
"Your available() check still oversells under load. Fix it."
"A promo team wants a new discount type next week. What changes?"
"Why not just use double for price to keep it simple?"
"A guest with a cart logs in and already has a saved cart. Merge them."
8What the interviewer is really checking#
The signals, ranked by how much they move the score.
- Do you separate concerns? The clearest signal: discounts inside the cart (weak) versus behind a strategy interface (strong).
- Do you treat money correctly, or reach for a
doubleand move on. - Do you notice the concurrency and oversell races on your own, or only after being poked.
- Do you know what the cart is NOT responsible for: payments, catalog, and being the source of truth for stock.
Say the seams out loud. "The cart's stock check is optimistic, the inventory service is authoritative" is one sentence that tells a staff engineer you have shipped this. That sentence has moved more candidates from lean-hire to hire in my debriefs than any amount of extra code.
What you can now explain in the room
- Why discounts belong behind a Strategy interface, and how a new promo adds a class instead of editing the cart.
- Why Money is a value object over BigDecimal, and how that kills a class of rounding bugs.
- How ConcurrentHashMap.compute stops a lost update when two tabs hit the same cart.
- Why the cart's stock check is optimistic and the inventory service is the real authority.
- How checkout reserves, prices, snapshots into an immutable Order, and releases on partial failure.
- Where cart merge, discount stacking, tax, and cross-device persistence plug in without touching the core.
If you liked the Strategy work here, try Design a Vending Machine (State pattern) and Design a Parking Lot (pricing strategies and allocation) next.
Get a prep plan built around your Design an Online Shopping Cart interview
Tell us the role and your interview date. We will send back a dated, day by day plan. Free.