OOD Fundamentals
OOP Foundations
SOLID Principles
Creational Patterns
Structural Patterns
Behavioral Patterns
Classic OOD Problems: Part 1
Classic OOD Problems: Part 2
Design an Online Shopping Cart
A shopping cart looks deceptively simple: add items, show a total, check out. But once you account for discounts that stack, inventory that fluctuates, prices that change after items are added, and checkout steps that can fail halfway through, the design space expands dramatically. Understanding the requirements upfront prevents you from building a cart that works for the happy path but collapses under real-world complexity.

Start with the core use cases. A customer browses a product catalog, adds items to a cart, adjusts quantities, removes items they changed their mind about, and applies a coupon code. The system calculates a subtotal, applies applicable discounts, adds tax, and shows a final total. When the customer clicks checkout, the system reserves inventory, processes payment, and creates an immutable order. If any step fails, the system rolls back gracefully so items are not stuck in limbo.
These use cases map directly to core entities. A Product represents something in the catalog with a name, base price, category, and available quantity. A CartItem pairs a product with a quantity and captures the price at the moment of addition. The ShoppingCart holds a collection of cart items and orchestrates operations like add, remove, update quantity, and clear. A PricingRule encapsulates tax or discount logic. A Coupon is a specific type of discount with validation rules like expiry date and minimum purchase. A Discount is the calculated reduction applied to a cart. The Order is the finalized, immutable snapshot created after successful checkout. The CheckoutProcess coordinates the multi-step checkout workflow. The InventoryManager tracks stock levels and handles reservations.
The most important distinction in this design is cart versus order. A cart is mutable: it is a work in progress that changes as the customer shops. An order is immutable: it is a finalized record of what was purchased, at what price, with what discounts. This separation matters because the cart reflects the customer's current intent while the order reflects a completed transaction. Mixing the two leads to bugs where changing your cart retroactively modifies a completed purchase.
Each entity maps to one or more design patterns. The ShoppingCart uses a dictionary internally for O(1) item lookups. Tax calculation uses the Strategy pattern because tax rules vary by jurisdiction and product category. The discount engine uses Chain of Responsibility to apply multiple discount types in sequence. Checkout uses the Command pattern so each step can be reversed on failure. And the InventoryManager uses the Observer pattern to notify interested components when stock levels change. Understanding which pattern solves which problem is the core skill this lesson develops.
In real e-commerce systems, the discount and promotion engine often accounts for more code than the cart itself. Handling stackable discounts, coupon validation, tiered pricing, and buy-one-get-one rules is where most of the complexity lives. Design the discount system as a separate, extensible module from the start.
Non-functional requirements shape the design too. The cart must handle concurrent access: a customer might have the same cart open on their phone and laptop. Inventory checks must be atomic to prevent overselling during flash sales. Prices displayed in the cart should remain stable during a shopping session to avoid confusing the customer. And the checkout process must be resilient: a network failure during payment should not result in a charged card with no order record.
What we intentionally defer: payment gateway integration (we model it as an interface), shipping cost calculation (varies by provider and destination), and user authentication (orthogonal to cart logic). These are important in production but do not change the core object-oriented design of the cart, discount engine, or checkout workflow. By deferring them behind interfaces, we can design and test the entire cart system without depending on any external service.
The cart item and pricing model form the foundation everything else builds on. Getting these classes right determines whether the discount engine, tax calculation, and checkout workflow can be clean or must work around awkward data structures.

