Design an Online Shopping Cart

Topics Covered

Requirements and Use Cases

Cart Item and Pricing Model

Discount and Promotion Engine

Checkout Workflow

Practice

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.

Inventory reservation with timeout

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.

Key Insight

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.

Cart total calculation flow

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.

python
1class Product:
2    def __init__(self, product_id: str, name: str, price_cents: int,
3                 category: str, available_quantity: int):
4        self.product_id = product_id
5        self.name = name
6        self.price_cents = price_cents  # always in cents
7        self.category = category
8        self.available_quantity = available_quantity

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."

python
1class CartItem:
2    def __init__(self, product: Product, quantity: int):
3        self.product = product
4        self.quantity = quantity
5        self.price_at_addition = product.price_cents  # snapshot
6
7    def line_total(self) -> int:
8        return self.price_at_addition * self.quantity

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.

Common Pitfall

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.

python
1class ShoppingCart:
2    def __init__(self):
3        self._items: dict[str, CartItem] = {}  # keyed by product_id
4
5    def add_item(self, product: Product, quantity: int = 1):
6        if product.product_id in self._items:
7            self._items[product.product_id].quantity += quantity
8        else:
9            self._items[product.product_id] = CartItem(product, quantity)
10
11    def remove_item(self, product_id: str):
12        self._items.pop(product_id, None)
13
14    def update_quantity(self, product_id: str, new_quantity: int):
15        if new_quantity <= 0:
16            self.remove_item(product_id)
17        elif product_id in self._items:
18            self._items[product_id].quantity = new_quantity
19
20    def clear(self):
21        self._items.clear()
22
23    def get_subtotal(self) -> int:
24        return sum(item.line_total() for item in self._items.values())
25
26    def get_items(self) -> list[CartItem]:
27        return list(self._items.values())

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.

python
1class TaxStrategy:
2    def calculate_tax(self, subtotal: int, items: list[CartItem]) -> int:
3        raise NotImplementedError
4
5class FlatRateTax(TaxStrategy):
6    def __init__(self, rate: float):
7        self._rate = rate
8
9    def calculate_tax(self, subtotal: int, items: list[CartItem]) -> int:
10        return round(subtotal * self._rate)
11
12class CategoryExemptTax(TaxStrategy):
13    def __init__(self, rate: float, exempt_categories: set[str]):
14        self._rate = rate
15        self._exempt = exempt_categories
16
17    def calculate_tax(self, subtotal: int, items: list[CartItem]) -> int:
18        taxable = sum(
19            item.line_total() for item in items
20            if item.product.category not in self._exempt
21        )
22        return round(taxable * self._rate)

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).

Discount chain application

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.

python
1class DiscountHandler:
2    def __init__(self):
3        self._next_handler: DiscountHandler = None
4
5    def set_next(self, handler: 'DiscountHandler') -> 'DiscountHandler':
6        self._next_handler = handler
7        return handler
8
9    def handle(self, cart: ShoppingCart, context: dict) -> int:
10        """Returns total discount amount in cents."""
11        discount = self._calculate(cart, context)
12        if self._next_handler:
13            discount += self._next_handler.handle(cart, context)
14        return discount
15
16    def _calculate(self, cart: ShoppingCart, context: dict) -> int:
17        raise NotImplementedError
18
19class BulkDiscountHandler(DiscountHandler):
20    def __init__(self, product_id: str, threshold: int,
21                 discounted_price: int):
22        super().__init__()
23        self._product_id = product_id
24        self._threshold = threshold
25        self._discounted_price = discounted_price
26
27    def _calculate(self, cart: ShoppingCart, context: dict) -> int:
28        for item in cart.get_items():
29            if (item.product.product_id == self._product_id
30                    and item.quantity >= self._threshold):
31                savings_per_unit = item.price_at_addition - self._discounted_price
32                return savings_per_unit * item.quantity
33        return 0
34
35class CouponHandler(DiscountHandler):
36    def __init__(self, coupon: 'Coupon'):
37        super().__init__()
38        self._coupon = coupon
39
40    def _calculate(self, cart: ShoppingCart, context: dict) -> int:
41        if not self._coupon.is_valid(cart, context):
42            return 0
43        return self._coupon.calculate_discount(cart)
44
45class LoyaltyHandler(DiscountHandler):
46    def __init__(self, tier_discounts: dict[str, float]):
47        super().__init__()
48        self._tier_discounts = tier_discounts
49
50    def _calculate(self, cart: ShoppingCart, context: dict) -> int:
51        tier = context.get("loyalty_tier", "none")
52        rate = self._tier_discounts.get(tier, 0.0)
53        return round(cart.get_subtotal() * rate)

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.

