Online Shopping
Product catalog, shopping cart, and order pipeline with inventory management. Cart is mutable and references products by ID. Orders are immutable snapshots with reserved inventory.
Key Abstractions
Facade, single entry point for browsing, cart operations, and order placement
Catalog item with name, price, description, and inventory count
User's mutable collection of items and quantities before checkout
Links a product reference to a desired quantity in the cart
Immutable snapshot created from cart at checkout, with shipping and payment details
Enum for order lifecycle: PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED
Tracks stock levels and handles reservation on order placement
Pricing modifier: percentage off, flat discount, coupon-based
Class Diagram
Where People Get This Wrong
Almost everyone designing an online shopping system makes the same mistake early on: they store full product objects inside the cart. It seems logical. The user added "Wireless Headphones at $79.99" to their cart, so store that. But products change. The price might drop to $69.99 during a flash sale. The description gets updated. If your cart holds a stale copy, the checkout page shows one price while the payment processor charges another. That is a support ticket factory.
The cart should hold product IDs and quantities. At display time, look up current product details. At checkout time, snapshot the current prices into the order. This way the cart always reflects live catalog data, and the order freezes what the customer actually agreed to pay.
The second common pitfall is inventory. Two users both add the last laptop stand to their carts. Both click checkout. Without inventory reservation at order placement, both orders succeed. One customer gets the item, the other gets a cancellation email. Reservation at checkout prevents this by locking stock atomically.
Requirements
Functional
- Browse a product catalog with real-time availability
- Add, remove, and update items in a shopping cart
- Apply discount codes (percentage off, flat discount) at checkout
- Place orders that create immutable snapshots of cart contents
- Order lifecycle: pending, confirmed, shipped, delivered, cancelled
- Inventory reservation on checkout to prevent overselling
- Inventory release on order cancellation
- Low-stock alerts when inventory drops below threshold
Non-Functional
- Thread-safe checkout for concurrent users competing for limited stock
- Discount strategies must be swappable without modifying order logic
- Inventory operations must be atomic (reserve/release/commit)
- Invalid order state transitions must be rejected
Design Decisions
Why do cart items reference products by ID?
Product prices and descriptions are mutable. A flash sale can drop prices. A product manager can update the description. If the cart holds a product object snapshot, the customer sees stale data at checkout. By referencing products by ID, every cart render fetches current product data. The customer always sees the latest price. When they check out, the order captures the price at that moment.
Why reserve inventory at order placement?
Between "click checkout" and "payment confirmed," stock could sell out. Reservation puts a hold on the requested quantity the moment checkout begins. If payment fails or the user abandons, the reservation times out or gets released. Without this, you oversell. With this, every confirmed order has guaranteed stock behind it.
Why is an order an immutable snapshot of the cart?
After checkout, the customer might add new items to their cart for their next purchase. If the order references the cart, it starts showing items that were never ordered. The order captures product IDs, names, prices, and quantities at checkout time. Once created, it never changes. The cart goes back to being an empty, mutable workspace.
Why separate InventoryManager from Product?
Products describe what something is. Inventory tracks how many you have. These change at different rates and for different reasons. Marketing updates product descriptions. Warehouses update stock counts. Merging them means a stock adjustment looks like a product edit. Splitting them keeps each concern clean and independently manageable. It also makes inventory locking straightforward since you only lock the InventoryManager, not the entire product catalog.
Interview Follow-ups
- "How would you handle wishlists?" Add a
Wishlistclass that stores product IDs per user. Moving from wishlist to cart is justaddToCartwith the same product ID. Wishlist does not reserve inventory. - "How would you support multiple payment methods?" Introduce a
PaymentStrategyinterface with implementations for credit card, digital wallet, and bank transfer. The checkout process calls the selected strategy. - "How would you handle returns?" Add a
ReturnRequestentity linked to an order. Approved returns triggerinventory.release()to put stock back and a refund through the payment service. - "How would you add product recommendations?" Create a
RecommendationServicethat analyzes order history and cart contents. It suggests products frequently bought together. Keep it decoupled from the shopping service.
Code Implementation
1 from __future__ import annotations
2 from abc import ABC, abstractmethod
3 from enum import Enum
4 from dataclasses import dataclass, field
5 from datetime import datetime
6 from threading import Lock
7 from typing import Protocol
8 import uuid
9
10
11 class OrderStatus(Enum):
12 PENDING = "pending"
13 CONFIRMED = "confirmed"
14 SHIPPED = "shipped"
15 DELIVERED = "delivered"
16 CANCELLED = "cancelled"
17
18
19 VALID_TRANSITIONS: dict[OrderStatus, set[OrderStatus]] = {
20 OrderStatus.PENDING: {OrderStatus.CONFIRMED, OrderStatus.CANCELLED},
21 OrderStatus.CONFIRMED: {OrderStatus.SHIPPED, OrderStatus.CANCELLED},
22 OrderStatus.SHIPPED: {OrderStatus.DELIVERED},
23 OrderStatus.DELIVERED: set(),
24 OrderStatus.CANCELLED: set(),
25 }
26
27
28 @dataclass
29 class Product:
30 id: str
31 name: str
32 price: float
33 description: str
34 category: str
35
36
37 class InventoryManager:
38 def __init__(self):
39 self._stock: dict[str, int] = {}
40 self._reserved: dict[str, int] = {}
41 self._lock = Lock()
42 self._observers: list["InventoryObserver"] = []
43 self._low_stock_threshold = 5
44
45 def add_observer(self, observer: "InventoryObserver") -> None:
46 self._observers.append(observer)
47
48 def set_stock(self, product_id: str, quantity: int) -> None:
49 with self._lock:
50 self._stock[product_id] = quantity
51 self._reserved.setdefault(product_id, 0)
52
53 def get_available(self, product_id: str) -> int:
54 with self._lock:
55 total = self._stock.get(product_id, 0)
56 reserved = self._reserved.get(product_id, 0)
57 return total - reserved
58
59 def reserve(self, product_id: str, quantity: int) -> bool:
60 with self._lock:
61 available = self._stock.get(product_id, 0) - self._reserved.get(product_id, 0)
62 if available < quantity:
63 return False
64 self._reserved[product_id] = self._reserved.get(product_id, 0) + quantity
65 remaining = self._stock[product_id] - self._reserved[product_id]
66 if remaining <= self._low_stock_threshold:
67 for obs in self._observers:
68 obs.on_low_stock(product_id, remaining)
69 return True
70
71 def release(self, product_id: str, quantity: int) -> None:
72 with self._lock:
73 self._reserved[product_id] = max(0, self._reserved.get(product_id, 0) - quantity)
74
75 def commit(self, product_id: str, quantity: int) -> None:
76 """Finalize a reservation by reducing actual stock."""
77 with self._lock:
78 self._stock[product_id] = max(0, self._stock.get(product_id, 0) - quantity)
79 self._reserved[product_id] = max(0, self._reserved.get(product_id, 0) - quantity)
80
81
82 class InventoryObserver(ABC):
83 @abstractmethod
84 def on_low_stock(self, product_id: str, remaining: int) -> None: ...
85
86
87 class OrderObserver(ABC):
88 @abstractmethod
89 def on_status_change(self, order_id: str, user_id: str,
90 old_status: OrderStatus, new_status: OrderStatus) -> None: ...
91
92
93 class ConsoleNotifier(InventoryObserver, OrderObserver):
94 def on_low_stock(self, product_id: str, remaining: int) -> None:
95 print(f" [ALERT] Low stock for product {product_id}: {remaining} remaining")
96
97 def on_status_change(self, order_id: str, user_id: str,
98 old_status: OrderStatus, new_status: OrderStatus) -> None:
99 print(f" [ORDER {order_id}] {old_status.value} -> {new_status.value} | user: {user_id}")
100
101
102 @dataclass
103 class CartItem:
104 product_id: str
105 quantity: int
106
107
108 class Cart:
109 def __init__(self, user_id: str):
110 self.user_id = user_id
111 self._items: dict[str, CartItem] = {}
112
113 def add_item(self, product_id: str, quantity: int) -> None:
114 if quantity <= 0:
115 raise ValueError("Quantity must be positive")
116 if product_id in self._items:
117 self._items[product_id].quantity += quantity
118 else:
119 self._items[product_id] = CartItem(product_id, quantity)
120
121 def remove_item(self, product_id: str) -> None:
122 if product_id not in self._items:
123 raise ValueError(f"Product {product_id} not in cart")
124 del self._items[product_id]
125
126 def update_quantity(self, product_id: str, quantity: int) -> None:
127 if product_id not in self._items:
128 raise ValueError(f"Product {product_id} not in cart")
129 if quantity <= 0:
130 self.remove_item(product_id)
131 else:
132 self._items[product_id].quantity = quantity
133
134 def get_items(self) -> list[CartItem]:
135 return list(self._items.values())
136
137 def is_empty(self) -> bool:
138 return len(self._items) == 0
139
140 def clear(self) -> None:
141 self._items.clear()
142
143
144 class DiscountStrategy(Protocol):
145 def apply(self, subtotal: float, code: str | None) -> float: ...
146
147
148 class NoDiscount:
149 def apply(self, subtotal: float, code: str | None) -> float:
150 return 0.0
151
152
153 class PercentageDiscount:
154 def __init__(self, valid_codes: dict[str, float]):
155 self._codes = valid_codes # code -> percentage (0-100)
156
157 def apply(self, subtotal: float, code: str | None) -> float:
158 if code and code in self._codes:
159 return round(subtotal * self._codes[code] / 100, 2)
160 return 0.0
161
162
163 class FlatDiscount:
164 def __init__(self, valid_codes: dict[str, float]):
165 self._codes = valid_codes # code -> flat amount
166
167 def apply(self, subtotal: float, code: str | None) -> float:
168 if code and code in self._codes:
169 return min(self._codes[code], subtotal)
170 return 0.0
171
172
173 @dataclass
174 class OrderLine:
175 product_id: str
176 product_name: str
177 unit_price: float
178 quantity: int
179
180 @property
181 def line_total(self) -> float:
182 return round(self.unit_price * self.quantity, 2)
183
184
185 class Order:
186 def __init__(self, order_id: str, user_id: str, lines: list[OrderLine],
187 subtotal: float, discount: float):
188 self.id = order_id
189 self.user_id = user_id
190 self.lines = list(lines)
191 self.subtotal = subtotal
192 self.discount = discount
193 self.total = round(subtotal - discount, 2)
194 self._status = OrderStatus.PENDING
195 self.created_at = datetime.now()
196 self._observers: list[OrderObserver] = []
197
198 @property
199 def status(self) -> OrderStatus:
200 return self._status
201
202 def add_observer(self, observer: OrderObserver) -> None:
203 self._observers.append(observer)
204
205 def transition_to(self, new_status: OrderStatus) -> None:
206 if new_status not in VALID_TRANSITIONS[self._status]:
207 raise ValueError(
208 f"Invalid transition from {self._status.value} to {new_status.value}")
209 old_status = self._status
210 self._status = new_status
211 for obs in self._observers:
212 obs.on_status_change(self.id, self.user_id, old_status, new_status)
213
214
215 class ShoppingService:
216 def __init__(self, discount_strategy: DiscountStrategy | None = None):
217 self._products: dict[str, Product] = {}
218 self._carts: dict[str, Cart] = {}
219 self._orders: dict[str, Order] = {}
220 self._inventory = InventoryManager()
221 self._discount = discount_strategy or NoDiscount()
222 self._notifier = ConsoleNotifier()
223 self._inventory.add_observer(self._notifier)
224 self._lock = Lock()
225
226 def add_product(self, product: Product, stock: int) -> None:
227 self._products[product.id] = product
228 self._inventory.set_stock(product.id, stock)
229
230 def browse_products(self) -> list[tuple[Product, int]]:
231 return [(p, self._inventory.get_available(p.id))
232 for p in self._products.values()
233 if self._inventory.get_available(p.id) > 0]
234
235 def get_cart(self, user_id: str) -> Cart:
236 if user_id not in self._carts:
237 self._carts[user_id] = Cart(user_id)
238 return self._carts[user_id]
239
240 def add_to_cart(self, user_id: str, product_id: str, quantity: int) -> Cart:
241 if product_id not in self._products:
242 raise ValueError(f"Product {product_id} not found")
243 cart = self.get_cart(user_id)
244 cart.add_item(product_id, quantity)
245 return cart
246
247 def remove_from_cart(self, user_id: str, product_id: str) -> Cart:
248 cart = self.get_cart(user_id)
249 cart.remove_item(product_id)
250 return cart
251
252 def checkout(self, user_id: str, coupon_code: str | None = None) -> Order:
253 with self._lock:
254 cart = self.get_cart(user_id)
255 if cart.is_empty():
256 raise ValueError("Cart is empty")
257
258 order_lines: list[OrderLine] = []
259 reserved_items: list[tuple[str, int]] = []
260
261 try:
262 for cart_item in cart.get_items():
263 product = self._products.get(cart_item.product_id)
264 if product is None:
265 raise ValueError(f"Product {cart_item.product_id} no longer exists")
266
267 if not self._inventory.reserve(cart_item.product_id, cart_item.quantity):
268 raise ValueError(
269 f"Insufficient stock for '{product.name}'. "
270 f"Available: {self._inventory.get_available(cart_item.product_id)}")
271 reserved_items.append((cart_item.product_id, cart_item.quantity))
272
273 order_lines.append(OrderLine(
274 product_id=product.id,
275 product_name=product.name,
276 unit_price=product.price,
277 quantity=cart_item.quantity
278 ))
279
280 except ValueError:
281 for pid, qty in reserved_items:
282 self._inventory.release(pid, qty)
283 raise
284
285 subtotal = sum(line.line_total for line in order_lines)
286 discount = self._discount.apply(subtotal, coupon_code)
287 order_id = str(uuid.uuid4())[:8]
288 order = Order(order_id, user_id, order_lines, subtotal, discount)
289 order.add_observer(self._notifier)
290 self._orders[order_id] = order
291 cart.clear()
292 return order
293
294 def confirm_order(self, order_id: str) -> None:
295 order = self._get_order(order_id)
296 order.transition_to(OrderStatus.CONFIRMED)
297 for line in order.lines:
298 self._inventory.commit(line.product_id, line.quantity)
299
300 def ship_order(self, order_id: str) -> None:
301 order = self._get_order(order_id)
302 order.transition_to(OrderStatus.SHIPPED)
303
304 def deliver_order(self, order_id: str) -> None:
305 order = self._get_order(order_id)
306 order.transition_to(OrderStatus.DELIVERED)
307
308 def cancel_order(self, order_id: str) -> None:
309 with self._lock:
310 order = self._get_order(order_id)
311 order.transition_to(OrderStatus.CANCELLED)
312 for line in order.lines:
313 self._inventory.release(line.product_id, line.quantity)
314
315 def _get_order(self, order_id: str) -> Order:
316 order = self._orders.get(order_id)
317 if not order:
318 raise ValueError(f"Order {order_id} not found")
319 return order
320
321
322 if __name__ == "__main__":
323 discount = PercentageDiscount({"SAVE10": 10, "SAVE20": 20})
324 service = ShoppingService(discount_strategy=discount)
325
326 service.add_product(Product("p1", "Wireless Headphones", 79.99, "Noise-cancelling over-ear", "Electronics"), 15)
327 service.add_product(Product("p2", "USB-C Cable", 12.99, "2m braided cable", "Accessories"), 50)
328 service.add_product(Product("p3", "Laptop Stand", 45.99, "Adjustable aluminum stand", "Accessories"), 3)
329 service.add_product(Product("p4", "Mechanical Keyboard", 129.99, "Cherry MX Blue switches", "Electronics"), 8)
330
331 print("=== Browsing products ===")
332 for product, stock in service.browse_products():
333 print(f" {product.name}: ${product.price:.2f} ({stock} in stock)")
334
335 print("\n=== Building cart ===")
336 user_id = "user-alice"
337 service.add_to_cart(user_id, "p1", 1)
338 service.add_to_cart(user_id, "p2", 3)
339 service.add_to_cart(user_id, "p3", 1)
340 cart = service.get_cart(user_id)
341 for item in cart.get_items():
342 product = service._products[item.product_id]
343 print(f" {product.name} x{item.quantity} = ${product.price * item.quantity:.2f}")
344
345 print("\n=== Checkout with 10% discount ===")
346 order = service.checkout(user_id, coupon_code="SAVE10")
347 print(f" Order {order.id}")
348 for line in order.lines:
349 print(f" {line.product_name} x{line.quantity} = ${line.line_total:.2f}")
350 print(f" Subtotal: ${order.subtotal:.2f}")
351 print(f" Discount: -${order.discount:.2f}")
352 print(f" Total: ${order.total:.2f}")
353
354 print("\n=== Confirming order ===")
355 service.confirm_order(order.id)
356
357 print("\n=== Shipping order ===")
358 service.ship_order(order.id)
359
360 print("\n=== Delivering order ===")
361 service.deliver_order(order.id)
362
363 print("\n=== Checking inventory after order ===")
364 for product, stock in service.browse_products():
365 print(f" {product.name}: {stock} in stock")
366
367 print("\n=== Testing oversell protection ===")
368 user2 = "user-bob"
369 service.add_to_cart(user2, "p3", 5)
370 try:
371 service.checkout(user2)
372 except ValueError as e:
373 print(f" Correctly rejected: {e}")
374
375 print("\n=== Testing order cancellation with inventory release ===")
376 user3 = "user-charlie"
377 service.add_to_cart(user3, "p4", 2)
378 order2 = service.checkout(user3)
379 print(f" Placed order {order2.id} for 2 keyboards")
380 print(f" Keyboard stock before cancel: {service._inventory.get_available('p4')}")
381 service.cancel_order(order2.id)
382 print(f" Keyboard stock after cancel: {service._inventory.get_available('p4')}")Common Mistakes
- ✗Storing product objects directly in the cart. If the price updates, the cart shows stale data.
- ✗Not reserving inventory at checkout. Two users checking out the last item both succeed and one gets nothing.
- ✗Making Order mutable after creation. Orders should be a frozen record of what the customer agreed to.
- ✗Hardcoding discount logic. Promotions change weekly. Strategy pattern keeps pricing rules swappable.
Key Points
- ✓Cart items reference products by ID, not by object, so price changes reflect at checkout time
- ✓Inventory reservation happens at order placement to prevent overselling across concurrent checkouts
- ✓Order is an immutable snapshot of the cart. Cart keeps changing after checkout, but the order does not.
- ✓Observer pattern sends low-stock alerts and order status notifications without coupling to inventory logic