A Product carries catalog information: an id, name, base price, category, and available quantity. It represents a row in the product catalog and does not know about any specific customer's cart.
A CartItem wraps a Product with cart-specific state: the quantity the customer selected and the price captured at the moment of addition. This separation is critical. The product catalog is shared across all users and can change at any time: prices update, items go on sale, stock fluctuates. The CartItem snapshots the price so the customer pays what they saw when they clicked "Add to Cart."
Why return cents as integers rather than dollars as floats? Because floating-point arithmetic creates rounding errors in financial calculations. The expression 0.1 + 0.2 evaluates to 0.30000000000000004 in most languages. Over thousands of transactions, these tiny errors compound into real accounting discrepancies. Storing prices in cents (integer arithmetic) eliminates this entirely. A $29.99 item is stored as 2999 cents. Only convert to dollars for display.
Never use floating-point types for money. Store all monetary values as integers representing the smallest currency unit (cents for USD, pence for GBP). A one-cent rounding error repeated across 100,000 daily transactions creates a $1,000 daily discrepancy that is almost impossible to trace.
The ShoppingCart manages a collection of CartItems and provides the core operations. When adding an item that already exists in the cart, the cart merges quantities rather than creating a duplicate entry. This merge-on-add behavior keeps the cart clean and makes quantity updates predictable.
The subtotal is the sum of all line totals before any discounts or taxes. This is the raw "what did the customer select?" number. Discounts and taxes are computed separately and layered on top, which keeps each concern in its own class.
Tax calculation uses the Strategy pattern because tax rules vary dramatically by jurisdiction. A US state-based tax rule applies a percentage based on the shipping state. A category-exempt rule skips tax on groceries. A European VAT rule applies different rates to different product categories. Rather than cramming all these rules into the cart, each rule is encapsulated in a TaxStrategy that the cart delegates to.
The Strategy pattern means adding a new tax jurisdiction requires writing one new class: no changes to ShoppingCart, CartItem, or any existing tax strategy. The cart simply holds a reference to whichever TaxStrategy applies to the current customer.
One subtle design decision is what happens when a customer updates a cart item's quantity. Should update_quantity modify the existing CartItem or replace it? Modifying in place is simpler, but replacing has an advantage: you can re-snapshot the price if the business chooses live pricing for quantity changes. The current design modifies in place, keeping the original snapshot price, which means a customer who added an item at $20 and later increases quantity from 1 to 5 still pays $20 per unit even if the catalog price is now $25. This is the safer default because it matches customer expectations.
The discount engine is where a shopping cart design goes from straightforward to genuinely challenging. Real e-commerce systems support multiple discount types that interact in non-obvious ways, and the order in which discounts are applied can change the final total significantly. Designing this system well means making it extensible (new discount types without modifying existing code) and predictable (the customer always understands why they got a specific total).

Start by categorizing the discount types. A percentage discount reduces the price by a fraction: 20 percent off your entire order. A fixed amount discount subtracts a flat value: $10 off orders over $50. A buy-N-get-M-free discount gives free items when a quantity threshold is met: buy 2 shirts, get 1 free. A bulk quantity discount reduces per-unit price at volume: $5 each for 1-9 units, $4 each for 10 or more. A loyalty tier discount applies based on the customer's membership level: Gold members get 5 percent off everything.
Each discount type has different inputs (cart total, item quantity, customer tier) and different calculation logic. The Chain of Responsibility pattern lets you process these discount types in sequence without hardcoding the order or coupling discount types to each other.
The chain is assembled by linking handlers: bulk discounts first, then coupons, then loyalty. Each handler calculates its own discount independently and passes control to the next handler. The total discount is the sum of all handler results.
Always apply discounts in a fixed, documented order. If bulk discounts run before coupons, a customer gets bulk savings first, then the coupon applies to the already-reduced subtotal. Reversing the order changes the final total. Documenting and enforcing the order eliminates 'why is my discount different?' support tickets.
Stacking rules add another layer. Some discounts stack: a bulk discount and a loyalty discount can both apply. Some discounts are exclusive: you cannot combine two coupon codes. The stacking policy should be explicit in the handler chain. One approach is to have each handler check a "discount_applied" flag in the context dictionary. If a non-stackable coupon has already been applied, subsequent coupon handlers skip their calculation.
Here is a concrete example. A Gold loyalty member has 12 shirts (qualifying for the bulk discount) and enters coupon code "SUMMER20" for 20 percent off. The bulk handler runs first and calculates the per-unit savings. The coupon handler runs next: it checks the stacking rules, confirms that bulk and coupon can stack, validates the coupon (not expired, minimum met), and calculates 20 percent off the post-bulk subtotal. The loyalty handler runs last and checks if loyalty stacks with the already-applied discounts. If the business rule says "loyalty does not stack with coupons," the loyalty handler reads a flag in the context set by the coupon handler and returns 0. All of this logic lives in the individual handlers: the chain orchestrator knows nothing about stacking rules.
Coupon validation deserves its own class because the validation rules are complex and change frequently. A coupon has an expiry date, a minimum purchase requirement, a maximum number of uses globally, and a per-user use limit. Each of these is a separate validation check, and new rules (restricted to certain product categories, only valid on weekdays) get added regularly.
The calculate_discount method includes a critical guard: a fixed discount cannot exceed the subtotal. Without this floor, a $50 coupon applied to a $30 cart would produce a negative total of -$20: effectively paying the customer to shop. The same principle applies to the total discount from the chain: after summing all handler results, clamp the total so the final price never drops below zero.
Why does the order of handlers matter? Consider a $100 cart with a bulk discount of $10 and a 20 percent coupon. If the bulk discount runs first, the effective subtotal for the coupon is $90, yielding an $18 coupon discount for a total of $28 off. If the coupon runs first, 20 percent of $100 is $20, then the bulk discount adds $10, for a total of $30 off. The two-dollar difference exists because percentages compound differently depending on the base they apply to. A documented, fixed order eliminates customer confusion and support escalations.
Testing the discount engine is straightforward because each handler is independent. You can unit-test the BulkDiscountHandler with a cart containing the target product at various quantities without worrying about coupon or loyalty logic. Integration tests assemble the full chain and verify the total discount. Edge cases to test include: empty cart (all handlers should return 0), cart below coupon minimum (coupon handler returns 0 but others still apply), expired coupon (validation rejects it), and multiple discounts that together exceed the subtotal (total must clamp to subtotal).
Checkout is where all the pieces come together: and where the most things can go wrong. A customer clicks "Place Order" and expects a single, seamless action. Behind the scenes, checkout is a multi-step process where each step can fail independently: validate the cart, reserve inventory, calculate the final total with discounts and tax, process payment, and create the order. If payment fails after inventory is reserved, those items must be released. If inventory reservation fails, the customer must be told which items are unavailable. Designing this as a Command sequence with compensating actions makes failures recoverable rather than catastrophic.