python
1bulk = BulkDiscountHandler("SHIRT-001", 10, 400)
2coupon = CouponHandler(summer_coupon)
3loyalty = LoyaltyHandler({"gold": 0.05, "silver": 0.03})
4
5bulk.set_next(coupon)
6coupon.set_next(loyalty)
7
8total_discount = bulk.handle(cart, {"loyalty_tier": "gold"})
Interview Tip

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.

python
1class Coupon:
2    def __init__(self, code: str, discount_type: str,
3                 discount_value: int, expiry_date,
4                 min_purchase: int = 0, max_uses: int = -1):
5        self.code = code
6        self.discount_type = discount_type  # "percentage" or "fixed"
7        self.discount_value = discount_value
8        self.expiry_date = expiry_date
9        self.min_purchase = min_purchase
10        self.max_uses = max_uses
11        self.times_used = 0
12
13    def is_valid(self, cart: ShoppingCart, context: dict) -> bool:
14        if context.get("current_date") > self.expiry_date:
15            return False
16        if cart.get_subtotal() < self.min_purchase:
17            return False
18        if 0 <= self.max_uses <= self.times_used:
19            return False
20        return True
21
22    def calculate_discount(self, cart: ShoppingCart) -> int:
23        if self.discount_type == "percentage":
24            return round(cart.get_subtotal() * self.discount_value / 100)
25        return min(self.discount_value, cart.get_subtotal())

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.

Checkout command sequence

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.

python
1class CheckoutCommand:
2    def execute(self, context: dict) -> bool:
3        raise NotImplementedError
4
5    def undo(self, context: dict):
6        raise NotImplementedError
7
8class ValidateCartCommand(CheckoutCommand):
9    def execute(self, context: dict) -> bool:
10        cart = context["cart"]
11        if not cart.get_items():
12            context["error"] = "Cart is empty"
13            return False
14        return True
15
16    def undo(self, context: dict):
17        pass  # validation has no side effects to reverse
18
19class ReserveInventoryCommand(CheckoutCommand):
20    def __init__(self, inventory_manager: 'InventoryManager'):
21        self._inventory = inventory_manager
22        self._reserved_items = []
23
24    def execute(self, context: dict) -> bool:
25        cart = context["cart"]
26        for item in cart.get_items():
27            success = self._inventory.reserve(
28                item.product.product_id, item.quantity
29            )
30            if not success:
31                context["error"] = (
32                    f"{item.product.name} is no longer available"
33                )
34                self.undo(context)  # release what we reserved so far
35                return False
36            self._reserved_items.append(
37                (item.product.product_id, item.quantity)
38            )
39        return True
40
41    def undo(self, context: dict):
42        for product_id, quantity in self._reserved_items:
43            self._inventory.release(product_id, quantity)
44        self._reserved_items.clear()
45
46class ProcessPaymentCommand(CheckoutCommand):
47    def __init__(self, payment_gateway: 'PaymentGateway'):
48        self._gateway = payment_gateway
49        self._transaction_id = None
50
51    def execute(self, context: dict) -> bool:
52        amount = context["final_total"]
53        result = self._gateway.charge(amount, context["payment_info"])
54        if result.success:
55            self._transaction_id = result.transaction_id
56            context["transaction_id"] = result.transaction_id
57            return True
58        context["error"] = "Payment failed"
59        return False
60
61    def undo(self, context: dict):
62        if self._transaction_id:
63            self._gateway.refund(self._transaction_id)
64            self._transaction_id = None

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.

python
1class CheckoutProcess:
2    def __init__(self):
3        self._commands: list[CheckoutCommand] = []
4        self._executed: list[CheckoutCommand] = []
5
6    def add_step(self, command: CheckoutCommand):
7        self._commands.append(command)
8
9    def run(self, context: dict) -> bool:
10        for command in self._commands:
11            if command.execute(context):
12                self._executed.append(command)
13            else:
14                self._rollback(context)
15                return False
16        return True
17
18    def _rollback(self, context: dict):
19        for command in reversed(self._executed):
20            command.undo(context)
21        self._executed.clear()

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.

python
1def create_checkout(cart, inventory, gateway):
2    process = CheckoutProcess()
3    process.add_step(ValidateCartCommand())
4    process.add_step(ReserveInventoryCommand(inventory))
5    process.add_step(ProcessPaymentCommand(gateway))
6    return process
7
8# Usage
9context = {"cart": cart, "payment_info": payment, "final_total": total}
10success = create_checkout(cart, inventory, gateway).run(context)

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.

python
1class InventoryManager:
2    def __init__(self):
3        self._stock: dict[str, int] = {}
4        self._reservations: list[dict] = []
5        self._observers: list['InventoryObserver'] = []
6
7    def reserve(self, product_id: str, quantity: int) -> bool:
8        available = self._stock.get(product_id, 0)
9        if available < quantity:
10            return False
11        self._stock[product_id] = available - quantity
12        self._reservations.append({
13            "product_id": product_id,
14            "quantity": quantity,
15            "expires_at": time.time() + 900  # 15 minutes
16        })
17        self._notify_observers(product_id)
18        return True
19
20    def release(self, product_id: str, quantity: int):
21        self._stock[product_id] = self._stock.get(product_id, 0) + quantity
22        self._notify_observers(product_id)
23
24    def release_expired(self):
25        now = time.time()
26        expired = [r for r in self._reservations if r["expires_at"] < now]
27        for reservation in expired:
28            self.release(reservation["product_id"],
29                         reservation["quantity"])
30            self._reservations.remove(reservation)
31
32    def add_observer(self, observer: 'InventoryObserver'):
33        self._observers.append(observer)
34
35    def _notify_observers(self, product_id: str):
36        for observer in self._observers:
37            observer.on_inventory_changed(product_id,
38                                           self._stock.get(product_id, 0))

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.

python
1class InventoryObserver:
2    def on_inventory_changed(self, product_id: str, new_quantity: int):
3        raise NotImplementedError
4
5class LowStockAlert(InventoryObserver):
6    def __init__(self, threshold: int = 5):
7        self._threshold = threshold
8
9    def on_inventory_changed(self, product_id: str, new_quantity: int):
10        if new_quantity <= self._threshold:
11            print(f"Low stock alert: {product_id} has {new_quantity} left")
12
13class OutOfStockBadge(InventoryObserver):
14    def on_inventory_changed(self, product_id: str, new_quantity: int):
15        if new_quantity == 0:
16            print(f"Marking {product_id} as out of stock")

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.

python
1class Order:
2    def __init__(self, order_id: str, items: list[dict],
3                 subtotal: int, discount: int, tax: int,
4                 total: int, transaction_id: str):
5        self.order_id = order_id
6        self.items = tuple(items)  # immutable snapshot
7        self.subtotal = subtotal
8        self.discount = discount
9        self.tax = tax
10        self.total = total
11        self.transaction_id = transaction_id
12        self.status = "PENDING"
13
14    def confirm(self):
15        if self.status != "PENDING":
16            raise ValueError("Can only confirm pending orders")
17        self.status = "CONFIRMED"
18
19    def cancel(self):
20        if self.status in ("SHIPPED", "DELIVERED"):
21            raise ValueError("Cannot cancel shipped or delivered orders")
22        self.status = "CANCELLED"

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 editor...

Loading problem...

Loading editor...

Loading problem...

Loading editor...