Each checkout step is a Command object with execute and undo methods. The CheckoutProcess runs commands in sequence. If any command fails, it calls undo on all previously executed commands in reverse order: this is the saga pattern adapted for object-oriented design.
The CheckoutProcess orchestrates these commands. It runs each one in order and tracks which commands have been executed so it knows exactly which undo methods to call on failure.
Assembling the checkout pipeline is straightforward. Each step is a command, and the process runs them in order. This assembly is typically done by a factory or builder that configures the pipeline based on the context: a guest checkout might skip the loyalty discount step, while a returning customer includes it.
Inventory reservation introduces a time dimension. When a customer starts checkout, items are reserved: removed from available stock so another customer cannot buy them simultaneously. But what if the customer abandons checkout? Those items would be locked forever. The solution is a reservation timeout: items are held for a limited period (typically 10 to 15 minutes), after which the reservation expires and items return to available stock.
The Observer pattern connects inventory changes to interested parties. When stock levels change, whether from a reservation, a release, or a restock, all registered observers are notified. The cart page can update "Only 3 left!" warnings. The product listing can show "Out of Stock" badges. An admin dashboard can trigger reorder alerts. None of these consumers need to poll the inventory: they react to push notifications.
Each observer reacts to the same event differently. The LowStockAlert triggers when stock drops below a threshold. The OutOfStockBadge triggers at exactly zero. Adding a new observer, say, an automatic reorder trigger, requires creating one class and registering it with the InventoryManager. No existing code changes.
After successful payment, the checkout creates an immutable Order that snapshots the cart's state. The order captures every item, quantity, price, applied discounts, tax, and final total at the moment of purchase. Even if the product catalog changes the next second, the order record remains accurate.
The order follows a state machine: PENDING (just created) to CONFIRMED (payment verified) to SHIPPED to DELIVERED. At any point before shipping, the order can transition to CANCELLED, which triggers compensating actions: refund the payment and release the inventory reservation.
Notice that the Order stores items as a tuple, not a list. This is a deliberate immutability signal: tuples cannot be modified after creation, so the order's item list is frozen at checkout time. The subtotal, discount, tax, and total are all captured as final values rather than being recalculated. This means the order is a complete, self-contained record that does not depend on the current state of the product catalog, discount engine, or tax rules.
Practice
Build a complete shopping cart system with discounts and inventory management.
Loading problem...
Loading problem...
Loading problem...