Every online store has one. Amazon, Flipkart, Shopify — they all start with the same deceptively simple feature: a shopping cart. Add items, see the total, checkout. Takes maybe 20 lines of code. But what happens when you need discount strategies — percentage off, buy-one-get-one, coupon codes? When products can be bundled — a laptop + mouse + bag as a single "Back to School" combo, and that combo can be inside another combo? When the cart needs to notify the UI when prices change, stock runs out, or a coupon expires? That 20-line cart becomes a real design problem with 3 patterns hiding inside it.
We're going to build this cart 7 times — each time adding ONE constraintA real-world requirement that forces your code to evolve. Each constraint is something the BUSINESS says, not a technical exercise. "The store needs bundle pricing" is a constraint. "Use the Composite pattern" is not — that's a solution you DISCOVER. that breaks your previous code. You'll feel the pain, discover the fix, and understand WHY the design exists. By Level 7, you'll have a production-grade e-commerce cart — and a set of reusable thinking tools that work for any system.
The Constraint Game — 7 Levels
L0: Add to Cart
L1: Product Types
L2: Discounts
L3: Bundles
L4: Live Updates
L5: Edge Cases
L6: Testability
L7: Scale It
The System Grows — Level by Level
Each level adds one constraint. Here's a thumbnail of how the class diagram expands from 2 types to a full e-commerce engine:
L0
Product CartItem ShoppingCart
3 types
L1
IProduct Physical Digital Subscription
6 types
L2
IDiscountStrategy Percentage Fixed BOGO
10 types
L3
ICartEntry SingleItem Bundle
13 types
L4
ICartObserver CartEvent UIUpdater
17 types
L5-6
Result<T> ICartService IProductRepo
21 types
L7
CartManager DistributedCache EventBus
25 types
Difficulty Curve
What You'll Build
System
Production-grade shopping cart with polymorphic productsProducts that share a common interface (IProduct) but differ in behavior. Physical products need shipping weight, digital products need a download URL, subscriptions need a billing cycle. Same interface, different implementations., discount strategiesMultiple discount algorithms (percentage off, fixed amount, BOGO) that all plug into the same slot. The cart doesn't care which discount you apply — it calls CalculateDiscount() and gets a number back. That's the Strategy pattern., nested bundles, live notifications, error handling, and DI.
Patterns
StrategyDefines a family of interchangeable algorithms. Each discount type (percentage, fixed, BOGO) is its own class with a shared CalculateDiscount() method. The cart delegates to whichever strategy is active — swappable at runtime., CompositeTreats single items and groups of items uniformly. A Bundle is a collection of cart entries — but a bundle IS ALSO a cart entry. So a "Back to School" bundle can contain another "Accessories" bundle. Same GetPrice() call, tree of any depth., ObserverWhen the cart changes (item added, price updated, stock depleted), every registered listener gets notified automatically. The UI refreshes, stock checks run, analytics fire — without the cart knowing any of them exist., Result<T>A functional error-handling pattern that returns either a success value or an error message. Instead of throwing exceptions for business logic (out of stock, invalid coupon), the cart returns Result.Fail("message") — making error paths explicit.
Skills
Real-world walkthrough, "What Varies?" thinkingThe most powerful question in LLD: "What part of this operation might change independently?" If the answer is "multiple algorithms for the same job," you've found a Strategy. If it's "single vs. group treated the same," you've found a Composite., polymorphic product modeling, nested tree structures, event-driven updates, CREATESThe 7-step interview framework: Clarify → Requirements → Entities → API → Trade-offs → Edge cases → Scale. Works for every LLD problem you'll ever face.
Before we write a single line of code, let's walk through a real online shopping experience. Not on a whiteboard — in your head. Imagine you're on Amazon or Flipkart. Watch what happens at every step, and notice every thing (noun) and every action (verb). These become your classes, methods, and data types — for free.
Think First #1 — Walk through an online shopping experience from browsing to order confirmation. List every THING you interact with and every ACTION that happens. Don't peek below for 60 seconds.
Things you probably found: Product, Cart, CartItem, Quantity, Price, Discount, Coupon, Bundle, Order, Payment, Address, Tax.
Actions: Browse products, Add to cart, Change quantity, Remove item, Apply coupon, View total, Choose payment, Place order, Confirm.
Every noun is a candidate entityIn LLD, an entity is a real-world concept that becomes a class, record, or enum in your code. Products, carts, and orders are entities because they hold data and have identity.. Every verb is a candidate method. This is noun extractionA systematic technique for finding entities: read the problem description, highlight every noun, and evaluate which ones need to exist in your code. Not every noun becomes a class — some become fields, some become enums, some are irrelevant. — the senior engineer's shortcut to a starter class diagram.
Stage 1: Browse & Add
What you SEE: Product pages with a name, price, images, and an "Add to Cart" button. A quantity selector next to each product. Sometimes a "Ships in 2 days" badge or "Instant download" label.
What happens behind the scenes: Each product has an ID, name, price, and a type (physical, digital, subscription). Physical products carry a weight for shipping. Digital ones have a download URL. Subscriptions have a billing cycle.
Design insight: We already found our first entity: Product. But not all products are the same. A physical product needs shipping weight, a digital product needs a download link. One flat class can't cleanly hold all variants. This is our first hint that products will need polymorphismWhen different types share the same interface but behave differently. A PhysicalProduct and DigitalProduct both have a Name and Price, but their tax rules and delivery mechanisms are completely different. You interact with them the same way but they do different things internally. — we'll tackle it in Level 1.
Stage 2: View Cart
What you SEE: A list of items you've added, each showing the product name, unit price, quantity, and a line subtotalThe price for one line item: unit price multiplied by quantity. If a laptop costs $999 and you added 2, the subtotal is $1,998. The cart total is the sum of all subtotals minus any discounts. (price × quantity). Plus/minus buttons to change quantities. A running total at the bottom.
What happens behind the scenes: The cart holds a collection of CartItems. Each CartItem wraps a Product reference plus a quantity. The cart's total is the sum of all line subtotals. Adding the same product twice should increase quantity, not create a duplicate entry.
Design insight: New entities: CartItem (product + quantity) and ShoppingCart (a collection of CartItems with AddItem/RemoveItem/GetTotal). The cart "owns" items — a classic compositionWhen one object contains other objects and controls their lifecycle. The cart "has" items. If the cart is destroyed, its items go with it. This is stronger than just referencing — the cart is responsible for its items. relationship.
Stage 3: Apply Discounts
What you SEE: A coupon code input field. An "Apply" button. Sometimes automatic deals — "Buy 2, get 1 free" badges that apply without a code. A "You saved $12.50!" line on the total.
What happens behind the scenes: The cart needs to calculate discounts, but there are many different kinds: percentage off the total, a fixed dollar amount off, buy-one-get-one on specific items, tiered discounts ("spend $100, get $15 off"). Each type uses a completely different algorithm to compute the savings.
Design insight: Multiple algorithms for the same operation (calculate discount) — and the store wants to add new promotion types without changing existing code. When you hear "multiple ways to do the same thing, swappable at runtime," that's the Strategy patternA design pattern where you define a family of algorithms, put each in its own class, and make them interchangeable. The cart doesn't know if it's applying a 20% off or a BOGO — it just calls CalculateDiscount() on whatever strategy is active.. We'll discover it in Level 2.
Stage 4: Checkout
What you SEE: Shipping address form, payment method selector (card, UPI, wallet), an order summary with subtotal, discounts, tax, shipping fee, and final total. A big "Place Order" button.
What happens behind the scenes: Tax calculation depends on product type — physical goods might be taxed differently from digital goods or subscriptions. Shipping cost depends on weight and destination. The system validates stock one last time before processing payment.
Design insight: Tax per product type means the product itself needs to know its tax rulesTax rates vary by product category and jurisdiction. Physical goods typically have sales tax, digital goods have a lower digital services tax (or none), and subscriptions may be tax-exempt. Encoding the rate as a property on each product type means tax calculation is automatic and type-safe.. Shipping depends on the product's physical properties. These are type-specific behaviors that further reinforce why a single flat Product class won't work. Each product type carries its own calculation logic.
Stage 5: Post-Order
What you SEE: Order confirmation page with a reference number. Email notification in your inbox. A tracking link for physical items. An instant download link for digital purchases.
What happens behind the scenes: Stock is decremented. The payment is captured. Notifications fire to multiple systems: email service, inventory tracker, analytics, the user's order history page. All of these happen because one event occurred — "order placed."
Design insight: One event triggers many reactions across many subsystems. The cart doesn't need to know about email, inventory, or analytics. It just says "something changed" and anyone who cares can listen. That's the Observer patternWhen one object (the subject) maintains a list of dependents (observers) and notifies them automatically of state changes. The cart publishes events; the UI, stock checker, and analytics subscribe. Nobody knows about anyone else. — we'll discover it in Level 4.
What We Discovered
Patterns Hiding in Plain Sight
Discovery
Real World
Code
Type
Product
Item on a product page
IProduct
interface (polymorphic types)
Physical Product
Ships in a box, has weight
PhysicalProduct
record (immutable data)
Digital Product
Instant download link
DigitalProduct
record (immutable data)
Cart Item
Product + quantity line
CartItem
class (mutable quantity)
Shopping Cart
Bag holding all items
ShoppingCart
class (mutable collection)
Discount
Coupon / sale / BOGO
IDiscountStrategy
interface (Strategy pattern)
Bundle
Combo deal / gift set
Bundle
class (Composite pattern)
Notification
UI refresh, email, stock update
ICartObserver
interface (Observer pattern)
The real world is your first diagram. We found 8 entities, 3 design patterns, and a clear picture of the system — without writing a single line of code. Senior engineers start here.
Skill Unlocked: Real-World Walkthrough
Walk through the physical experience before coding. List every noun (= entity) and verb (= method). This gives you a requirements checklist AND a starter class diagram — for free. Works for parking lots, elevators, vending machines, anything.
Section 3 🟢 EASY
Level 0 — Add to Cart
Constraint: "A customer adds products to a cart. The cart shows the total price."
This is where it all begins. The simplest possible shopping cart — no product types, no discounts, no bundles. Just: add a product, see the total. We'll feel the pain of missing features soon enough.
Every complex e-commerce system starts with a laughably simple version. For a shopping cart, that means: a product with a name and price, a cart item that tracks quantity, and a cart that sums up subtotals. That's it. No tax calculations, no discount logic, no nothing. The goal of Level 0 is to get something working — then let the next constraint break it.
Think First #2
What data does a Product need at minimum? What does a CartItem need beyond just a product reference? How does the cart know when to increase quantity vs. add a new line?
60 seconds — sketch it on paper before peeking.
Reveal Answer
A Product needs an ID, name, and price. It never changes after creation, so a recordIn C#, a record struct is an immutable value type. Once you create a Product with Id="LAPTOP-1" and Price=999, those values are locked. Records also auto-generate equality: two Products with the same Id are considered equal. is perfect. A CartItem needs a product reference and a mutable quantity (it starts at 1, but can increase). The cart matches products by ID — if the product is already in the cart, increment quantity; otherwise, add a new CartItem.
Your Internal Monologue
"OK, simplest shopping cart... I need a Product. What's the minimum? An ID to identify it, a name for display, and a price. Does price ever change? Not in the cart — the product page sets it. So Product is immutableData that never changes after creation. A product's price of $29.99 is a fact. If the store wants to change it, they create a new product entry — the existing carts keep their original price. This prevents surprising price changes mid-checkout. — a record."
"CartItem? That's a product + a quantity. Quantity changes (user hits +/- buttons), so CartItem needs to be a class, not a record. Subtotal is just price × quantity — a computed propertyA property that calculates its value on the fly instead of storing it. Subtotal => Product.Price * Quantity always returns the correct value, even if Quantity changes. No stored state to get out of sync., not stored."
"For the cart itself... should I use a Dictionary<string, int> mapping product IDs to quantities? Quick and dirty... but then I lose the product reference and can't compute subtotals without looking it up again. A List<CartItem> is cleaner — each entry has everything it needs."
What Would You Do?
DictionaryApproach.cs
// Quick and dirty: map product ID → quantity
var cart = new Dictionary<string, int>();
void AddToCart(string productId)
{
if (cart.ContainsKey(productId))
cart[productId]++;
else
cart[productId] = 1;
}
// Problem: where's the price? the product name?
// You need ANOTHER dictionary for that.
// GetTotal() requires a lookup service just to find prices.
The catch: This tracks quantities, but loses all product data. To show the user "Laptop — $999 × 2 = $1,998" you need the Product object anyway. You end up rebuilding the cart item concept from scratch. The dictionary is fighting you.
When IS this actually better? In a quick script or prototype where you only care about IDs and counts (e.g., inventory tracking with no display). For a shopping cart with a UI? You need the full product data.
CartWithItems.cs
public readonly record struct Product(string Id, string Name, decimal Price);
public class CartItem
{
public Product Product { get; }
public int Quantity { get; set; }
public decimal Subtotal => Product.Price * Quantity;
public CartItem(Product product, int quantity = 1)
{
Product = product;
Quantity = quantity;
}
}
public class ShoppingCart
{
private readonly List<CartItem> _items = new();
public IReadOnlyList<CartItem> Items => _items;
public decimal Total => _items.Sum(i => i.Subtotal);
// ... AddItem, RemoveItem
}
Why this wins: Each CartItem carries its product data AND its quantity. Subtotal computes itself. The cart's Total sums all subtotals. The code reads like the domain: "a cart has items, each item has a product and a quantity." When your code mirrors how people think about the problem, bugs have fewer places to hide.
Here's the complete Level 0 code. Three types, under 40 lines. Read every line — we'll evolve each one.
Product.cs — Level 0
/// <summary>
/// A product in the store catalog.
/// Immutable — once created, its data never changes.
/// </summary>
public readonly record struct Product(
string Id, // unique identifier (e.g., "LAPTOP-1")
string Name, // display name (e.g., "MacBook Air M3")
decimal Price // unit price in dollars
);
CartItem.cs — Level 0
/// <summary>
/// One line in the shopping cart: a product + how many.
/// Quantity is mutable (user can hit +/- buttons).
/// </summary>
public class CartItem
{
public Product Product { get; }
public int Quantity { get; set; }
/// Computed property — always in sync, no stored state
public decimal Subtotal => Product.Price * Quantity;
public CartItem(Product product, int quantity = 1)
{
Product = product;
Quantity = quantity;
}
}
ShoppingCart.cs — Level 0
public class ShoppingCart
{
private readonly List<CartItem> _items = new();
/// Read-only view — callers can see items but can't modify the list directly
public IReadOnlyList<CartItem> Items => _items;
/// Total = sum of all line subtotals
public decimal Total => _items.Sum(item => item.Subtotal);
/// Add a product. If it's already in the cart, increase quantity.
public void AddItem(Product product, int quantity = 1)
{
var existing = _items.FirstOrDefault(i => i.Product.Id == product.Id);
if (existing is not null)
{
existing.Quantity += quantity;
}
else
{
_items.Add(new CartItem(product, quantity));
}
}
/// Remove a product entirely from the cart.
public bool RemoveItem(string productId)
{
var item = _items.FirstOrDefault(i => i.Product.Id == productId);
if (item is null) return false;
_items.Remove(item);
return true;
}
}
Program.cs — Level 0
var laptop = new Product("LAPTOP-1", "MacBook Air M3", 999.00m);
var mouse = new Product("MOUSE-1", "Logitech MX Master", 89.99m);
var cable = new Product("CABLE-1", "USB-C Cable 2m", 12.99m);
var cart = new ShoppingCart();
cart.AddItem(laptop); // 1 laptop
cart.AddItem(mouse, 2); // 2 mice
cart.AddItem(cable); // 1 cable
cart.AddItem(laptop); // adds another laptop (qty → 2)
foreach (var item in cart.Items)
Console.WriteLine($" {item.Product.Name} × {item.Quantity} = {item.Subtotal:C}");
Console.WriteLine($" Total: {cart.Total:C}");
// Output:
// MacBook Air M3 × 2 = $1,998.00
// Logitech MX Master × 2 = $179.98
// USB-C Cable 2m × 1 = $12.99
// Total: $2,190.97
Let's walk through what each piece does:
Product record struct — three fields: ID, name, price. It's a readonly record structA value type that's both immutable and gets free equality. Two Products with the same Id, Name, and Price are automatically equal. The "readonly" means you can't change any field after creation. Perfect for catalog data., meaning once you create a product, its data is locked. Products are facts — "this laptop costs $999" doesn't change mid-session.
CartItem class — wraps a Product with a mutable Quantity. Subtotal is a computed property that always returns Price × Quantity — no stored value to get stale.
ShoppingCart class — holds a private list of CartItems. AddItem() checks if the product is already in the cart (by ID) and increments quantity if so. Total sums all subtotals using LINQLanguage Integrated Query — C#'s built-in toolkit for querying collections. _items.Sum(i => i.Subtotal) iterates through all items and adds up their subtotals in one expressive line. It reads almost like English: "sum of all item subtotals.".
IReadOnlyList<CartItem> — exposes items for reading (the UI needs to display them) but prevents outside code from directly adding/removing. This is encapsulationHiding internal data and exposing only what's needed. The cart's internal List is private — outsiders get a read-only view. This means nobody can accidentally add an item without going through AddItem(), which handles the "increment quantity if already exists" logic. in action — the cart controls its own collection.
Under 40 lines, and it works for a basic store. But can you spot what's missing? There's no concept of product types (physical vs. digital), no discounts, no bundles, and no notifications. We'll feel each of these pains in the coming levels.
How AddItem() Works
Growing Diagram — After Level 0
Class Diagram — Level 0
System So Far
Before This Level
You see "shopping cart" and think "that's just a list of products."
After This Level
You know to start with the stupidest possible version (Product + CartItem + Cart) — then let each missing feature guide the next design decision.
Transfer: This "start with the dumbest thing that works" approach applies everywhere. In a Parking Lot, Level 0 is: one lot, park a car, return a fee. In a Library System: one shelf, check out a book. The pattern is universal — build the skeleton first, then let constraints shape the design.
Section 4 🟢 EASY
Level 1 — Product Types
New Constraint: "Products are physical (need shipping), digital (instant download), or subscription (recurring billing). Tax rules differ by type."
What Breaks?
Our Level 0 Product has three fields: Id, Name, Price. That's it. But a physical product needs a weight for shipping cost calculation. A digital product needs a download URL. A subscription needs a billing cycle (monthly, yearly). One flat record can't cleanly hold all three variants.
You could add nullable fields: decimal? Weight, string? DownloadUrl, BillingCycle? Cycle. But then every consumer asks "wait, is this product physical or digital?" and checks which fields aren't null. That's a code smellA surface-level indicator that something deeper might be wrong. Nullable fields that are only relevant for certain product types means the type system isn't helping you — you're fighting it. The compiler can't prevent you from reading DownloadUrl on a physical product. — the type system should tell you what kind of product it is, not force you to guess.
Think First #3
Three product variants with different data fields AND different tax rules. A physical product is taxed at 10%, digital at 5%, subscriptions at 0%. How do you model this so the compiler prevents misuse AND adding a fourth product type is easy?
60 seconds — consider records, interfaces, and inheritance.
Reveal Answer
An interfaceIProduct with shared properties (Id, Name, Price) and a TaxRate property. Then separate record types for each variant: PhysicalProduct adds Weight, DigitalProduct adds DownloadUrl, SubscriptionProduct adds BillingCycle. Each record implements IProduct and returns its own tax rate. The compiler enforces correctness — you can't read Weight on a DigitalProduct.
Your Internal Monologue
"Three product types, each with different data... I could use a big class with nullable fields. decimal? Weight, string? DownloadUrl... but that's ugly. A physical product has a DownloadUrl field set to null. Meaningless. And the compiler won't stop me from reading it."
"What about inheritance? A base Product class with PhysicalProduct : Product... That works, but deep inheritance gets messy fast. And these products are mostly data, not behavior. RecordsC# records are designed for immutable data objects. They auto-generate equality, ToString(), and deconstruction. Each product variant is a data carrier with type-specific fields — exactly what records excel at. are better for data-heavy types."
"Actually, an interface for the shared contract + separate records for each type. IProduct defines Id, Name, Price, TaxRate. Each record carries its own specific fields. Tax rate varies by type — each record returns a different value. That's polymorphismDifferent types responding to the same message in different ways. When you call product.TaxRate, a PhysicalProduct returns 0.10m, a DigitalProduct returns 0.05m. Same property, different answers — the caller doesn't need to know which type it is. at work."
The Three Product Types
What Would You Do?
GiantProduct.cs
public record Product(
string Id,
string Name,
decimal Price,
ProductType Type,
decimal? Weight, // only for physical
string? DownloadUrl, // only for digital
BillingCycle? Cycle // only for subscription
);
// Every consumer must check Type before accessing fields:
if (product.Type == ProductType.Physical)
var shipping = product.Weight!.Value * 0.5m;
// Nullable mess. Compiler won't stop you from reading
// product.DownloadUrl on a physical product.
Consequence: Every field is valid for only one product type but visible on all three. You end up sprinkling if (type == Physical) checks everywhere. The compiler can't help you — null reference errors wait in production. This approach gets worse with every new product type.
DeepInheritance.cs
public abstract class Product
{
public string Id { get; init; }
public string Name { get; init; }
public decimal Price { get; init; }
public abstract decimal TaxRate { get; }
}
public class PhysicalProduct : Product
{
public decimal Weight { get; init; }
public override decimal TaxRate => 0.10m;
}
public class DigitalProduct : Product { ... }
// Works! But: classes are reference types (heap allocated),
// need constructors, don't get free equality/ToString.
// And what if you need PhysicalSubscription later?
// Single inheritance blocks that.
Not terrible, but: Classes are reference types — heap allocations, no free equality. Products are pure data; they don't need the overhead of a mutable class hierarchy. And single inheritance means you can't create a "PhysicalSubscription" without restructuring everything.
When IS this better? When products have significant behavior (virtual methods, state machines). For data-first modeling with variant-specific fields, records + interfaces are cleaner.
InterfaceRecords.cs
public interface IProduct
{
string Id { get; }
string Name { get; }
decimal Price { get; }
decimal TaxRate { get; }
}
public record PhysicalProduct(string Id, string Name, decimal Price, decimal Weight)
: IProduct { public decimal TaxRate => 0.10m; }
public record DigitalProduct(string Id, string Name, decimal Price, string DownloadUrl)
: IProduct { public decimal TaxRate => 0.05m; }
public record SubscriptionProduct(string Id, string Name, decimal Price, BillingCycle Cycle)
: IProduct { public decimal TaxRate => 0.00m; }
public enum BillingCycle { Monthly, Yearly }
Why this wins: The interface defines the shared contract (Id, Name, Price, TaxRate). Each record carries only its own fields. PhysicalProduct has Weight; DigitalProduct has DownloadUrl. No nullables, no type checks. Records are immutable value objects with free equality, deconstruction, and ToString. Adding a fourth product type? Create one new record, implement IProduct. Zero changes to existing code.
Here's the complete Level 1 code evolution. The Product record becomes an IProduct interface with three implementations, and CartItem now works with any product type.
IProduct.cs — Level 1
/// <summary>
/// The shared contract for ALL product types.
/// Any code that works with products uses this interface —
/// it doesn't know (or care) if it's physical, digital, or subscription.
/// </summary>
public interface IProduct
{
string Id { get; }
string Name { get; }
decimal Price { get; }
/// Each product type returns its own tax rate.
/// Physical = 10%, Digital = 5%, Subscription = 0%.
decimal TaxRate { get; }
}
PhysicalProduct.cs — Level 1
/// Ships in a box. Needs weight for shipping cost calculation.
public record PhysicalProduct(
string Id,
string Name,
decimal Price,
decimal Weight // kg — used for shipping cost
) : IProduct
{
public decimal TaxRate => 0.10m; // 10% sales tax
}
DigitalProduct.cs — Level 1
/// Instant download — no shipping, lower tax.
public record DigitalProduct(
string Id,
string Name,
decimal Price,
string DownloadUrl // link sent after purchase
) : IProduct
{
public decimal TaxRate => 0.05m; // 5% digital tax
}
SubscriptionProduct.cs — Level 1
public enum BillingCycle { Monthly, Yearly }
/// Recurring charge — no physical goods, no tax.
public record SubscriptionProduct(
string Id,
string Name,
decimal Price,
BillingCycle Cycle // Monthly or Yearly
) : IProduct
{
public decimal TaxRate => 0.00m; // Tax-exempt
}
CartItem.cs — Level 1 (updated)
public class CartItem
{
public IProduct Product { get; } // was: Product record
public int Quantity { get; set; }
public decimal Subtotal => Product.Price * Quantity;
public decimal Tax => Subtotal * Product.TaxRate; // NEW: tax per line
public CartItem(IProduct product, int quantity = 1)
{
Product = product;
Quantity = quantity;
}
}
Let's walk through the key changes:
IProduct interface — the shared contract. Any code that works with products depends on this interface, not on a specific product type. The cart doesn't need to know which kind of product it holds.
Three record types — each carries only the fields relevant to its type. No nullable garbage. PhysicalProduct has Weight; DigitalProduct has DownloadUrl; SubscriptionProduct has Cycle.
TaxRate property — each record returns a different rate. When you call product.TaxRate, the runtime calls the correct implementation. That's polymorphism — same property name, different behavior depending on the actual type.
CartItem updated — now holds IProduct instead of the concrete Product record. It also computes a Tax property per line item. Zero changes to ShoppingCart — it still calls item.Subtotal and everything works.
Tax Rules by Product Type
Why Records, Not Classes?
System So Far — Level 1
Before This Level
You see "different product types" and think "add nullable fields to the Product class."
After This Level
You smell "different data per type" and reach for an interface + type-specific records. The compiler enforces correctness, and adding a new type is one new file.
Smell → Pattern: "Different data per variant, same operations" → Interface + Records. When you have 3+ types that share a contract but carry different fields, don't stuff everything into one class. Let the type system work for you.
Transfer: This same technique works in a Parking Lot (Motorcycle, Car, Van share IVehicle but have different sizes), a Library (Book, DVD, EBook share IMediaItem), or any system with multiple entity variants.
Section 5 🟡 MEDIUM
Level 2 — Discounts
New Constraint: "The store has multiple discount types: percentage off (20% sale), fixed amount ($10 off), buy-one-get-one (free item on qualifying purchase). Adding 'spend $100 get $15 off' should require ZERO changes to existing discount code."
What Breaks?
Our Level 1 ShoppingCart.Total is just the sum of all subtotals. There's no concept of discounts at all. Where would a discount even go? You could hardcode it into GetTotal():
The Mess (Don't Do This)
public decimal GetTotal()
{
var subtotal = _items.Sum(i => i.Subtotal);
if (discountType == "percentage")
return subtotal * (1 - percentage);
else if (discountType == "fixed")
return subtotal - fixedAmount;
else if (discountType == "bogo")
return subtotal - cheapestItemPrice;
// ... every new promotion: another else-if here
}
Every new discount type means touching GetTotal(). That violates the Open/Closed PrincipleSoftware should be open for extension (you can add new behavior) but closed for modification (you don't change existing code). Adding a new discount type should mean adding a new class, not editing GetTotal(). If you have to modify working code every time, you risk breaking what already works. — the code is open for modification when it should be open for extension.
Think First #4
Multiple discount algorithms, same operation (calculate discount amount), and adding a new promotion type should require zero changes to existing code. What pattern fits?
60 seconds — think about what varies and what stays the same.
Reveal Answer
The Strategy pattern. Define an interface IDiscountStrategy with a single method CalculateDiscount(items). Each discount type — percentage, fixed, BOGO — is its own class implementing that interface. The cart holds a list of strategies and applies them. Adding "spend $100, get $15 off" means creating ONE new class. The cart, the existing discounts, and GetTotal() are untouched.
Your Internal Monologue
"Multiple discount types... I could add a switch statement in GetTotal(). switch (discountType) with cases for percentage, fixed, BOGO. That works for 3 types. But the product manager wants to add new promotions every month — flash sales, tiered discounts, loyalty rewards. I'd be editing GetTotal() constantly."
"What varies here? The algorithm for computing the discount. What stays the same? The operation: take a list of cart items, return a discount amount. Multiple algorithms, same interface... Wait — that's literally the Strategy patternDefine a family of algorithms, put each in its own class, and make them interchangeable. The client (ShoppingCart) doesn't know which algorithm it's using — it just calls CalculateDiscount() on whatever strategy objects are attached. Adding a new discount type = adding one new class.."
"I didn't try to use Strategy. I just asked 'what varies independently?' and the answer pointed me there. That's the thinking tool — not 'memorize when to use Strategy,' but 'notice when multiple algorithms share an interface.'"
"And the cart should support multiple discounts simultaneously. A 20% holiday sale AND a $10 coupon at the same time. So the cart needs a list of strategies, not just one."
The Strategy Pattern — One Interface, Many Algorithms
Consequence: Every new promotion touches GetTotal(). You add "loyalty points" — edit. "Holiday bundle" — edit. "Spend $100 get $15 off" — edit. After 10 promotions, GetTotal() is a 50-line switch statement that nobody wants to touch. One typo in a string comparison and a $5 million sale gives customers free products.
IfElseChain.cs
public decimal GetTotal()
{
var subtotal = _items.Sum(i => i.Subtotal);
var discount = 0m;
if (_hasPercentageDiscount)
discount += subtotal * _percentage;
if (_hasFixedDiscount)
discount += _fixedAmount;
if (_hasBogo)
discount += GetCheapestItemPrice();
if (_hasLoyaltyDiscount) // added 3 months later
discount += _loyaltyPoints * 0.01m;
// ... growing forever
return subtotal - discount;
}
Slightly better for stacking, but: At least this handles multiple discounts simultaneously. But every new discount still means adding a new field (_hasX), a new parameter, and a new if-block. The method grows, the fields multiply, and the cart class balloons with discount-specific logic.
StrategyApproach.cs
public interface IDiscountStrategy
{
decimal CalculateDiscount(IReadOnlyList<CartItem> items);
string Description { get; }
}
// Cart applies ALL attached strategies — stacking works naturally
public decimal GetTotal()
{
var subtotal = _items.Sum(i => i.Subtotal);
var totalDiscount = _discounts.Sum(d => d.CalculateDiscount(_items));
return Math.Max(0, subtotal - totalDiscount);
}
// Adding "Spend $100 get $15 off"? ONE new class:
public sealed class ThresholdDiscount : IDiscountStrategy { ... }
// ZERO changes to ShoppingCart, ZERO changes to existing discounts.
Why this wins: The cart doesn't know what kinds of discounts exist. It just asks each strategy "how much off?" and sums the answers. New discount type? Create ONE class. The cart, the existing discounts, and GetTotal() are completely untouched. That's the Open/Closed PrincipleOpen for extension (add new discount classes), closed for modification (existing code stays untouched). You extend the system by ADDING code, not EDITING it. This means existing, tested behavior can't accidentally break. in action.
Switch vs. Strategy — Adding a New Discount
Here's the complete Level 2 code. We add IDiscountStrategy and three implementations, then update the cart to hold a list of strategies.
IDiscountStrategy.cs — Level 2
/// <summary>
/// The contract every discount type must follow.
/// One method: "given these cart items, how much should I discount?"
/// One property: a human-readable description for the receipt.
/// </summary>
public interface IDiscountStrategy
{
/// Calculate the discount amount (NOT the final price — just the savings).
/// Receives the full item list so complex discounts can inspect quantities,
/// product types, or totals.
decimal CalculateDiscount(IReadOnlyList<CartItem> items);
/// Display name for the receipt (e.g., "20% Holiday Sale")
string Description { get; }
}
PercentageDiscount.cs — Level 2
/// "20% off your entire order!"
public sealed class PercentageDiscount : IDiscountStrategy
{
private readonly decimal _percentage;
public string Description { get; }
public PercentageDiscount(decimal percentage, string description = "")
{
if (percentage < 0 || percentage > 1)
throw new ArgumentOutOfRangeException(nameof(percentage),
"Percentage must be between 0 and 1 (e.g., 0.20 for 20%)");
_percentage = percentage;
Description = string.IsNullOrEmpty(description)
? $"{percentage:P0} Off"
: description;
}
public decimal CalculateDiscount(IReadOnlyList<CartItem> items)
{
var subtotal = items.Sum(i => i.Subtotal);
return Math.Round(subtotal * _percentage, 2);
}
}
FixedAmountDiscount.cs — Level 2
/// "$10 off your order!"
public sealed class FixedAmountDiscount : IDiscountStrategy
{
private readonly decimal _amount;
public string Description { get; }
public FixedAmountDiscount(decimal amount, string description = "")
{
if (amount < 0)
throw new ArgumentOutOfRangeException(nameof(amount),
"Discount amount cannot be negative");
_amount = amount;
Description = string.IsNullOrEmpty(description)
? $"${amount} Off"
: description;
}
public decimal CalculateDiscount(IReadOnlyList<CartItem> items)
{
var subtotal = items.Sum(i => i.Subtotal);
// Don't discount more than the subtotal
return Math.Min(_amount, subtotal);
}
}
BuyOneGetOneDiscount.cs — Level 2
/// "Buy one, get one free!" on a specific product.
/// If the customer has 2+ of the qualifying product,
/// the cheapest one is free.
public sealed class BuyOneGetOneDiscount : IDiscountStrategy
{
private readonly string _qualifyingProductId;
public string Description => "Buy One Get One Free";
public BuyOneGetOneDiscount(string qualifyingProductId)
{
_qualifyingProductId = qualifyingProductId;
}
public decimal CalculateDiscount(IReadOnlyList<CartItem> items)
{
var qualifying = items
.FirstOrDefault(i => i.Product.Id == _qualifyingProductId);
// Need at least 2 to qualify
if (qualifying is null || qualifying.Quantity < 2)
return 0m;
// One free item = one unit's price
return qualifying.Product.Price;
}
}
ShoppingCart.cs — Level 2 (updated)
public class ShoppingCart
{
private readonly List<CartItem> _items = new();
private readonly List<IDiscountStrategy> _discounts = new(); // NEW
public IReadOnlyList<CartItem> Items => _items;
public IReadOnlyList<IDiscountStrategy> Discounts => _discounts; // NEW
public decimal Subtotal => _items.Sum(i => i.Subtotal);
/// Total discount from ALL attached strategies
public decimal TotalDiscount =>
_discounts.Sum(d => d.CalculateDiscount(_items));
/// Final total = subtotal minus discounts (never below zero)
public decimal Total => Math.Max(0, Subtotal - TotalDiscount);
public decimal TotalTax => _items.Sum(i => i.Tax); // from L1
// --- Item management (unchanged from L0) ---
public void AddItem(IProduct product, int quantity = 1) { /* ... same as before */ }
public bool RemoveItem(string productId) { /* ... same as before */ }
// --- Discount management (NEW) ---
public void ApplyDiscount(IDiscountStrategy discount)
=> _discounts.Add(discount);
public void RemoveDiscount(IDiscountStrategy discount)
=> _discounts.Remove(discount);
}
Let's walk through the key pieces:
IDiscountStrategy — one method (CalculateDiscount) and one property (Description). Every discount type implements this contract. The cart never sees concrete types — just the interface.
PercentageDiscount — takes a decimal between 0 and 1 (0.20 = 20%). Multiplies the subtotal by the percentage. Input validated in the constructorThe method that runs when you create a new object. By validating input here (percentage must be 0-1, amount must be positive), you guarantee that every PercentageDiscount in your system is valid. It's impossible to create a broken one. This is called "making illegal states unrepresentable." so invalid percentages can't sneak in.
FixedAmountDiscount — subtracts a flat amount. Uses Math.Min to prevent discounting below the subtotal (a $50 coupon on a $30 order gives $30 off, not $50).
BuyOneGetOneDiscount — targets a specific product by ID. If the customer has 2+ units, one unit's price is the discount. This shows how strategies can inspect individual items, not just the total. The sealedThe sealed keyword prevents other classes from inheriting from this one. PercentageDiscount does one thing well — there's no reason to subclass it. Sealing also gives the runtime a small performance boost because it can skip virtual dispatch lookups. keyword prevents unnecessary subclassing.
ShoppingCart updates — holds a list of IDiscountStrategy. TotalDiscount sums all strategies. Total subtracts discounts from subtotal using Math.Max(0, ...) to prevent negative totalsWithout the Math.Max guard, stacking a 50% discount with a $200 flat discount on a $300 cart would give -$50. You'd be paying the customer! The guard ensures the minimum total is always zero — discounts can't exceed the cart value.. ApplyDiscount and RemoveDiscount manage the list. The existing item management code is untouched.
Stacking Discounts — How It Works
System So Far — Level 2
Before This Level
You see "multiple discount types" and think "switch statement in GetTotal()."
After This Level
You smell "multiple algorithms, same interface" and instinctively reach for the Strategy pattern. New behavior = new class, not edited code.
Smell → Pattern: "Multiple Algorithms, Same Interface" → Strategy. When you see 3+ ways to do the same thing (calculate a discount, calculate pricing, schedule an elevator), and the choice can change at runtime, that's Strategy. The interface stays fixed; the implementations multiply.
Transfer: The Strategy pattern shows up everywhere. In a Parking Lot, multiple pricing algorithms (hourly, daily, premium) all implement IPricingStrategy. In an Elevator System, different scheduling algorithms (FCFS, SCAN, LOOK) implement ISchedulingStrategy. Same pattern, different domain.
Section 6
Level 3 — Bundles & Combos 🟡 MEDIUM
New Constraint: "Products can be bundled: a 'Back to School' combo = laptop + mouse + bag at a combined price. A bundle can contain other bundles ('Ultimate Setup' = 'Back to School' + monitor + keyboard). The cart must treat single items and bundles identically when calculating totals."
What breaks: Level 2's CartItem wraps a single IProduct. A bundle is multiple products with a combined price. You can't shove a bundle into a CartItem without creating a separate BundleItem class. But then GetTotal() needs completely different logic for items versus bundles. And bundles inside bundles? That's recursive special cases everywhere. Your neat cart code collapses into a tangle of if (item is BundleItem) checks.
Think First #5 — pause and design before you see the answer (60 seconds)
A single item has a price. A bundle has items with a combined price. A mega-bundle has bundles inside it. All of them need to support GetPrice(). Think about a tree — leaves are individual items, branches are bundles. They share the same interface so the cart never has to ask "are you a single item or a bundle?"
What pattern lets you treat a single thing and a group of things with the exact same interface, even when groups are nested inside groups?
Your inner voice:
"Items and bundles... both have a price. A bundle contains items, but it's also an item from the cart's perspective. It's like files and folders — a folder contains files, but you can treat a folder the same way you treat a file (copy it, move it, delete it)."
"If I make a single item and a bundle share the same interface — say, ICartComponent with GetPrice() — then the cart doesn't care what it's holding. A single item returns its own price. A bundle loops over its children and sums their prices (minus a discount). A mega-bundle does the same thing, and its children can be items OR bundles."
"Wait — that's a tree! And a tree where leaves and branches share the same interface... that's the Composite patternThe Composite pattern lets you compose objects into tree structures to represent part-whole hierarchies. The key idea: a single object and a group of objects implement the same interface. The client code treats them identically — it never has to ask "are you a leaf or a branch?" This is how file systems, UI component trees, and bundle pricing work.! I didn't try to force a pattern — I just followed the tree shape of the data and the pattern appeared."
The Big Idea: Composite Pattern
This is a brand-new pattern — and it shows up everywhere once you learn to see it. Let's build your intuition from the ground up before touching any code.
Think about your computer's file system. You have files (they hold data) and folders (they hold files — or other folders). When you right-click a file and check its size, you get a number. When you right-click a folder and check its size, you get the total size of everything inside — including sub-folders. The folder's "size" is computed by recursively summing its children's sizes.
Here's the elegant part: you interact with files and folders using the exact same actions. You can copy, move, delete, or rename either one. The operating system doesn't force you to learn two separate sets of commands. A folder is just a "thing that contains other things" — and it looks like a thing itself.
That's the Composite patternA structural design pattern that composes objects into tree structures. Single objects ("leaves") and groups of objects ("composites") share the same interface, so client code can treat them uniformly without knowing whether it's dealing with a single element or a collection. in one sentence: a single thing and a group of things share the same interface, so the caller never has to check which one it's talking to.
The File & Folder Analogy
Here's how the file system maps perfectly onto our shopping cart:
Before vs. After: Flat List vs. Tree
Without Composite, the cart sees a flat list and needs special logic for bundles. With Composite, the cart sees a tree and calls GetPrice() on each root element — the tree does the rest.
What Would You Do?
Three developers face the bundle problem. Each has a different instinct. Only one survives the "mega-bundle inside a bundle" test.
The idea: Keep CartItem for single products and add a BundleItem class. The cart holds a mixed list and uses is checks to handle each type.
SeparateClasses.cs — two types, two code paths
// Two different types in the cart
List<object> _items = new(); // mixed bag!
public decimal GetTotal()
{
decimal total = 0;
foreach (var item in _items)
{
if (item is CartItem ci)
total += ci.Product.Price * ci.Quantity;
else if (item is BundleItem bi)
total += bi.Products.Sum(p => p.Price) - bi.Discount;
// What about mega-bundles? Another branch...
}
return total;
}
Verdict: It works for two types, but every new "kind" of thing in the cart adds another if/else. Mega-bundles? Subscription packs? Gift sets? Each demands its own branch. The GetTotal() method grows into an unmaintainable mess. And that List<object> is a code smell — you've lost type safety.
When IS this approach actually better? Almost never. If you're 100% certain there will only ever be two types and the code is a quick script, the simplicity might be fine. But shopping carts always grow.
The idea: Make Bundle inherit from Product. A bundle IS a product, so inheritance makes sense, right?
InheritanceApproach.cs — bundle inherits from product
public class Bundle : Product
{
public List<Product> Children { get; } = new();
public decimal Discount { get; set; }
// Override Price to compute from children
public override decimal Price
=> Children.Sum(c => c.Price) - Discount;
}
// But Product has: SKU, Weight, Dimensions...
// A Bundle doesn't HAVE a SKU or Weight by itself
// We're forcing unrelated properties onto bundles
Verdict: The "is-a" relationship feels right at first — a bundle IS something with a price. But Product carries baggage: SKU, weight, dimensions, stock count. A bundle doesn't have its own SKU (it's a group, not a thing on a shelf). You end up with dummy values for irrelevant fields. And if Product evolves, every bundle inherits unwanted behavior. This violates LSPThe Liskov Substitution Principle says that if you replace a parent type with a child type, everything should still work correctly. If a Bundle inherits from Product but doesn't truly behave like a Product (e.g., it has no real SKU), substituting one for the other breaks assumptions in the code. — you can't substitute a Bundle everywhere a Product is expected without surprises.
When IS inheritance better? When the child truly is a variant of the parent with no dummy fields. DigitalProduct : Product makes sense because it HAS all Product properties. Bundle : Product doesn't because a bundle is a container, not a product.
The idea: Create a shared interface, ICartComponent, implemented by both SingleItem (leaf) and ProductBundle (composite). The bundle holds a list of ICartComponent — which can be items OR other bundles.
CompositeApproach.cs — tree structure
public interface ICartComponent
{
string Name { get; }
decimal GetPrice();
IEnumerable<ICartComponent> GetItems();
}
// The cart doesn't care about types:
foreach (var component in cart.Components)
total += component.GetPrice(); // uniform!
Verdict: This is the winner. The cart holds ICartComponent objects. It calls GetPrice() on each one. A single item returns its product's price. A bundle sums its children's prices minus a discount. A mega-bundle does the same, and its children can be items or bundles. The recursion handles itself — you never write a single if (item is Bundle) check. New kinds of cart components (gift sets, subscription packs) just implement the interface.
Decision Compass: If a single thing and a group of things need to support the same operations — and groups can contain other groups → Composite pattern. The tree shape of the data IS the pattern.
The Solution
Three pieces work together: an interface that both leaves and branches share, a leaf class for individual items, and a composite class for bundles. The beauty is that the cart code doesn't change at all — it already calls GetPrice(), and that just works.
ICartComponent.cs — the shared interface for leaves and branches
/// <summary>
/// The component interface: both single items and bundles implement this.
/// The cart holds a list of ICartComponent and never checks the concrete type.
/// </summary>
public interface ICartComponent
{
string Name { get; }
/// <summary> Price of this component (leaf = product price, bundle = sum - discount) </summary>
decimal GetPrice();
/// <summary>
/// Flatten the tree: a leaf returns itself, a bundle returns all children recursively.
/// Useful for "show all individual items in the cart" views.
/// </summary>
IEnumerable<ICartComponent> GetItems();
}
This is the contract that makes the whole tree work. The interface is intentionally minimal — a name, a price computation, and a way to flatten nested structures. Notice there's nothing about "children" or "add/remove" here. That's deliberate: a single item has no concept of children, so we don't pollute the interface with operations that only bundles need.
SingleItem.cs — a leaf in the tree (one product)
/// <summary>
/// A leaf node: wraps a single IProduct.
/// GetPrice() returns the product's price.
/// GetItems() returns just itself — there's nothing nested.
/// </summary>
public sealed class SingleItem : ICartComponent
{
public IProduct Product { get; }
public int Quantity { get; set; }
public SingleItem(IProduct product, int quantity = 1)
{
Product = product ?? throw new ArgumentNullException(nameof(product));
Quantity = quantity;
}
public string Name => Product.Name;
// Leaf: just return my own price times quantity
public decimal GetPrice() => Product.Price * Quantity;
// Leaf: I'm the only item, so return myself
public IEnumerable<ICartComponent> GetItems() => [this];
}
The leaf is straightforward. GetPrice() delegates to the product. GetItems() returns a single-element collection containing itself. When the cart asks a single item to flatten itself, it just says "I'm the only thing here."
ProductBundle.cs — a branch in the tree (group of items/bundles)
/// <summary>
/// A composite node: holds children that are themselves ICartComponent.
/// Children can be SingleItems OR other ProductBundles — nesting is unlimited.
/// </summary>
public sealed class ProductBundle : ICartComponent
{
private readonly List<ICartComponent> _children = new();
public string Name { get; }
public decimal BundleDiscount { get; }
public ProductBundle(string name, decimal bundleDiscount = 0m)
{
Name = name;
BundleDiscount = bundleDiscount;
}
/// <summary> Add an item or another bundle to this bundle. </summary>
public void Add(ICartComponent component)
=> _children.Add(component);
/// <summary>
/// Price = sum of all children's prices minus the bundle discount.
/// Because children can be bundles too, this call is RECURSIVE:
/// bundle.GetPrice() calls child-bundle.GetPrice() which calls its children...
/// The tree walks itself!
/// </summary>
public decimal GetPrice()
=> _children.Sum(c => c.GetPrice()) - BundleDiscount;
/// <summary>
/// Flatten: ask each child to flatten itself, then merge all results.
/// SelectMany concatenates: [laptop] + [mouse, bag] = [laptop, mouse, bag]
/// </summary>
public IEnumerable<ICartComponent> GetItems()
=> _children.SelectMany(c => c.GetItems());
public IReadOnlyList<ICartComponent> Children => _children;
}
The magic is in GetPrice(). It sums its children's prices, but each child could be a leaf (returns a product price) OR another bundle (which recursively sums its children). The tree walks itself — no external recursion needed. GetItems() uses SelectManyA LINQ method that "flattens" nested collections. If you have a list of lists, SelectMany merges them into one flat list. It's like taking all the items out of multiple boxes and putting them on one table. to flatten the entire tree into a single list of individual items.
Program.cs — building and pricing bundles
// Build some individual items
var laptop = new SingleItem(new PhysicalProduct("Laptop", 999m));
var mouse = new SingleItem(new PhysicalProduct("Mouse", 29m));
var bag = new SingleItem(new PhysicalProduct("Laptop Bag", 49m));
var monitor = new SingleItem(new PhysicalProduct("Monitor", 299m));
var keyboard = new SingleItem(new PhysicalProduct("Keyboard", 79m));
// Build "Back to School" bundle: laptop + mouse + bag, $50 off
var backToSchool = new ProductBundle("Back to School", bundleDiscount: 50m);
backToSchool.Add(laptop);
backToSchool.Add(mouse);
backToSchool.Add(bag);
// Price: (999 + 29 + 49) - 50 = $1,027
// Build "Ultimate Setup" MEGA-bundle: BackToSchool + monitor + keyboard, $30 off
var ultimateSetup = new ProductBundle("Ultimate Setup", bundleDiscount: 30m);
ultimateSetup.Add(backToSchool); // a bundle INSIDE a bundle!
ultimateSetup.Add(monitor);
ultimateSetup.Add(keyboard);
// Price: (1027 + 299 + 79) - 30 = $1,375
// The cart doesn't care what it holds:
cart.Add(ultimateSetup); // bundle of bundles
cart.Add(new SingleItem(new PhysicalProduct("USB Cable", 12m)));
// GetTotal() just calls GetPrice() on each component. Done.
var total = cart.GetTotal(); // $1,375 + $12 = $1,387
Look at how clean this is. We nested a bundle inside a bundle, added a standalone item, and the cart's GetTotal() method didn't need a single line of new code. The tree structure handles the recursion internally. Want to add a "Holiday Gift Set" bundle that contains the "Ultimate Setup" plus gift wrapping? Just create another ProductBundle and add the existing mega-bundle as a child. The nesting goes as deep as you want.
How GetPrice() Walks the Tree
When you call GetPrice() on the "Ultimate Setup" mega-bundle, here's what happens behind the scenes. The call bounces down the tree, collects prices at each leaf, and bubbles the sum back up. No loops in your code — the tree structure IS the loop.
Notice how nobody orchestrates this recursion. We didn't write a recursive function. We just called GetPrice() on the top bundle, and because each bundle's GetPrice() calls its children's GetPrice(), the tree walks itself all the way down to the leaves and sums its way back up. That's the power of the Composite pattern — the structure IS the algorithm.
The Composite Tree for Our Shopping Cart
Here's what the cart looks like as a tree structure. The cart itself holds root-level components. Some are leaves (single items), some are branches (bundles) that contain more branches and leaves.
Growing Diagram — Level 3
Two new types join our system: SingleItem (wraps a product as a leaf) and ProductBundle (holds children as a branch). Both implement ICartComponent, so the cart just holds components and never checks concrete types.
Before / After Your Brain
Before This Level
You see "groups of items that act like items" and reach for special if/else checks per type. Bundles become second-class citizens with their own code paths.
After This Level
You spot the tree shape in the data and instinctively reach for Composite. A single thing and a group of things — same interface, tree walks itself. The caller never asks "are you a leaf or a branch?"
Smell → Pattern:"Tree of Same-Type Things" — When a single item and a group of items both need the same operations (price, display, serialize), and groups can be nested inside groups → Composite pattern. One shared interface, recursive structure, uniform client code.
Transfer: Same technique in a UI Component Tree: a Button is a leaf, a Panel is a composite that holds buttons and other panels. Render() works the same way — a button renders itself, a panel renders its children. Same in a Menu System: a menu item is a leaf, a dropdown is a composite. Same in Organization Charts: an employee is a leaf, a department is a composite. The tree shape is everywhere.
Section 7
Level 4 — Live Updates 🟡 MEDIUM
New Constraint: "When a product's price changes in the catalog, every cart containing that product must update its total. When stock runs out, affected items must show 'Out of Stock.' When a coupon expires, the discount must be removed. The cart must notify the UI about all these changes — in real time."
What breaks: Level 3's cart is passive. It calculates totals when asked, but it never reacts to anything. If a product's price changes in the catalog, the cart still shows the old price until the user refreshes. If stock runs out while a customer is browsing, they only find out at checkout. The cart is a snapshot frozen in time — it has no ears to hear changes happening elsewhere in the system.
Think First #6 — pause and design before you see the answer (60 seconds)
The cart needs to react to events it didn't cause — price changes, stock changes, coupon expiry. It shouldn't poll the catalog every second asking "anything new?" That's wasteful. Instead, the catalog should tell the cart when something changes.
Multiple listeners need to hear about cart changes too: the UI needs to refresh, an analytics service wants to log events, an email service might send an "item back in stock" notification. What pattern lets multiple observers react to events from a single source — without the source knowing who's listening?
Your inner voice:
"The cart needs to know when prices change. I could have the cart check the catalog every time GetTotal() is called... but that's coupling the cart to the catalog's internal structure. And what about stock changes? Coupon expiry? The cart can't poll everything."
"Flip it around. Instead of the cart asking, the catalog should tell. 'Hey, price changed for product #42.' The cart just listens. And not just the cart — the UI, analytics, email service all want to know too."
"That's the Observer patternThe Observer pattern creates a one-to-many relationship: when one object (the subject/publisher) changes state, all its dependents (observers/subscribers) get notified automatically. Think of it like subscribing to a YouTube channel — when a new video is uploaded, every subscriber gets a notification. The channel doesn't know or care who's subscribed.. The catalog is the publisher, carts and UI components are subscribers. When something changes, the publisher fires an event, and every subscriber reacts in its own way. The publisher doesn't know or care who's listening."
How Events Flow: Publisher → Subscribers
The catalog service acts as the publisherIn the Observer pattern, the publisher (also called "subject") is the object that detects a change and broadcasts a notification. It maintains a list of subscribers but doesn't know what they do with the notification. Think of a radio station — it broadcasts to everyone tuned in, without knowing who's listening.. When a price changes, it broadcasts to every subscriberA subscriber (also called "observer") registers interest in a publisher's events. When the publisher fires an event, every subscriber's callback runs automatically. Each subscriber can react differently — one updates the UI, another logs to analytics, another sends an email. that registered interest. The cart, the UI, and the analytics service all hear about it — but the catalog doesn't know or care who they are.
What Would You Do?
Two approaches to keeping the cart in sync with the outside world. One requires the cart to keep asking. The other lets the world tell the cart.
The idea: Every time GetTotal() is called, the cart re-fetches current prices from the catalog. If a price changed, the total automatically reflects it.
PollingApproach.cs — cart asks catalog every time
public decimal GetTotal()
{
decimal total = 0;
foreach (var item in _components)
{
// Re-fetch CURRENT price every time
var currentPrice = _catalog.GetCurrentPrice(item.ProductId);
total += currentPrice * item.Quantity;
// Check stock too while we're at it
if (_inventory.GetStock(item.ProductId) == 0)
item.MarkOutOfStock();
}
return _discount?.Apply(total) ?? total;
}
Verdict: It works — prices are always fresh. But the cart is now coupled to the catalog and inventory services. It needs to import them, hold references to them, and call them every single time it calculates a total. Performance suffers with many items. And the UI only updates when the user triggers a recalculation — there's no real-time push. The user doesn't see a price drop until they refresh.
When IS polling better? In very simple systems with one or two services, where real-time updates aren't needed. A command-line tool that calculates once and exits? Polling is perfectly fine. But a live shopping experience? It needs push.
The idea: The cart subscribes to events from the catalog, inventory, and coupon services. When something changes, the relevant service fires an event, and the cart reacts. The cart doesn't ask — it listens.
ObserverApproach.cs — cart reacts to events
// Cart subscribes to catalog events:
catalogService.PriceChanged += OnPriceChanged;
catalogService.StockDepleted += OnStockDepleted;
couponService.CouponExpired += OnCouponExpired;
// When a price changes, the cart reacts:
private void OnPriceChanged(string productId, decimal newPrice)
{
var item = FindItem(productId);
if (item is not null)
{
item.UpdatePrice(newPrice);
NotifyObservers(); // tell UI, analytics, etc.
}
}
Verdict: This is the winner. The cart subscribes once and then passively waits. When a price drops, the event fires immediately, the cart updates its internal state, and it can chain-notify its own observers (the UI, analytics, etc.). The catalog doesn't import or know about the cart. Each service is decoupled — they communicate through events, not direct calls. Adding a new subscriber (e.g., a "price alert" email service) means registering one more event handler — zero changes to the publisher.
Decision Compass: Does object A need to react to changes in object B, without B knowing about A? → Observer pattern. B publishes events. A subscribes. They never import each other.
The Solution
We define an ICartObserver interface for anything that wants to hear about cart changes. The cart itself becomes both a subscriber (it listens to catalog events) and a publisher (it notifies UI/analytics when its state changes). This dual role is common in real systems — events cascade through layers.
ICartObserver.cs — the subscriber contract
/// <summary>
/// Any component that wants to react to cart changes implements this.
/// The cart holds a list of ICartObserver and calls the relevant method
/// whenever something happens. The cart doesn't know the concrete types.
/// </summary>
public interface ICartObserver
{
/// <summary> A product's price changed while it was in the cart </summary>
void OnItemPriceChanged(string productId, decimal oldPrice, decimal newPrice);
/// <summary> A product in the cart just went out of stock </summary>
void OnItemOutOfStock(string productId);
/// <summary> A discount code applied to the cart has expired </summary>
void OnDiscountExpired(string discountCode);
/// <summary> The cart's total changed (for any reason) </summary>
void OnCartTotalChanged(decimal newTotal);
}
Each method represents a different kind of event. A UI component might implement all four (to show animations, badges, and updated totals). An analytics service might only care about OnCartTotalChanged. The interface keeps the cart decoupled — it calls these methods without knowing what happens on the other end.
CartEventArgs.cs — event data records
/// <summary>
/// Immutable records carrying event data.
/// Records are perfect for events: they're created once, never modified,
/// and automatically get equality + ToString for free.
/// </summary>
public sealed record PriceChangedEvent(
string ProductId,
decimal OldPrice,
decimal NewPrice,
DateTimeOffset Timestamp);
public sealed record StockDepletedEvent(
string ProductId,
DateTimeOffset Timestamp);
public sealed record CouponExpiredEvent(
string DiscountCode,
DateTimeOffset Timestamp);
Using recordsIn C#, records are immutable reference types that auto-generate value-based equality, deconstruction, and a readable ToString(). They're ideal for event data because events represent facts that happened — facts don't change after they occur. for event data is a natural fit. An event is a fact that happened at a point in time — "the price of product X changed from $999 to $899 at 2:30 PM." Facts don't change, so immutability is exactly right.
ShoppingCart.cs — observer registration and notification
public sealed class ShoppingCart
{
private readonly List<ICartComponent> _components = new();
private readonly List<ICartObserver> _observers = new();
// --- Observer Management ---
public void Subscribe(ICartObserver observer)
=> _observers.Add(observer);
public void Unsubscribe(ICartObserver observer)
=> _observers.Remove(observer);
// --- Reacting to external events ---
public void HandlePriceChange(string productId, decimal oldPrice, decimal newPrice)
{
// Update the item's price internally
var item = FindComponentByProductId(productId);
if (item is SingleItem si)
si.UpdatePrice(newPrice);
// Notify all observers: "a price changed!"
foreach (var obs in _observers)
obs.OnItemPriceChanged(productId, oldPrice, newPrice);
// Also notify: "the total changed!"
NotifyTotalChanged();
}
public void HandleStockDepleted(string productId)
{
foreach (var obs in _observers)
obs.OnItemOutOfStock(productId);
}
private void NotifyTotalChanged()
{
var newTotal = GetTotal();
foreach (var obs in _observers)
obs.OnCartTotalChanged(newTotal);
}
}
The cart maintains a list of observers and notifies all of them when something happens. Adding a new subscriber (say, a "wishlist" service that tracks price drops) means calling cart.Subscribe(wishlistService) — zero changes to the cart itself. That's the Open/Closed PrincipleThe O in SOLID: software entities should be open for extension (you can add new observers) but closed for modification (the cart's notification loop never changes). Adding new behavior means adding new classes, not editing existing ones. in action: the cart is open for extension (add subscribers) but closed for modification (the notification loop never changes).
Event Flow: Price Change Cascade
Here's what happens when a product's price changes. The event cascades through two layers: first the catalog notifies the cart, then the cart notifies its own subscribers. Each layer is decoupled from the next.
Passive Cart vs. Reactive Cart
Here's the fundamental shift this level introduces. Before Observer, the cart was a calculator that ran on demand. After Observer, the cart is a living component that stays in sync with the world.
Growing Diagram — Level 4
The Observer infrastructure joins our system. ICartObserver is the new interface, and the cart gains Subscribe() / Unsubscribe() methods. Concrete observers implement the interface.
Before / After Your Brain
Before This Level
You build objects that compute things when asked. Data goes stale between requests. Adding a new consumer means editing the producer.
After This Level
You think in events. "When X happens, who needs to know?" becomes your first question. Publishers fire events, subscribers react. Neither imports the other. Adding a new listener means zero changes to existing code.
Smell → Pattern:"Notify When Something Happens" — When multiple parts of the system need to react to changes in another part, and you don't want tight coupling between them → Observer pattern. The source publishes events, consumers subscribe. Nobody imports anybody else.
Transfer: Same technique in a Stock Trading App: the market feed publishes price ticks, and portfolio dashboards, alert systems, and automated traders all subscribe independently. Same in Chat Applications: when a message arrives, the conversation view, the notification badge, and the sound system all observe the same event. Same in Game Engines: when a player takes damage, the health bar, sound effects, and particle system all react through events.
Section 8
Level 5 — Edge Cases 🔴 HARD
New Constraint: "Everything that can go wrong, will go wrong. Out of stock after adding to cart. Coupon expired between adding and checkout. Quantity exceeds available stock. Two users add the last item simultaneously. Cart abandoned for 24 hours."
What breaks: Level 4's code handles the happy path beautifully — add items, apply discounts, get a total. But it assumes everything goes right. What happens when a user adds 3 laptops, but only 2 are in stock? What if two people click "Add to Cart" on the last item at the exact same millisecond? What if a coupon expires while the user is still shopping? Right now, these scenarios produce wrong totals, overselling, or silent data corruption.
Think First #7 — pause and think about what can break (60 seconds)
Use the "What If?" framework. For every operation in the cart (add, remove, apply coupon, checkout), ask four questions:
Concurrency: What if two users/threads do this at the same time?
Failure: What if the operation can't complete? (out of stock, payment fails)
Boundary: What if values are at extremes? (zero items, max quantity, empty cart)
Timing: What if things happen in an unexpected order? (coupon expires mid-session)
List at least 5 edge cases before reading on. The more you find, the more senior your thinking.
Your inner voice:
"Let me walk through the 'What If?' framework systematically..."
"Concurrency: Two users, one item left. Both click 'Add.' Without a lock, both succeed, and we've sold something we don't have. That's a race condition. I need atomic 'check + reserve' operations."
"Failure: Stock depletes after adding. Payment fails at checkout. The product gets discontinued. Each needs a clear error path — not just throwing exceptions, but returning a Result typeA Result type wraps either a success value or an error. Instead of throwing exceptions (which are invisible to the caller), you return a Result that forces the caller to handle both cases. In C#, this might be a record like Result<T> with IsSuccess, Value, and ErrorMessage properties. It makes error handling explicit and visible. so the caller MUST handle the failure."
"Boundary: Adding 0 items. Adding negative quantity. Cart with 10,000 items. Empty cart checkout. Each needs validation at the entry point."
"Timing: Coupon expires mid-session. Price changes during checkout. Cart sits idle for 24 hours. These need time-aware validation that runs at checkout, not just at add-time."
The "What If?" Framework
This is your edge-case discovery tool. For every operation, systematically ask these four questions. Senior engineers do this instinctively — it's a learned reflex, not a talent.
What Would You Do?
Two approaches to handling failures. One throws exceptions and hopes someone catches them. The other makes error handling explicit and impossible to ignore.
The idea: Throw exceptions when things go wrong. The caller must wrap everything in try/catch blocks.
ExceptionApproach.cs — throw and hope
public void AddItem(IProduct product, int quantity)
{
if (quantity <= 0)
throw new ArgumentException("Quantity must be positive");
if (_inventory.GetStock(product.Id) < quantity)
throw new OutOfStockException(product.Id);
// ... add the item
}
// Caller has to remember to catch:
try { cart.AddItem(laptop, 2); }
catch (OutOfStockException ex) { /* handle */ }
catch (ArgumentException ex) { /* handle */ }
// What if they forget? Runtime crash.
Verdict: Exceptions are invisible in the method signature. Nothing in AddItem(IProduct, int) tells the caller "this can fail in 3 different ways." Forgotten catch blocks mean runtime crashes. Exceptions are also expensive in .NET — throwing creates a full stack trace.
The idea: Return a Result typeA Result<T> wraps either a success value or an error message. The caller MUST check which one they got before using the value. This makes error handling explicit and compile-time visible, unlike exceptions which are invisible in method signatures. that forces the caller to handle both success and failure. No exceptions, no invisible failure paths.
ResultApproach.cs — explicit success or failure
public sealed record CartResult
{
public bool IsSuccess { get; init; }
public string? Error { get; init; }
public static CartResult Ok() => new() { IsSuccess = true };
public static CartResult Fail(string error) => new() { IsSuccess = false, Error = error };
}
public CartResult AddItem(IProduct product, int quantity)
{
if (quantity <= 0)
return CartResult.Fail("Quantity must be positive.");
if (_inventory.GetStock(product.Id) < quantity)
return CartResult.Fail($"Only {_inventory.GetStock(product.Id)} in stock.");
// ... add the item
return CartResult.Ok();
}
// Caller SEES the result type and must handle it:
var result = cart.AddItem(laptop, 2);
if (!result.IsSuccess)
ShowError(result.Error); // can't forget!
Verdict: This is the winner. The method signature tells you it can fail: it returns CartResult, not void. The caller gets the error message as data, not as a catch block they might forget. No stack trace overhead. No runtime surprises. The error path is as explicit as the happy path.
The Solution: Validation Pipeline
Instead of scattering validation checks throughout the cart, we build a validation pipeline. Each operation (add, remove, checkout) passes through a series of validation gates. If any gate fails, the operation stops with a clear error message. Think of it like airport security — you pass through multiple checkpoints, and any one of them can stop you.
CartResult.cs — success or failure, no ambiguity
public sealed record CartResult
{
public bool IsSuccess { get; init; }
public string? Error { get; init; }
public CartErrorCode Code { get; init; }
public static CartResult Ok()
=> new() { IsSuccess = true, Code = CartErrorCode.None };
public static CartResult Fail(string error, CartErrorCode code)
=> new() { IsSuccess = false, Error = error, Code = code };
}
public enum CartErrorCode
{
None,
OutOfStock,
InvalidQuantity,
CouponExpired,
CartEmpty,
StalePrice,
ConcurrencyConflict
}
ShoppingCart.cs — validated AddItem
public CartResult AddItem(IProduct product, int quantity)
{
// Gate 1: Input validation
if (quantity <= 0)
return CartResult.Fail(
"Quantity must be at least 1.",
CartErrorCode.InvalidQuantity);
// Gate 2: Stock check
var stock = _inventory.GetStock(product.Id);
if (stock < quantity)
return CartResult.Fail(
$"Only {stock} available (you requested {quantity}).",
CartErrorCode.OutOfStock);
// Gate 3: Reserve stock (atomic operation)
if (!_inventory.TryReserve(product.Id, quantity))
return CartResult.Fail(
"Another customer just took the last one!",
CartErrorCode.ConcurrencyConflict);
// All gates passed — add to cart
var item = new SingleItem(product, quantity);
_components.Add(item);
NotifyTotalChanged();
return CartResult.Ok();
}
Each gate checks one thing. If it fails, we return immediately with a clear error and code. The caller sees exactly what went wrong. Gate 3 is especially important — TryReserve() is an atomic operationAn atomic operation is one that either completes entirely or doesn't happen at all — there's no in-between state. "Check stock AND reserve" must happen as one indivisible step. If you check stock separately and then reserve, another thread can steal the item between the two steps. That's a race condition. that checks stock AND reserves in one step, preventing the race condition where two users both "see" the last item.
ShoppingCart.cs — checkout with re-validation
public CartResult Checkout()
{
// Gate 1: Not empty
if (!_components.Any())
return CartResult.Fail("Cart is empty.", CartErrorCode.CartEmpty);
// Gate 2: Re-validate all prices (might have changed since add)
foreach (var item in _components.OfType<SingleItem>())
{
var currentPrice = _catalog.GetCurrentPrice(item.Product.Id);
if (currentPrice != item.Product.Price)
return CartResult.Fail(
$"Price of {item.Name} changed from {item.Product.Price:C} to {currentPrice:C}. Please review.",
CartErrorCode.StalePrice);
}
// Gate 3: Re-validate all stock (might have depleted)
foreach (var item in _components.OfType<SingleItem>())
{
if (_inventory.GetStock(item.Product.Id) < item.Quantity)
return CartResult.Fail(
$"{item.Name} is no longer available in requested quantity.",
CartErrorCode.OutOfStock);
}
// Gate 4: Validate coupon hasn't expired
if (_discount is not null && _couponService.IsExpired(_discount.Code))
return CartResult.Fail(
$"Coupon '{_discount.Code}' has expired.",
CartErrorCode.CouponExpired);
// All gates passed — proceed to payment
return CartResult.Ok();
}
Checkout is the moment of truth — we re-validate everything. Prices may have changed since the user added items. Stock may have depleted. Coupons may have expired. Each gate catches a specific temporal edge case. The user sees a clear message ("Price changed, please review") instead of a mysterious failure at the payment gateway.
The Race Condition: Two Users, One Item
This is the most dangerous edge case. Without atomic "check + reserve," two users can both add the last item, and you've promised something you can't deliver.
The Validation Pipeline
Every cart operation passes through validation gates. Like airport security: each checkpoint looks for one specific problem. If any gate fails, the operation stops with a clear error.
Growing Diagram — Level 5
CartResult and the validation pipeline join the system. Every public method now returns a result type, and the inventory gains atomic reservation.
Before / After Your Brain
Before This Level
You code the happy path and "handle errors later" (which means never). Failures produce cryptic exceptions or silent corruption. Race conditions are invisible until production.
After This Level
You apply "What If?" to every operation. Return Result<T> so failures are visible. Use atomic operations for shared resources. Re-validate at checkout because time invalidates assumptions. The error path is as explicit as the happy path.
Smell → Pattern:"Happy Path Only" — When code handles success but not failure, and concurrent access is possible → Apply the "What If?" framework (Concurrency / Failure / Boundary / Timing). Return Result types instead of throwing exceptions. Use atomic operations for shared resources.
Section 9
Level 6 — Testability 🔴 HARD
New Constraint: "Write unit tests for the cart. Mock the pricing service (to test price changes without a real catalog). Mock the inventory (to test stock depletion without a real warehouse). Control time itself (to test coupon expiry without waiting 24 hours)."
What breaks: Level 5's cart directly calls _inventory.GetStock() and _catalog.GetCurrentPrice(). In a unit test, you don't have a real database, a real warehouse, or a real clock. If the cart creates its own dependencies (new InventoryService()), there's no way to swap them with fakes. The cart is untestable because it controls what it depends on.
Think First #8 — how would you test a cart without real services? (60 seconds)
The cart depends on inventory, catalog, and time. In production, these are real services. In tests, they need to be fakes. How do you make the cart use either one without changing its code?
Hint: the cart shouldn't create its dependencies. It should receive them. Who creates them? Someone else — the test or the DI container.
Your inner voice:
"The cart calls _inventory.GetStock(). In a test, I don't have a real inventory. If the cart does new InventoryService() inside itself, I can't replace it with a mock."
"But if the cart receives IInventoryService through its constructor, then in production I pass the real one, and in tests I pass a fake that returns whatever I want. The cart never knows the difference."
"That's Dependency InjectionDependency Injection (DI) means an object receives its dependencies from the outside instead of creating them. The constructor says "I need an IInventoryService" and whoever creates the object decides which implementation to provide. In production, it's the real service. In tests, it's a fake/mock. The object itself never changes.. Don't create your dependencies — receive them. And since the cart depends on interfaces (not concrete classes), any implementation that matches the interface will work. Including test fakes."
The Solution: Dependency Injection + Interface Segregation
The cart receives all its dependencies through the constructor. In production, the DI containerA DI container (also called IoC container) is a framework that automatically creates objects and wires their dependencies together. In .NET, you register interfaces and their implementations (e.g., "when someone asks for IInventoryService, give them WarehouseInventory"), and the container does the rest. No manual new-ing required. provides real services. In tests, you pass lightweight fakes that let you control everything.
ShoppingCart.cs — dependencies injected through constructor
public sealed class ShoppingCart
{
private readonly IInventoryService _inventory;
private readonly ICatalogService _catalog;
private readonly ICouponService _coupons;
private readonly ITimeProvider _time;
// Dependencies come FROM OUTSIDE — cart doesn't create them
public ShoppingCart(
IInventoryService inventory,
ICatalogService catalog,
ICouponService coupons,
ITimeProvider time)
{
_inventory = inventory ?? throw new ArgumentNullException(nameof(inventory));
_catalog = catalog ?? throw new ArgumentNullException(nameof(catalog));
_coupons = coupons ?? throw new ArgumentNullException(nameof(coupons));
_time = time ?? throw new ArgumentNullException(nameof(time));
}
// Now coupon expiry checks use _time.UtcNow instead of DateTime.UtcNow
// In tests: set _time to return any date you want!
}
The constructor is the cart's "menu of needs." It says: "Give me an inventory, a catalog, a coupon service, and a clock." It doesn't say which implementation of those interfaces to use. That decision belongs to whoever creates the cart — the DI container in production, the test setup in tests.
ITimeProvider.cs — control time in tests
/// <summary>
/// Abstracting time lets you test coupon expiry, cart abandonment,
/// and flash sales without actually waiting. In tests, you control the clock.
/// </summary>
public interface ITimeProvider
{
DateTimeOffset UtcNow { get; }
}
// Production: uses the real system clock
public sealed class SystemTimeProvider : ITimeProvider
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
// Tests: you control what "now" means
public sealed class FakeTimeProvider : ITimeProvider
{
public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UtcNow;
public void AdvanceBy(TimeSpan duration)
=> UtcNow = UtcNow.Add(duration);
}
Time is the sneakiest dependency. Code that calls DateTime.UtcNow directly is untestable — you can't make "now" be 3 AM on a Sunday in December. But by injecting ITimeProvider, you can set "now" to any moment you need. Want to test what happens when a coupon expires? fakeTime.AdvanceBy(TimeSpan.FromHours(25)). Done.
ShoppingCartTests.cs — testing with fakes
[Fact]
public void AddItem_WhenOutOfStock_ReturnsFailure()
{
// Arrange: fake inventory that says "0 in stock"
var fakeInventory = new FakeInventory(stock: 0);
var cart = new ShoppingCart(fakeInventory, fakeCatalog, fakeCoupons, fakeTime);
// Act
var result = cart.AddItem(laptop, quantity: 1);
// Assert
Assert.False(result.IsSuccess);
Assert.Equal(CartErrorCode.OutOfStock, result.Code);
}
[Fact]
public void Checkout_WhenCouponExpired_ReturnsFailure()
{
// Arrange: coupon valid NOW, but checkout happens LATER
var fakeTime = new FakeTimeProvider();
var cart = new ShoppingCart(fakeInv, fakeCat, fakeCoupons, fakeTime);
cart.AddItem(laptop, 1);
cart.ApplyCoupon("SAVE20"); // valid at this moment
// Act: fast-forward 25 hours
fakeTime.AdvanceBy(TimeSpan.FromHours(25));
var result = cart.Checkout();
// Assert: coupon expired!
Assert.False(result.IsSuccess);
Assert.Equal(CartErrorCode.CouponExpired, result.Code);
}
[Fact]
public void GetTotal_WithBundleDiscount_CalculatesCorrectly()
{
var cart = new ShoppingCart(fakeInv, fakeCat, fakeCoupons, fakeTime);
var bundle = new ProductBundle("Test Bundle", bundleDiscount: 50m);
bundle.Add(new SingleItem(new FakeProduct("A", 100m)));
bundle.Add(new SingleItem(new FakeProduct("B", 200m)));
cart.Add(bundle);
Assert.Equal(250m, cart.GetTotal()); // (100+200) - 50
}
Look at how clean these tests are. No real database. No real warehouse. No waiting for clocks. Each test controls exactly one variable and asserts exactly one outcome. The fakes make the cart completely deterministic — no flaky tests, no network calls, no side effects. This is what DI buys you: testable code that's easy to reason about.
DI Wiring: Production vs. Test
Test Isolation: Each Test Controls One Variable
Growing Diagram — Level 6
Before / After Your Brain
Before This Level
Your classes create their own dependencies with new. Testing means running the entire system. You can't isolate a single component.
After This Level
Dependencies arrive through the constructor. Every new in business logic is a smell. You test in isolation — swap any dependency with a fake, control every variable, assert every outcome. Even time is injectable.
Smell → Pattern:"Need One Instance But Testable" — When you need shared services but also need to test them in isolation → Dependency Injection via constructor + interfaces. Register as Singleton in the DI container (not static). Mock in tests.
Section 10
Level 7 — Scale It 🔴 HARD
New Constraint: "Multi-store marketplace: each seller has their own inventory, pricing, and shipping rules. Carts can contain items from multiple sellers. The cart must sync across devices (phone, laptop, tablet). The system must handle millions of concurrent active carts."
What breaks: Level 6's cart lives in one process on one server. It works beautifully for a single-store app. But a marketplace has thousands of sellers, each with different prices, stock levels, and discount rules. A user's cart on their phone needs to show the exact same items as on their laptop. And when millions of users are shopping simultaneously, one server can't hold all those carts in memory. Our clean in-memory design hits the wall of distributed systems.
Think First #9 — how would you scale this to millions of carts? (60 seconds)
Think about three scaling challenges: (1) Data: carts can't live in memory — where do they go? (2) Consistency: edits on phone must appear on laptop — how? (3) Throughput: millions of concurrent writes — one database can handle reads, but can it handle writes?
This is where LLD meets HLDLow-Level Design (LLD) focuses on class structure, patterns, and code. High-Level Design (HLD) focuses on system architecture: servers, databases, message queues, caching, and how services communicate over the network. The bridge is when your clean in-memory design needs to work across multiple machines.. Your clean patterns don't change — but HOW they communicate across machines does.
Your inner voice:
"Single server, single process — that's all my LLD assumed. Now I need multiple sellers with their own inventory, cross-device sync, and millions of carts. Let me think..."
"Multi-seller: Each seller is essentially their own 'store' with their own IInventoryService and pricing. The cart holds items from multiple sellers. At checkout, I split the order by seller. That's the marketplace patternIn a marketplace architecture, the platform (e.g., Amazon) hosts many sellers. Each seller manages their own inventory and pricing. A single customer cart can contain items from multiple sellers. At checkout, the order is split into sub-orders per seller, each processed independently. This is why Amazon packages arrive from different warehouses on different days.."
"Cross-device sync: The cart can't live only in memory — it needs persistent storage (Redis or a database). When the phone adds an item, an event fires, and the laptop's cart polls or listens for changes."
"Millions of carts: One database can't handle all writes. I'd shard carts by user ID — user 12345 always goes to shard 3. Or use an event-driven architecture where cart operations become events in a message queue. This is where my Observer pattern knowledge directly transfers to distributed systems!"
The Marketplace Architecture
In a marketplace, the platform hosts many sellers. Each seller manages their own products and inventory. A customer's cart pulls from multiple sellers, and at checkout, the order splits by seller — each handled independently. This is why your Amazon order often arrives in multiple packages from different warehouses.
Cross-Device Sync: Your Cart Follows You
The cart needs persistent storage so it's the same on every device. When you add an item on your phone, your laptop sees it instantly. This is where your Observer pattern knowledge transfers directly to distributed systems — events over a network instead of events within a process.
Growing Diagram — Level 7 (Final Architecture)
Notice the key insight: your LLD patterns don't change when you scale. The Composite pattern still computes bundle prices. Strategy still swaps discount algorithms. Observer still notifies listeners. What changes is the infrastructure around them: in-memory lists become Redis, method calls become API calls, and event handlers become message queue subscribers. Clean LLD is the foundation that makes HLD possible.
Before / After Your Brain
Before This Level
You see LLD and HLD as separate worlds. "Patterns are for interviews, servers are for production." You design a clean in-memory system and panic when asked "how does this scale?"
After This Level
You see the bridge: LLD patterns are the building blocks inside each service; HLD is how services talk to each other. Observer → Event Bus. DI Singleton → Service Mesh. In-memory cache → Redis. The patterns you learned scale because they're about structure, not size.
Transfer: Every case study ends at this bridge. Parking Lot: single-lot classes become a multi-lot chain via API gateway. Vending Machine: single machine state becomes a fleet managed by IoT events. Tic-Tac-Toe: local game becomes an online tournament via WebSocket events. The LLD patterns inside each service stay identical. The HLD around them changes.
Section 11
The Full Code — Everything Assembled
You've built this shopping cart piece by piece across seven levels. Now it's time to see the whole thing in one place. Every file below is the final, production-ready version — incorporating every pattern and refinement we discovered along the way.
Before we dive into the code, here's a bird's-eye view of every type in the system, color-coded by the level that introduced it. Green types appeared early (Levels 0–1), yellow ones came in the middle (Levels 2–4), and red ones were added in the advanced levels (5–7). Notice how the system grew organically — each type was forced into existence by a real constraint, not by upfront planning.
Now let's see the actual code. Each file is organized by responsibility — models in one place, discount strategies in another, composite components in a third. Click through the tabs to read each file.
Models.cs — All data types the system carries around
namespace ShoppingCart.Models;
// ─── IProduct ────────────────────────────────────────
// The contract every product must follow. // Level 1
// Physical goods, digital downloads, and subscriptions
// all look the same to the cart — it only cares about
// name, price, and whether the item needs shipping.
public interface IProduct
{
string Name { get; }
decimal Price { get; }
bool RequiresShipping { get; }
int StockCount { get; }
}
// ─── PhysicalProduct ────────────────────────────────
// Things you can hold: books, shoes, cables. // Level 1
// They need shipping and have limited stock.
public record PhysicalProduct(
string Name,
decimal Price,
double WeightKg,
int StockCount
) : IProduct
{
public bool RequiresShipping => true;
}
// ─── DigitalProduct ─────────────────────────────────
// Things you download: ebooks, music, software. // Level 1
// No shipping needed. Stock is effectively infinite.
public record DigitalProduct(
string Name,
decimal Price
) : IProduct
{
public bool RequiresShipping => false;
public int StockCount => int.MaxValue; // Level 5 — always in stock
}
// ─── SubscriptionProduct ────────────────────────────
// Recurring services: monthly plans, yearly passes. // Level 1
// Has a billing cycle and renewal date.
public record SubscriptionProduct(
string Name,
decimal Price,
string BillingCycle // "monthly" | "yearly"
) : IProduct
{
public bool RequiresShipping => false;
public int StockCount => int.MaxValue;
}
// ─── CartItem ───────────────────────────────────────
// Pairs a product with a quantity. This is what // Level 0
// actually lives inside the cart.
public class CartItem
{
public IProduct Product { get; } // Level 1 — was concrete Product
public int Quantity { get; private set; }
public CartItem(IProduct product, int quantity = 1)
{
Product = product ?? throw new ArgumentNullException(nameof(product));
Quantity = quantity > 0 ? quantity
: throw new ArgumentOutOfRangeException(nameof(quantity)); // Level 5
}
public decimal Subtotal => Product.Price * Quantity;
public void IncreaseQuantity(int amount = 1)
{
if (amount <= 0)
throw new ArgumentOutOfRangeException(nameof(amount)); // Level 5
Quantity += amount;
}
public void DecreaseQuantity(int amount = 1)
{
if (amount <= 0 || amount > Quantity)
throw new ArgumentOutOfRangeException(nameof(amount)); // Level 5
Quantity -= amount;
}
}
// ─── CartResult<T> ──────────────────────────────────
// A generic result wrapper — either a value or an error. // Level 5
// Used for operations that might fail (out of stock,
// expired coupon, concurrent modification).
public record CartResult<T>(T? Value, string? Error = null)
{
public bool IsSuccess => Error is null;
public static CartResult<T> Ok(T value) => new(value);
public static CartResult<T> Fail(string error) => new(default, error);
}
Everything here is either a data typeData types describe the SHAPE of information — what fields it has and what values are valid. They don't contain business logic (no decisions, no side effects). Think of them as forms: they define the blanks, not what you write in them. or a thin wrapper around data. The IProduct interface was born in Level 1 when we realized physical goods, digital goods, and subscriptions have different properties but the cart shouldn't care. CartResult<T> arrived in Level 5 when we needed a way to say "this operation failed, and here's why" instead of throwing exceptions everywhere.
namespace ShoppingCart.Discounts;
// ─── The contract for calculating discounts ──────────
// Any discount — percentage off, fixed amount, BOGO — // Level 2
// implements this one method. The cart doesn't care
// HOW the discount is calculated, only that it gets
// a dollar amount back.
public interface IDiscountStrategy
{
string Name { get; }
decimal CalculateDiscount(IReadOnlyList<CartItem> items);
bool IsExpired(DateTime now); // Level 5
}
// ─── PercentageDiscount ─────────────────────────────
// "20% off your entire cart." The most common type. // Level 2
public sealed class PercentageDiscount : IDiscountStrategy
{
private readonly decimal _percentage;
private readonly DateTime? _expiresAt; // Level 5
public string Name { get; }
public PercentageDiscount(string name, decimal percentage, DateTime? expiresAt = null)
{
if (percentage <= 0 || percentage > 100)
throw new ArgumentOutOfRangeException(nameof(percentage));
Name = name;
_percentage = percentage;
_expiresAt = expiresAt;
}
public decimal CalculateDiscount(IReadOnlyList<CartItem> items)
{
var subtotal = items.Sum(i => i.Subtotal);
return Math.Round(subtotal * _percentage / 100m, 2);
}
public bool IsExpired(DateTime now) =>
_expiresAt.HasValue && now > _expiresAt.Value; // Level 5
}
// ─── FixedAmountDiscount ────────────────────────────
// "$10 off your order." Simple, predictable. // Level 2
public sealed class FixedAmountDiscount : IDiscountStrategy
{
private readonly decimal _amount;
private readonly decimal _minimumOrder; // Level 5
private readonly DateTime? _expiresAt;
public string Name { get; }
public FixedAmountDiscount(string name, decimal amount,
decimal minimumOrder = 0m, DateTime? expiresAt = null)
{
_amount = amount > 0 ? amount
: throw new ArgumentOutOfRangeException(nameof(amount));
_minimumOrder = minimumOrder;
_expiresAt = expiresAt;
Name = name;
}
public decimal CalculateDiscount(IReadOnlyList<CartItem> items)
{
var subtotal = items.Sum(i => i.Subtotal);
if (subtotal < _minimumOrder) return 0m; // Level 5
return Math.Min(_amount, subtotal); // Never discount more than total
}
public bool IsExpired(DateTime now) =>
_expiresAt.HasValue && now > _expiresAt.Value;
}
// ─── BuyOneGetOneDiscount ───────────────────────────
// "Buy 2 of the same item, get the cheapest free." // Level 2
// The trickiest discount — it needs to look at
// individual items, not just the cart total.
public sealed class BuyOneGetOneDiscount : IDiscountStrategy
{
private readonly string _targetProductName;
private readonly DateTime? _expiresAt;
public string Name => $"BOGO on {_targetProductName}";
public BuyOneGetOneDiscount(string targetProductName, DateTime? expiresAt = null)
{
_targetProductName = targetProductName;
_expiresAt = expiresAt;
}
public decimal CalculateDiscount(IReadOnlyList<CartItem> items)
{
// Find the matching item
var target = items.FirstOrDefault(
i => i.Product.Name == _targetProductName);
if (target is null || target.Quantity < 2) return 0m;
// Every second item is free
int freeItems = target.Quantity / 2;
return freeItems * target.Product.Price;
}
public bool IsExpired(DateTime now) =>
_expiresAt.HasValue && now > _expiresAt.Value;
}
This is the Strategy patternThe Strategy pattern lets you define a family of algorithms, put each one in its own class, and make them interchangeable. The cart calls CalculateDiscount() without caring which strategy is behind it — Percentage, Fixed, or BOGO all look the same from the outside.. Three very different algorithms, one interface. The cart calls CalculateDiscount() and gets back a dollar amount — it has no idea whether the discount was a flat percentage, a fixed amount, or a complex "buy one get one" calculation. Adding a new discount type (like "tiered pricing" or "loyalty points") means adding one class. Zero changes to the cart.
CartComponents.cs — Composite pattern: items and bundles treated the same
namespace ShoppingCart.Components;
// ─── ICartComponent ─────────────────────────────────
// The Composite contract. Both individual items AND // Level 3
// bundles of items implement this. The cart can hold
// a mix of singles and bundles without caring which
// is which — it just asks "what's your price?"
public interface ICartComponent
{
string DisplayName { get; }
decimal GetPrice();
int GetItemCount();
IEnumerable<ICartComponent> GetChildren(); // Level 3
}
// ─── SingleItem ─────────────────────────────────────
// A leaf node: one product with a quantity. // Level 3
// This is the simplest component in the tree.
public sealed class SingleItem : ICartComponent
{
private readonly CartItem _item;
public SingleItem(CartItem item)
{
_item = item ?? throw new ArgumentNullException(nameof(item));
}
public string DisplayName =>
$"{_item.Product.Name} x{_item.Quantity}";
public decimal GetPrice() => _item.Subtotal;
public int GetItemCount() => _item.Quantity;
public IEnumerable<ICartComponent> GetChildren() =>
Enumerable.Empty<ICartComponent>(); // Leaf — no children
}
// ─── ProductBundle ──────────────────────────────────
// A composite node: a named group of items (or other // Level 3
// bundles!) sold together at a bundle price.
// Example: "Gaming Starter Pack" = keyboard + mouse + pad
public sealed class ProductBundle : ICartComponent
{
private readonly List<ICartComponent> _children = [];
private readonly decimal _bundleDiscount; // Level 3
public string DisplayName { get; }
public ProductBundle(string name, decimal bundleDiscountPercent = 0m)
{
DisplayName = name;
_bundleDiscount = bundleDiscountPercent;
}
// Add a single item or another bundle — tree structure!
public void Add(ICartComponent component)
{
// Level 5: prevent circular references
if (component == this)
throw new InvalidOperationException("A bundle cannot contain itself.");
if (component is ProductBundle bundle && ContainsRecursive(bundle, this))
throw new InvalidOperationException("Circular bundle reference detected.");
_children.Add(component);
}
public decimal GetPrice()
{
var rawTotal = _children.Sum(c => c.GetPrice());
var discount = rawTotal * _bundleDiscount / 100m;
return Math.Round(rawTotal - discount, 2);
}
public int GetItemCount() => _children.Sum(c => c.GetItemCount());
public IEnumerable<ICartComponent> GetChildren() => _children;
// Recursive check to prevent bundle-contains-itself loops // Level 5
private static bool ContainsRecursive(ProductBundle needle, ICartComponent haystack)
{
foreach (var child in haystack.GetChildren())
{
if (child == needle) return true;
if (child is ProductBundle pb && ContainsRecursive(needle, pb))
return true;
}
return false;
}
}
This is the Composite patternThe Composite pattern lets you build tree structures where individual objects and groups of objects are treated identically. Here, a SingleItem and a ProductBundle both implement ICartComponent. You can nest bundles inside bundles — the cart just calls GetPrice() on the root and the tree calculates itself.. A ProductBundle can contain SingleItems, other ProductBundles, or any mix. The cart doesn't need to know whether it's holding 5 individual items or 2 bundles containing 3 items each — it calls GetPrice() on each top-level component and the tree does the rest. The circular reference check in Add() was born from Bug Study #3 (Level 5) — without it, a bundle containing itself loops forever.
ShoppingCart.cs — The cart engine that ties everything together
namespace ShoppingCart;
// ─── ICartObserver ──────────────────────────────────
// Observers get notified when things happen in the cart. // Level 4
// Price drops, stock alerts, coupon events — any
// listener can subscribe without the cart knowing
// what they do with the information.
public interface ICartObserver
{
void OnItemAdded(CartItem item);
void OnItemRemoved(string productName);
void OnPriceChanged(string productName, decimal oldPrice, decimal newPrice);
void OnCartCleared();
}
// ─── ShoppingCart ───────────────────────────────────
// The orchestrator. Manages items, applies discounts, // Level 0
// notifies observers, and handles concurrent access.
public sealed class ShoppingCart
{
private readonly List<ICartComponent> _components = []; // Level 3 — was List<CartItem>
private readonly List<IDiscountStrategy> _discounts = [];// Level 2
private readonly List<ICartObserver> _observers = []; // Level 4
private readonly object _lock = new(); // Level 5
private readonly IPricingService _pricingService; // Level 6
private readonly IInventoryService _inventoryService; // Level 6
private readonly ITimeProvider _timeProvider; // Level 6
public ShoppingCart(
IPricingService pricingService,
IInventoryService inventoryService,
ITimeProvider timeProvider) // Level 6 — DI
{
_pricingService = pricingService;
_inventoryService = inventoryService;
_timeProvider = timeProvider;
}
// ─ Observer management ─ // Level 4
public void Subscribe(ICartObserver observer) =>
_observers.Add(observer);
public void Unsubscribe(ICartObserver observer) =>
_observers.Remove(observer);
private void NotifyItemAdded(CartItem item) =>
_observers.ForEach(o => o.OnItemAdded(item));
private void NotifyItemRemoved(string name) =>
_observers.ForEach(o => o.OnItemRemoved(name));
// ─ Add an item ─
public CartResult<CartItem> AddItem(IProduct product, int quantity = 1)
{
lock (_lock) // Level 5
{
// Validate stock
if (!_inventoryService.IsInStock(product.Name, quantity)) // Level 6
return CartResult<CartItem>.Fail(
$"Not enough stock for {product.Name}.");
// Check if item already in cart (as SingleItem)
var existing = FindExistingSingleItem(product.Name);
if (existing is not null)
{
existing.IncreaseQuantity(quantity);
return CartResult<CartItem>.Ok(existing);
}
// Add new item
var cartItem = new CartItem(product, quantity);
_components.Add(new SingleItem(cartItem));
NotifyItemAdded(cartItem);
return CartResult<CartItem>.Ok(cartItem);
}
}
// ─ Add a bundle ─ // Level 3
public CartResult<ProductBundle> AddBundle(ProductBundle bundle)
{
lock (_lock)
{
_components.Add(bundle);
return CartResult<ProductBundle>.Ok(bundle);
}
}
// ─ Remove an item by product name ─
public CartResult<bool> RemoveItem(string productName)
{
lock (_lock)
{
var component = _components.FirstOrDefault(c =>
c.DisplayName.StartsWith(productName));
if (component is null)
return CartResult<bool>.Fail($"'{productName}' not found in cart.");
_components.Remove(component);
NotifyItemRemoved(productName);
return CartResult<bool>.Ok(true);
}
}
// ─ Apply a discount ─ // Level 2
public CartResult<bool> ApplyDiscount(IDiscountStrategy discount)
{
if (discount.IsExpired(_timeProvider.UtcNow)) // Level 5 + Level 6
return CartResult<bool>.Fail(
$"Discount '{discount.Name}' has expired.");
_discounts.Add(discount);
return CartResult<bool>.Ok(true);
}
// ─ Calculate the final total ─
public decimal CalculateTotal()
{
lock (_lock)
{
var subtotal = _components.Sum(c => c.GetPrice());
var now = _timeProvider.UtcNow; // Level 6
// Remove expired discounts, then calculate // Level 5
_discounts.RemoveAll(d => d.IsExpired(now));
var totalDiscount = _discounts.Sum(d =>
d.CalculateDiscount(GetFlatItems()));
var finalTotal = subtotal - totalDiscount;
return Math.Max(0m, Math.Round(finalTotal, 2)); // Never go negative
}
}
// ─ Flatten the component tree into CartItems ─ // Level 3
private IReadOnlyList<CartItem> GetFlatItems()
{
var flat = new List<CartItem>();
FlattenRecursive(_components, flat);
return flat;
}
private static void FlattenRecursive(
IEnumerable<ICartComponent> components, List<CartItem> result)
{
foreach (var c in components)
{
if (c is SingleItem si)
result.Add(GetCartItemFromSingle(si));
else
FlattenRecursive(c.GetChildren(), result);
}
}
// ─ Helpers ─
private CartItem? FindExistingSingleItem(string productName) =>
_components.OfType<SingleItem>()
.Select(si => GetCartItemFromSingle(si))
.FirstOrDefault(ci => ci.Product.Name == productName);
private static CartItem GetCartItemFromSingle(SingleItem si)
{
// Uses reflection-free access via the DisplayName pattern
// In production, you'd expose CartItem directly on SingleItem
var field = typeof(SingleItem)
.GetField("_item", System.Reflection.BindingFlags.NonPublic
| System.Reflection.BindingFlags.Instance);
return (CartItem)field!.GetValue(si)!;
}
public IReadOnlyList<ICartComponent> Components => _components;
public int TotalItemCount => _components.Sum(c => c.GetItemCount());
public void Clear()
{
lock (_lock)
{
_components.Clear();
_discounts.Clear();
_observers.ForEach(o => o.OnCartCleared());
}
}
}
Notice how every pattern shows up here. The cart holds ICartComponent items (Composite), applies IDiscountStrategy algorithms (Strategy), notifies ICartObserver listeners (Observer), and uses dependency injectionDependency injection means the cart doesn't create its own services — it receives them through its constructor. This makes the cart testable (pass fake services in tests) and flexible (swap implementations without changing the cart). for pricing, inventory, and time (DI). The lock around every mutation protects against race conditionsA race condition happens when two threads access the same data at the same time and the result depends on which one finishes first. Without the lock, two users could both add the last item in stock, overselling the product. when multiple requests modify the same cart simultaneously.
Program.cs — DI wiring + demo usage
using Microsoft.Extensions.DependencyInjection;
using ShoppingCart;
using ShoppingCart.Models;
using ShoppingCart.Discounts;
using ShoppingCart.Components;
// ─── Service interfaces ─────────────────────────────
public interface IPricingService // Level 6
{
decimal GetCurrentPrice(string productName);
}
public interface IInventoryService // Level 6
{
bool IsInStock(string productName, int quantity);
void Reserve(string productName, int quantity);
}
public interface ITimeProvider // Level 6
{
DateTime UtcNow { get; }
}
// ─── Default implementations ────────────────────────
public class DefaultPricingService : IPricingService
{
public decimal GetCurrentPrice(string name) => 0m; // Placeholder
}
public class DefaultInventoryService : IInventoryService
{
public bool IsInStock(string name, int qty) => true;
public void Reserve(string name, int qty) { }
}
public class SystemTimeProvider : ITimeProvider // Level 6
{
public DateTime UtcNow => DateTime.UtcNow;
}
// ─── Sample observers ───────────────────────────────
public class PriceDropAlert : ICartObserver // Level 4
{
public void OnItemAdded(CartItem item) =>
Console.WriteLine($" [Alert] Watching '{item.Product.Name}' for price drops.");
public void OnItemRemoved(string name) =>
Console.WriteLine($" [Alert] Stopped watching '{name}'.");
public void OnPriceChanged(string name, decimal oldP, decimal newP) =>
Console.WriteLine($" [Alert] '{name}' price changed: {oldP:C} → {newP:C}!");
public void OnCartCleared() =>
Console.WriteLine(" [Alert] Cart cleared — all watches removed.");
}
// ─── Wire up DI ─────────────────────────────────────
var services = new ServiceCollection();
services.AddSingleton<IPricingService, DefaultPricingService>();
services.AddSingleton<IInventoryService, DefaultInventoryService>();
services.AddSingleton<ITimeProvider, SystemTimeProvider>();
var provider = services.BuildServiceProvider();
// ─── Build the cart ─────────────────────────────────
var cart = new ShoppingCart.ShoppingCart(
provider.GetRequiredService<IPricingService>(),
provider.GetRequiredService<IInventoryService>(),
provider.GetRequiredService<ITimeProvider>());
// Subscribe an observer
cart.Subscribe(new PriceDropAlert());
// ─── Add some products ──────────────────────────────
var book = new PhysicalProduct("Clean Code", 39.99m, 0.45, 100);
var ebook = new DigitalProduct("Design Patterns PDF", 29.99m);
var netflix = new SubscriptionProduct("Streaming Plan", 14.99m, "monthly");
cart.AddItem(book, 2);
cart.AddItem(ebook);
cart.AddItem(netflix);
// ─── Create a bundle ────────────────────────────────
var bundle = new ProductBundle("Dev Starter Pack", bundleDiscountPercent: 15);
bundle.Add(new SingleItem(new CartItem(
new PhysicalProduct("Mechanical Keyboard", 79.99m, 0.8, 50))));
bundle.Add(new SingleItem(new CartItem(
new PhysicalProduct("Mouse Pad XL", 19.99m, 0.3, 200))));
cart.AddBundle(bundle);
// ─── Apply a discount ───────────────────────────────
cart.ApplyDiscount(new PercentageDiscount(
"SUMMER20", 20, expiresAt: DateTime.UtcNow.AddDays(30)));
// ─── Print summary ──────────────────────────────────
Console.WriteLine($"\n🛒 Cart: {cart.TotalItemCount} items");
foreach (var component in cart.Components)
Console.WriteLine($" • {component.DisplayName} — {component.GetPrice():C}");
Console.WriteLine($"\n💰 Total after discounts: {cart.CalculateTotal():C}");
Console.WriteLine("Done!");
This is the composition rootThe composition root is the ONE place in your application where all dependencies get wired together. Everything else just declares what it NEEDS (through constructor parameters). Only Program.cs decides which concrete implementations to use. Swapping implementations (for testing or for different environments) requires changing just this one file. — the single place where concrete implementations are chosen and plugged together. Notice that ShoppingCart never mentions DefaultPricingService or SystemTimeProvider by name. It only knows about interfaces. The wiring happens here, at the top of the application.
Key Insight: Five files. 24 types. Four design patterns (Strategy, Composite, Observer, plus Iterator hiding in GetChildren()). Every single type was discovered by hitting a real constraint, not by reading a textbook and deciding to "apply" a pattern. That's the difference between knowing patterns and understanding them.
Section 12
Pattern Spotting — X-Ray Vision
You've been using design patterns for the last seven levels. But here's the interesting part: you might not have noticed all of them. Some patterns are obvious — we named them as we built them. Others are hiding in the code, doing their job quietly without anyone putting a label on them.
This section is about developing pattern recognitionThe ability to look at code and see the underlying design patterns at work. Senior engineers do this unconsciously — they glance at code and immediately see "that's a Strategy" or "that's a Composite." This skill comes from practice: once you've built patterns yourself, you spot them everywhere. — the skill of looking at code and seeing the structural bones underneath. Let's start with a challenge.
Think First #10
We explicitly named four patterns during the build: Strategy, Composite, Observer, and DI. But there are at least two more patterns hiding in our code that we never mentioned by name. Hint: look at how the cart walks through its component tree, and think about what happens when you stack multiple discounts on top of each other.
Take your time.
Reveal Answer
Iterator — The GetChildren() method on ICartComponent returns an IEnumerable, letting you walk through the tree one node at a time without knowing whether you're traversing a flat list or a deeply nested bundle-of-bundles. The FlattenRecursive helper in ShoppingCart uses this to convert the whole tree into a flat list of items for discount calculation. That's the Iterator pattern — providing a uniform way to traverse a collection regardless of its internal structure.
Decorator (Stacking) — When you call ApplyDiscount() multiple times, each discount wraps around the previous calculation. The "SUMMER20" coupon reduces the total, then the "BOGO" discount runs on the already-reduced item set. They stack like layers. While we didn't implement a classic Decorator class hierarchy, the behavior of chaining discounts is the Decorator pattern in spirit — each one takes the previous result and modifies it.
The Four Explicit Patterns
These are the patterns we named during the build. For each one, let's look at where it lives in the code, what it enables, and what would happen without it.
Strategy — Swappable Discount Algorithms
Where it lives:IDiscountStrategy + PercentageDiscount, FixedAmountDiscount, BuyOneGetOneDiscount
What it enables: Add a new discount type (loyalty points, tiered pricing, seasonal sale) by creating one new class. The cart never changes. Marketing can invent new promotions without a developer touching the core checkout logic.
Without it: A giant switch statement inside CalculateTotal() that grows every time someone dreams up a new coupon type. After 10 discount types, the switch is 200 lines and every change risks breaking an existing discount.
Composite — Items and Bundles as a Tree
Where it lives:ICartComponent + SingleItem (leaf) + ProductBundle (composite)
What it enables: A "Gaming Starter Pack" bundle can contain individual products AND another bundle (like an "Accessories Bundle"). The cart calls GetPrice() on each top-level component, and nested bundles calculate their own sub-totals recursively. No special cases, no depth limits.
Without it: Two separate lists — one for items, one for bundles — with duplicated logic for pricing, counting, and display. Nested bundles? Impossible. Three levels of nesting? A nightmare of special-case code.
Observer — React to Cart Events
Where it lives:ICartObserver + PriceDropAlert, StockNotifier, CouponWatcher
What it enables: When an item is added, the price tracker logs it. When stock changes, the notifier sends an alert. When a coupon expires, the watcher fires. None of these features exist inside the cart — they're plugged in from outside. Adding "send analytics to Mixpanel" means writing one new observer. Zero changes to the cart.
Without it: The cart would contain Console.WriteLine, email-sending code, analytics calls, and push notification logic. Every new "reaction to cart events" feature bloats the cart class. Testing the cart means dealing with email servers and analytics APIs.
Dependency Injection — Testable Services
Where it lives: The ShoppingCart constructor takes IPricingService, IInventoryService, and ITimeProvider.
What it enables: In tests, pass a fake time provider that always returns "January 1st" to test expiration logic deterministically. Pass a fake inventory service that always says "out of stock" to test error paths. The cart doesn't know or care whether it's talking to a real database or a test double.
Without it:DateTime.UtcNow hardcoded everywhere — making tests that depend on time unpredictable. new InventoryService() baked into the cart constructor — making it impossible to test without a real database connection.
How Patterns Interact at Runtime
Patterns don't work in isolation. When a customer adds an item and checks out, multiple patterns fire in sequence. Here's the interaction flow:
Hidden Patterns: We found 4 patterns hiding in the code that were never named during the build. Iterator emerged when we needed to walk the Composite tree. Decorator-style stacking emerged when we applied multiple discounts. Null Object hides in SingleItem.GetChildren() returning an empty enumerable instead of null. And Result<T> is a functional pattern — railway-oriented programming — that lets errors propagate cleanly without exceptions. Patterns aren't things you "decide to use" — they're structures that emerge naturally when you solve problems cleanly.
Section 13
The Growing Diagram — Complete Evolution
This is the visual summary of the entire Constraint Game. Watch the system grow from a single class to a 24-type architecture, one level at a time. Each stage adds exactly the types that were forced into existence by that level's constraint.
Think First #11
Look at the 8 stages below. Which single level added the most types? Why do you think that level needed so many new types compared to the others? And which level added zero new types but changed the most existing code?
60 seconds. Think about it before scrolling.
Reveal Answer
Level 1 added the most types (5 new types: IProduct, PhysicalProduct, DigitalProduct, SubscriptionProduct, CartItem updated). Product variety is the biggest source of complexity in a shopping system — different types have different shipping rules, stock rules, and pricing rules. Getting the product hierarchy right is foundational.
Level 5 added zero new types but changed the most existing code. Edge case handling (validation, expiration, stock checks) threads through everything. It doesn't create new abstractions — it hardens the existing ones. That's why edge case work feels exhausting: it's invisible, scattered, and touches every file.
Entity Summary Table
Here's every type in the system, what kind of type it is, which level introduced it, and why it's that kind of type. This table is your study guide for the entire Shopping Cart architecture.
Type
Kind
Level
Why This Kind?
Product
record
L0
Immutable data carrier — no behavior, just fields
CartItem
class
L0
Mutable quantity — needs methods like IncreaseQuantity()
ShoppingCart
class
L0
Orchestrator with mutable state (items list, observers, lock)
IProduct
interface
L1
Abstraction: cart doesn't care if product is physical or digital
PhysicalProduct
record
L1
Has weight + shipping, but still immutable data
DigitalProduct
record
L1
No shipping, infinite stock — simpler variant
SubscriptionProduct
record
L1
Recurring billing cycle — a product with time dimension
IDiscountStrategy
interface
L2
Strategy patternDefines a family of algorithms and makes them interchangeable. The cart calls CalculateDiscount() on whatever strategy is plugged in.: one interface, multiple implementations
PercentageDiscount
class
L2
Algorithm: "X% off total" — needs a percentage field
Composite patternLets individual items and groups of items implement the same interface. The cart can hold a mix of singles and bundles without knowing the difference.: uniform interface for singles and bundles
SingleItem
class
L3
Leaf node in the composite tree (wraps a CartItem)
ProductBundle
class
L3
Composite node: holds children (items or sub-bundles)
ICartObserver
interface
L4
Observer patternWhen the cart changes, it notifies all registered observers. Observers can react however they want — send alerts, log analytics, update UI — without the cart knowing about any of them.: decoupled event listeners
PriceDropAlert
class
L4
Observer: watches for price changes, sends alerts
StockNotifier
class
L4
Observer: alerts when stock is running low
CouponWatcher
class
L4
Observer: tracks coupon usage and expiration
CartResult<T>
record
L5
Result type: carry success value or error without exceptions
CartValidator
class
L5
Validates stock, expiration, quantity bounds
ConcurrentCartLock
class
L5
Thread safety wrapper for shared cart mutations
IPricingService
interface
L6
DI: injectable pricing (real vs. test)
IInventoryService
interface
L6
DI: injectable stock checks (real vs. fake)
ITimeProvider
interface
L6
DI: injectable clock (real vs. frozen for tests)
The big takeaway: 24 types sounds like a lot. But none of them were added "just because." Every type was forced into existence by a specific constraint. Remove any level, and the types it introduced become unnecessary. That's the difference between accidental complexityComplexity that comes from poor design choices — unnecessary abstractions, premature patterns, over-engineering. It can be removed without losing functionality. (types you added because you thought you should) and essential complexityComplexity that's inherent to the problem. A shopping cart with multiple product types, discount strategies, bundles, and concurrent access IS genuinely complex. The types reflect that reality, not a design choice. (types the problem demanded).
Section 14
Five Bad Solutions — Learn What NOT to Do
You've seen the good solution — built incrementally over 7 levels. Now let's study five bad approaches that people commonly reach for. Each one is tempting for a different reason, and each one breaks in a different way. We'll walk through the exact code, show you an SVG of the problem, and demonstrate the fix.
Bad Solution #1 — The God Class
What it is: Everything crammed into one massive class. Items, discounts, notifications, inventory checks, pricing logic — all inline, all tangled together. It looks efficient at first because there's "only one file to read."
GodClass.cs — Everything in one place
public class ShoppingCartSystem
{
private List<(string name, decimal price, int qty)> items = new();
private string discountType = "none";
private decimal discountValue = 0;
public void AddItem(string name, decimal price, int qty)
{
items.Add((name, price, qty));
Console.WriteLine($"Added {name}"); // UI coupled to logic
SendEmail($"Item added: {name}"); // Email in the cart?!
UpdateAnalytics("item_added", name); // Analytics too?!
}
public decimal CalculateTotal()
{
decimal total = items.Sum(i => i.price * i.qty);
// Discount logic inline — grows with every new type
if (discountType == "percent")
total -= total * discountValue / 100;
else if (discountType == "fixed")
total -= discountValue;
else if (discountType == "bogo")
{
// 50 lines of BOGO logic...
}
return total;
}
private void SendEmail(string msg) { /* SMTP code here */ }
private void UpdateAnalytics(string evt, string data) { /* HTTP call */ }
// ... 800 more lines of mixed concerns
}
The Moment It Dies: Marketing wants to add a "loyalty points" discount. The developer opens this 1200-line file, scrolls past email code, scrolls past analytics code, finds the if/else chain, adds an else if, accidentally breaks the BOGO calculation, and the bug isn't caught until Black Friday — costing the company $50,000 in incorrect discounts.
The fix: Apply the Single Responsibility PrincipleEvery class should have one reason to change. The cart manages items. Discounts calculate savings. Observers handle notifications. Each concern lives in its own class.. Split into ShoppingCart (manages items), IDiscountStrategy implementations (calculate discounts), ICartObserver implementations (handle notifications), and service interfaces for inventory and analytics. This is exactly what we built across Levels 0–6.
Maps to: SRP + Strategy + Observer + DI
Bad Solution #2 — The Over-Engineer
What it is: Every design pattern in the book, applied upfront before any real constraint demands it. Abstraction layers upon abstraction layers. The code technically follows every SOLID principle, but nobody can read it.
OverEngineered.cs — Patterns for the sake of patterns
// To add an item to the cart, you need:
public interface ICartItemFactory { CartItem Create(IProduct product); }
public interface ICartItemBuilder { ICartItemBuilder WithQuantity(int q); CartItem Build(); }
public interface ICartCommandHandler { void Handle(ICartCommand cmd); }
public interface ICartCommand { }
public record AddItemCommand(IProduct Product) : ICartCommand;
public interface ICartEventMediator { void Publish(ICartEvent e); }
public interface ICartEvent { }
public record ItemAddedEvent(CartItem Item) : ICartEvent;
public interface ICartRepository { void Save(ShoppingCart cart); }
public interface ICartUnitOfWork { void Commit(); }
// And the actual "add item" logic? Buried 12 layers deep:
public class AddItemCommandHandler : ICartCommandHandler
{
private readonly ICartItemFactory _factory;
private readonly ICartEventMediator _mediator;
private readonly ICartRepository _repo;
private readonly ICartUnitOfWork _uow;
public void Handle(ICartCommand cmd)
{
var item = _factory.Create(((AddItemCommand)cmd).Product);
// ... 6 more lines of delegation
_mediator.Publish(new ItemAddedEvent(item));
_uow.Commit();
}
}
// Total: 14 types to do what ONE method call achieves.
The Moment It Dies: A junior developer joins the team and needs to "change the discount to 15%." They spend 3 hours tracing through factories, builders, mediators, and command handlers before finding the one line that actually sets the discount value. They quit the next week.
The fix:YAGNI"You Aren't Gonna Need It." Don't add abstractions until a real constraint demands them. A Factory is great when you have 5 product types that need different construction logic. But if AddItem() is just one line, a Factory adds complexity with zero benefit. — You Aren't Gonna Need It. Start with the simplest code that works (Level 0). Add patterns ONLY when a constraint forces them. Our Constraint Game approach guarantees this: every pattern earned its place by solving a real problem.
Maps to: YAGNI + incremental design
Bad Solution #3 — The Happy-Path Hero
What it is: Actually well-structured! Clean patterns, good naming. Looks production-ready. But: no error handling, no concurrency protection, no edge cases. Works perfectly in development. First week in production: data corruption.
HappyPath.cs — Clean but fragile
public class ShoppingCart
{
private List<ICartComponent> _components = new();
private List<IDiscountStrategy> _discounts = new();
// No lock — concurrent access will corrupt state
public void AddItem(IProduct product, int qty)
{
_components.Add(new SingleItem(new CartItem(product, qty)));
// No stock check — what if item is out of stock?
// No validation — what if qty is negative?
}
public void ApplyDiscount(IDiscountStrategy discount)
{
_discounts.Add(discount);
// No expiration check — expired coupons still apply!
}
public void AddBundle(ProductBundle bundle)
{
_components.Add(bundle);
// No circular reference check — bundle-in-bundle loops forever
}
public decimal CalculateTotal()
{
var subtotal = _components.Sum(c => c.GetPrice());
var discount = _discounts.Sum(d => d.CalculateDiscount(/*...*/));
return subtotal - discount;
// Can go negative! No Math.Max(0, ...) guard
}
}
Why It's the Most Dangerous: Solutions #1 and #2 are obviously bad. A code reviewer catches them in 5 minutes. But this? It looks professional. Clean patterns, good naming, proper interfaces. It passes code review. It passes unit tests (because the tests are also happy-path). The bugs only appear under real traffic with real concurrency and real expired coupons.
The fix: Apply Level 5's What If? frameworkBefore calling any feature "done," ask: What if stock is zero? What if the coupon expired? What if two requests hit at the same time? What if a bundle contains itself? Every "What If?" becomes a guard clause, a lock, or a validation check.. Add lock around shared state, CartResult<T> for error propagation, validation in AddItem(), expiration checks in ApplyDiscount(), and circular reference detection in AddBundle().
Maps to: Level 5 (Edge Cases) + Level 6 (DI for testable time)
Bad Solution #4 — The Flat Cart Only
What it is: The cart only supports individual items. No bundles, no grouping. Everything is a flat list. When the business says "sell a Gaming Starter Pack as a combo," the developer hacks it by creating a single product called "Gaming Starter Pack" with a manually calculated price. Bundles-within-bundles? Impossible.
FlatCart.cs — No bundle support
public class ShoppingCart
{
private List<CartItem> _items = new(); // Flat list only
public void AddItem(IProduct product, int qty)
{
_items.Add(new CartItem(product, qty));
}
// Want to add a "Gaming Pack" bundle?
// Hack: create a fake product with a manually set price
public void AddBundle(string bundleName, decimal bundlePrice)
{
var fakeProduct = new PhysicalProduct(
bundleName, bundlePrice, 0, 999);
_items.Add(new CartItem(fakeProduct));
// Problem: what's IN the bundle? Nobody knows.
// Problem: bundle discount applied how? It's just a product.
// Problem: stock for bundle items? Not tracked individually.
}
public decimal Total => _items.Sum(i => i.Subtotal);
// Can't show "Gaming Pack contains: Keyboard, Mouse, Pad"
// Can't apply bundle-specific discounts
// Can't nest bundles inside bundles
}
The Moment It Dies: The business launches "Back to School" with a "Student Bundle" containing a "Tech Essentials" sub-bundle and a "Books" sub-bundle. The flat cart can't represent this at all. The workaround: three separate "fake" products with manually calculated prices that drift out of sync whenever a sub-item's price changes.
The fix: Introduce the Composite patternThe Composite pattern lets you build tree structures where individual items and groups of items implement the same interface (ICartComponent). A SingleItem is a leaf. A ProductBundle is a node that contains children — which can be other SingleItems or other ProductBundles. The tree can be any depth. (Level 3). Replace List<CartItem> with List<ICartComponent>. Both SingleItem and ProductBundle implement ICartComponent. Bundles can contain items, other bundles, or any mix. GetPrice() calculates recursively. The cart doesn't care about the tree depth.
Maps to: Level 3 (Composite Pattern)
Bad Solution #5 — Polling Instead of Observer
What it is: Instead of the cart telling interested parties when something changes, interested parties ask the cart every 5 seconds: "Did anything change? How about now? Now?" This is called pollingPolling means repeatedly checking for changes at fixed intervals, whether or not anything actually changed. It wastes resources when nothing changed and responds slowly when something did. The alternative is event-driven: the source TELLS you when something happens., and it's the opposite of the Observer pattern.
PollingCart.cs — Checking every 5 seconds
public class PriceCheckerService
{
private readonly ShoppingCart _cart;
private Dictionary<string, decimal> _lastKnownPrices = new();
// Runs on a background timer every 5 seconds
public async Task PollForChanges()
{
while (true)
{
await Task.Delay(5000); // Wait 5 seconds
foreach (var item in _cart.GetAllItems())
{
var currentPrice = GetCurrentPriceFromDb(item.Name);
if (_lastKnownPrices.TryGetValue(item.Name, out var oldPrice)
&& currentPrice != oldPrice)
{
Console.WriteLine($"Price changed: {item.Name}!");
// 5 seconds late — user might have already checked out
}
_lastKnownPrices[item.Name] = currentPrice;
}
// This runs 12 times/minute per user.
// 1000 users = 12,000 DB queries/minute.
// For something that changes maybe once a day.
}
}
}
The Moment It Dies: Black Friday. 50,000 concurrent users. The polling service fires 600,000 database queries per minute, 99.9% returning "no change." The database buckles. Response times spike from 50ms to 8 seconds. The checkout page times out. Revenue drops $200,000 in the first hour.
The fix: Replace polling with the Observer patternInstead of asking "did anything change?" every 5 seconds, the cart TELLS observers when something changes. OnPriceChanged fires instantly — no delay, no wasted queries, no polling overhead.. The cart fires OnPriceChanged() at the exact moment the price changes. Zero queries when nothing happens. Instant notification when something does. The database load drops from 600,000 queries/minute to near-zero.
Maps to: Level 4 (Observer Pattern)
Think First #12
Which of the five bad solutions is the most dangerous? Why?
Reveal Answer
Bad Solution #3 (Happy-Path Hero) is the most dangerous. Solutions #1 (God Class) and #2 (Over-Engineer) are obviously wrong — any code reviewer catches them in minutes. Solution #4 (Flat Cart) hits a wall as soon as bundles are needed. Solution #5 (Polling) shows up in load testing.
But #3? It looks professional. Clean patterns, good naming, proper interfaces. It passes code review. It passes unit tests (which are also happy-path). The bugs only surface under real production traffic — concurrent users, expired coupons, out-of-stock races. By then, the damage is done. A wolf in sheep's clothing is always more dangerous than an obvious wolf.
Section 15
Code Review Challenge — Find 5 Bugs
A candidate submitted this Shopping Cart implementation as a pull request. It compiles. It runs. It handles a basic add-items-and-checkout flow. But there are exactly 5 bugs hiding in it — issues that would cause real problems in production. Can you find them all before scrolling down?
Read the code below carefully. Try to find all 5 issues before revealing the answers.
CandidateCartSolution.cs — Find 5 Bugs
public class ShoppingCart // Line 1
{
private List<CartItem> _items = new(); // Line 3
private List<IDiscountStrategy> _discounts = new();
private List<ICartObserver> _observers = new();
public void AddItem(IProduct product, int quantity) // Line 7
{
var item = new CartItem(product, quantity);
_items.Add(item);
foreach (var obs in _observers) // Line 11
obs.OnItemAdded(item);
}
public void RemoveItem(string productName) // Line 14
{
_items.RemoveAll(i => i.Product.Name == productName);
foreach (var obs in _observers)
obs.OnItemRemoved(productName);
}
public void Subscribe(ICartObserver observer)
{
_observers.Add(observer);
}
// Note: no Unsubscribe method // Line 23
public void ApplyDiscount(IDiscountStrategy discount) // Line 25
{
_discounts.Add(discount);
}
public double GetTotal() // Line 29
{
double subtotal = 0;
foreach (var item in _items)
subtotal += (double)item.Product.Price * item.Quantity; // Line 32
foreach (var discount in _discounts)
subtotal -= (double)discount.CalculateDiscount(_items); // Line 34
return subtotal;
}
public void AddBundle(ProductBundle bundle) // Line 38
{
foreach (var child in bundle.GetChildren())
{
if (child is SingleItem si) // Line 41
_items.Add(GetCartItemFromSingle(si));
// Ignores nested bundles — flattens only one level
}
}
}
Problem: The cart uses a plain List<CartItem> with no synchronization. In a web application, the same user's cart can be modified by multiple HTTP requests simultaneously — for example, the user adds an item on their phone while their desktop tab removes an item. Two threads modifying the same List at the same time can corrupt the internal array, skip items, or throw InvalidOperationException.
Bug: No lock around shared state
// Two threads can call AddItem() at the same time
public void AddItem(IProduct product, int quantity)
{
var item = new CartItem(product, quantity);
_items.Add(item); // NOT thread-safe!
// Thread A adds at index 5
// Thread B adds at index 5 at the same time
// Result: one item lost, or internal array corrupted
}
Fix: Wrap mutations in a lock
private readonly object _lock = new();
public CartResult<CartItem> AddItem(IProduct product, int quantity)
{
lock (_lock) // Only one thread at a time
{
var item = new CartItem(product, quantity);
_items.Add(item);
NotifyItemAdded(item);
return CartResult<CartItem>.Ok(item);
}
}
Taught in: Level 5 — Concurrent cart handling
Bug #2 — Decimal Precision: Using double for Money (Lines 29-34)
Problem: The GetTotal() method returns double and casts prices to double for arithmetic. Floating-pointComputers store decimal numbers in binary. Some values that look simple in decimal (like 0.1) become infinitely repeating fractions in binary — just like 1/3 is infinitely repeating in decimal. This means double arithmetic can produce tiny rounding errors: 0.1 + 0.2 = 0.30000000000000004, not 0.3. For money, those tiny errors accumulate across thousands of transactions. numbers can't represent all decimal values exactly. The classic example: 0.1 + 0.2 = 0.30000000000000004 in double. For a shopping cart, this means the total might be off by fractions of a cent — and across thousands of orders, those fractions add up to real money.
Bug: double loses precision for money
public double GetTotal()
{
double subtotal = 0;
foreach (var item in _items)
subtotal += (double)item.Product.Price * item.Quantity;
// $19.99 * 3 = 59.970000000000006 (not 59.97!)
// Over 10,000 orders: off by $47.32
return subtotal;
}
Fix: Use decimal (designed for financial math)
public decimal CalculateTotal()
{
var subtotal = _items.Sum(i => i.Product.Price * i.Quantity);
// decimal stores values as base-10, not base-2
// $19.99 * 3 = 59.97 (exactly!)
var discounts = _discounts.Sum(d => d.CalculateDiscount(_items));
return Math.Max(0m, Math.Round(subtotal - discounts, 2));
}
Rule of thumb: In C#, always use decimal for money. The decimal type stores values in base-10 (not base-2), so $19.99 is represented exactly. The m suffix (19.99m) tells the compiler to use decimal instead of double.
Problem: The AddBundle() method flattens a bundle by iterating through its children, but only handles one level deep. If a bundle contains a sub-bundle, the nested items are silently ignored. Even worse: there's no protection against a bundle being added to itself. If bundleA.Add(bundleA) happens, calling GetPrice() on that bundle enters infinite recursionInfinite recursion happens when a method calls itself (directly or indirectly) without ever stopping. Each call uses memory on the "call stack." After ~1000 nested calls, the stack overflows and the program crashes with a StackOverflowException. A bundle that contains itself will keep calling GetPrice() on itself forever. and crashes with StackOverflowException.
Bug: No circular reference check
var bundle = new ProductBundle("Gaming Pack");
bundle.Add(new SingleItem(keyboard));
bundle.Add(bundle); // Oops! Bundle contains itself!
// Later:
bundle.GetPrice();
// GetPrice() calls children.Sum(c => c.GetPrice())
// One child IS the bundle itself
// So it calls GetPrice() again... and again... and again
// 💥 StackOverflowException
Fix: Check for circular references on Add()
public void Add(ICartComponent component)
{
// Direct self-reference
if (component == this)
throw new InvalidOperationException(
"A bundle cannot contain itself.");
// Indirect circular reference (A contains B contains A)
if (component is ProductBundle pb
&& ContainsRecursive(this, pb))
throw new InvalidOperationException(
"Circular bundle reference detected.");
_children.Add(component);
}
private static bool ContainsRecursive(
ProductBundle needle, ICartComponent haystack)
{
foreach (var child in haystack.GetChildren())
{
if (child == needle) return true;
if (child is ProductBundle pb
&& ContainsRecursive(needle, pb))
return true;
}
return false;
}
Bug #4 — Observer Memory Leak: Never Unsubscribing (Line 23)
Problem: The cart has a Subscribe() method but no Unsubscribe(). Once an observer is added, it can never be removed. In a web application, if each HTTP request creates a new observer and subscribes it, the observer list grows forever. After 10,000 requests, the cart is holding references to 10,000 dead observer objects that the garbage collectorThe garbage collector (GC) in .NET automatically frees memory when objects are no longer referenced. But if the cart's _observers list holds a reference to an observer, the GC can't reclaim it — even if nothing else in the application uses that observer. This is called a memory leak: memory that's allocated but can never be freed. can't reclaim — a classic memory leak.
Bug: No way to unsubscribe
public void Subscribe(ICartObserver observer)
{
_observers.Add(observer);
}
// No Unsubscribe method!
// In a web app:
// Request 1: Subscribe(new PriceAlert()) → list has 1 observer
// Request 2: Subscribe(new PriceAlert()) → list has 2 observers
// Request 10000: Subscribe(new PriceAlert()) → list has 10000 observers
// Memory usage: climbing, never drops
// Each AddItem() now fires 10000 notifications
// Performance: degrading, eventually OutOfMemoryException
Fix: Add Unsubscribe + weak references
public void Subscribe(ICartObserver observer) =>
_observers.Add(observer);
public void Unsubscribe(ICartObserver observer) =>
_observers.Remove(observer);
// Alternatively, use the IDisposable pattern:
public IDisposable Subscribe(ICartObserver observer)
{
_observers.Add(observer);
return new Unsubscriber(_observers, observer);
}
private class Unsubscriber(
List<ICartObserver> observers,
ICartObserver observer) : IDisposable
{
public void Dispose() => observers.Remove(observer);
}
// Usage:
using var subscription = cart.Subscribe(new PriceAlert());
// Automatically unsubscribes when the scope ends
Bug #5 — Stale Discount: Applying Expired Coupons (Line 25)
Problem: The ApplyDiscount() method blindly adds any discount to the list without checking whether it has expired. A coupon that was valid yesterday but expired at midnight today is still accepted. Worse: even during GetTotal(), expired discounts are never filtered out. If a user adds a coupon at 11:59 PM and checks out at 12:01 AM, the expired discount is still applied.
Bug: No expiration check
public void ApplyDiscount(IDiscountStrategy discount)
{
_discounts.Add(discount);
// No check: is this coupon still valid?
// "SUMMER20" expired 3 days ago — still applied!
// "BLACKFRIDAY" was a 24-hour sale — still applied on Saturday!
}
public double GetTotal()
{
// ...
foreach (var discount in _discounts)
subtotal -= (double)discount.CalculateDiscount(_items);
// No re-check at checkout time either!
// Coupon could expire BETWEEN adding to cart and checking out
}
Fix: Check at apply time AND at checkout time
public CartResult<bool> ApplyDiscount(IDiscountStrategy discount)
{
// Check at apply time
if (discount.IsExpired(_timeProvider.UtcNow))
return CartResult<bool>.Fail(
$"Discount '{discount.Name}' has expired.");
_discounts.Add(discount);
return CartResult<bool>.Ok(true);
}
public decimal CalculateTotal()
{
var now = _timeProvider.UtcNow;
// Re-check at checkout time — might have expired since applying
_discounts.RemoveAll(d => d.IsExpired(now));
var subtotal = _components.Sum(c => c.GetPrice());
var discount = _discounts.Sum(d =>
d.CalculateDiscount(GetFlatItems()));
return Math.Max(0m, Math.Round(subtotal - discount, 2));
}
// ITimeProvider is injected (DI) — testable!
// In tests: pass a fake clock that returns any date you want
Score Yourself: All 5: Senior-level code review skills. You see concurrency bugs, precision bugs, AND design flaws. 3-4: Solid mid-level. You caught the obvious ones — review the levels for the ones you missed. 1-2: Go back to Levels 2-5. The patterns will click once you've seen the problems they prevent. 0: No shame — code review is a skill you build with practice. Re-read this section after finishing the case study.
The real lesson: Notice the pattern. Bug #1 (thread safety) and #2 (decimal precision) are invisible in code review unless you know what to look for — the code looks correct at first glance. Bug #3 (composite loop) requires understanding recursive data structures. Bug #4 (observer leak) only manifests after thousands of requests. Bug #5 (expired discount) only matters at the boundary between "add to cart" and "checkout." The hardest bugs aren't in the logic — they're in the assumptions the developer made about how the system will be used.
Section 16
The Interview — Both Sides of the Table
Shopping Cart is one of the most popular LLD interview questions because it looks deceptively simple. "Add items, calculate total, checkout." But the interviewer is watching for how you discover complexity — bundles, discounts, observers, concurrency. Below are two 30-minute runs: the polished one and the messy-but-honest one. Both get hired.
Time
Candidate Says
Interviewer Thinks
0:00
"Before I start coding, let me clarify scope. Are we handling individual items only, or also bundles (like a gaming bundle with console + controller)? Are there discount strategies — percentage, flat, buy-one-get-one? Is this single-seller or marketplace?"
Excellent — scoping reveals bundles and discounts as first-class concerns. Most candidates miss both.
2:00
"Functional: add/remove items, support bundles of items, apply discount strategies, calculate total, checkout. Non-functional: extensible discounts (new ones without changing cart), observable (UI/analytics react to changes), thread-safe for concurrent access."
F/NF split with extensibility and observability called out. Senior-level framing.
4:00
"Entities: IProduct (interface — PhysicalProduct, DigitalProduct, GiftCard). ICartComponent for Composite — both a single item and a bundle implement it, so GetPrice() works recursively. ShoppingCart owns a list of ICartComponent."
Composite pattern identified from entity modeling, not name-dropped. IProduct vs ICartComponent distinction is sharp.
6:00
"Discounts vary independently from the cart — percentage off, flat amount off, buy-one-get-one. That's Strategy. I'll define IDiscountStrategy with ApplyDiscount(items). New discount = new class, zero changes to cart."
Pattern motivated by 'what varies independently.' Clean OCP reasoning.
8:00
"For live updates — price changes, stock alerts, analytics — I'll use Observer. The cart is the subject. UI, analytics, and stock checker subscribe. When items change, all observers get notified."
Three patterns (Composite, Strategy, Observer), each justified by a different need. Not over-engineering.
"Edge cases: adding an item that's out of stock — Result.Fail(). A bundle containing itself — cycle detection in AddComponent(). Two users grabbing the last item — optimistic concurrency with version checks. Empty cart checkout — rejected before payment."
Four proactive edge cases including cycle detection. Most candidates miss the bundle-containing-itself trap entirely.
26:00
"For scaling: multi-seller marketplace means each seller has their own inventory service. Cart becomes a MarketplaceCart that groups items by seller for separate checkout streams. Discount strategies need seller-scoping. This bridges to HLD — each seller is a microservice."
LLD → HLD bridge with marketplace scoping. Strong Hire.
Time
Candidate Says
Interviewer Thinks
0:00
"Shopping cart... OK, so we have items with prices. I'll make a List<Product> and sum up the prices..."
Jumped to implementation. Let's see if they recover.
1:30
"Actually — wait. Let me ask first: do we need bundles? Like combo deals? And are there different discount types?"
Self-corrected. Asking about bundles unprompted is a strong signal.
4:00
"I'll have a Product class and a Bundle class that contains products. But wait — what if a bundle contains other bundles? Like a 'Super Bundle' with a 'Gaming Bundle' inside it. I need them to share an interface..."
Discovered Composite organically! Better than memorizing it — shows real-time design thinking.
7:00
"For discounts, I could use a switch on discount type... hmm, but the PM says 'we add new promotions every week.' A switch would grow forever. Let me make each discount its own class with a shared interface."
Explored the naive path, felt the OCP violation, upgraded to Strategy. That's engineering judgment.
12:00
Coding... pauses... "I'm not sure how to handle GetPrice() on a bundle. Do I sum children? What about bundle discounts?"
Pausing is fine. Thinking about recursive pricing is the right question.
13:00
"OK — ProductBundle.GetPrice() sums GetPrice() on each child, then applies its own bundle discount. The recursion handles nested bundles automatically. Each leaf knows its own price."
Nailed Composite recursion after a brief pause. Clear explanation of the tree walk.
20:00
Interviewer: "What if two users add the last item at the same time?"
Testing concurrency awareness — most candidates freeze.
20:30
"Oh — that's a race condition on inventory. I'd use optimistic concurrency: check stock, reserve with a version number, and if the version changed between check and reserve, return Result.Fail("Out of stock") instead of overselling."
Needed a nudge but responded immediately with optimistic concurrency + Result<T>. Solid.
26:00
"For extensibility: Observer pattern for notifying UI and analytics when cart changes. For marketplace: group items by seller, checkout per seller. I'd also add a CartResult<T> wrapper so callers never deal with raw exceptions."
Covered Observer, marketplace scaling, and Result pattern. Strong Hire despite the rocky start.
Scoring Summary
The Clean Run
Strong Hire
Scoped bundles + discounts + marketplace upfront
F/NF split with extensibility and observability
Three patterns, each motivated by a different problem
Four proactive edge cases including cycle detection
Marketplace scaling bridge to HLD
The Realistic Run
Strong Hire
Slow start — recovered by asking about bundles
Discovered Composite organically from nested bundles
Explored switch → felt OCP pain → upgraded to Strategy
Needed nudge on concurrency — responded with optimistic locking
Honest, structured recovery throughout
The CREATES Timeline
Both runs followed the same invisible structure — CREATES. Here's how it maps to the 30-minute interview.
Common Follow-up Questions Interviewers Ask
"How would you handle bundles inside bundles?" — Tests if Composite supports recursion or is flat
"What if a discount depends on cart total, not per-item?" — Tests if Strategy receives enough context
"How do you prevent a bundle from containing itself?" — Tests cycle detection awareness
"What if prices change while the user is shopping?" — Tests Observer + staleness handling
"How would you add A/B testing for discounts?" — Tests Strategy composition + analytics
Key Takeaway: Two very different styles. Same outcome.
Interviewers don't grade on polish — they grade on THINKING.
A stumble you recover from is often more impressive than a flawless run — because it shows how you handle real-world ambiguity.
Section 17
Articulation Guide — What to SAY
Knowing a great Shopping Cart design and communicating it under interview pressure are two different skills. You can have the perfect Composite + Strategy + Observer architecture in your head and still tank because you can’t narrate it clearly. These 8 cards cover the moments where exact phrasing matters most.
1. Opening the Problem
Situation: The interviewer says “Design a Shopping Cart.” You feel the pull to start with List<Product>.
Say: “Before I code, let me scope this. Are we handling just individual items, or also product bundles? Are there discount strategies — percentage, flat, BOGO? Is this single-seller or marketplace? Does the UI need live updates when the cart changes?”
Don’t say: “OK so I’ll make a Product class and a Cart with a List...” (jumping to implementation)
Why it works: Each question reveals a design concern — bundles expose Composite, discounts expose Strategy, live updates expose Observer. You're showing the interviewer you know WHERE complexity hides.
2. Entity Decisions
Situation: You’re deciding how to model products, bundles, and the cart.
Say: “IProduct is an interface because physical goods, digital goods, and gift cards have different shipping/delivery behavior. ICartComponent is the Composite interface — both a single item and a bundle implement GetPrice(), so the cart doesn’t care whether it’s holding one book or a bundle of 10.”
Don’t say: “I’ll use a Product class.” (no reasoning about type decisions)
Why it works: Shows you think about WHY something is an interface vs a class. The IProduct vs ICartComponent split proves you understand the Composite motivation.
3. Composite for Bundles
Situation: The interviewer asks “Why not just a flat list of items?”
Say: “A flat list can’t represent a ‘Gaming Bundle’ that itself contains a ‘Starter Pack’ bundle. Composite gives me a tree where every node — leaf or branch — implements GetPrice(). The cart just calls GetPrice() on its top-level items and the recursion handles nesting. Adding a new bundle depth requires zero code changes.”
Don’t say: “I’ll use Composite because we have bundles.” (circular reasoning, no depth)
Why it works: You named the SPECIFIC scenario (nested bundles), explained WHY a flat list breaks, and showed the payoff (zero changes for deeper nesting).
4. Strategy for Discounts
Situation: The interviewer pushes: “Why not just pass a discount type enum and use a switch?”
Say: “Each discount type uses genuinely different logic: PercentageDiscount multiplies by a factor, FlatDiscount subtracts a fixed amount, BOGODiscount finds pairs of eligible items. An enum+switch grows with every new promotion the marketing team invents. Strategy lets each discount be its own class. Add ‘Spend $100, get $15 off’ next Tuesday — one new class, zero changes to cart.”
Don’t say: “Strategy is the standard pattern for varying algorithms.” (textbook, no connection to the problem)
Why it works: You showed three concretely different algorithms, named a realistic future requirement, and quantified the cost of change (one class, zero edits).
5. Observer for Live Updates
Situation: The interviewer asks “How does the UI know the cart changed?”
Say: “The cart doesn’t know about the UI at all. It just fires an event — ‘cart changed.’ Whoever cares — the price display, the item counter badge, the analytics tracker — subscribes to that event. Adding a new subscriber means zero changes to the cart. That’s the Observer pattern.”
Don’t say: “I’ll use Observer for notifications.” (names pattern without explaining the decoupling benefit)
Why it works: The phrase "the cart doesn't know about the UI at all" is the insight. It shows you understand loose coupling, not just the pattern name.
6. Edge Cases
Situation: You’ve finished the happy path. Most candidates stop here.
Say: “Let me think about what could go wrong. Two users adding the last item — optimistic concurrency with version checks. A bundle containing itself — cycle detection in AddComponent(). Discount on an empty cart — guard clause returns zero. Price changes mid-session — Observer notifies, but we need a staleness check at checkout.”
Don’t say:(nothing — most candidates never mention edge cases unprompted)
Why it works: Proactive edge-case thinking is the single strongest "Strong Hire" signal. Four different categories (concurrency, structural, boundary, staleness) show comprehensive thinking.
7. Scaling to Marketplace
Situation: The interviewer asks “What if this becomes a marketplace like Amazon?”
Say: “The cart becomes a MarketplaceCart that groups items by seller. Each seller group has its own checkout stream, its own shipping calculation, and its own discount rules. The Composite pattern doesn’t change — bundles are just seller-scoped now. Strategy gets a seller context parameter. The Observer notifies per-seller checkout services. This bridges to HLD: each seller becomes a microservice.”
Don’t say: “I’d just add a sellerId field.” (misses architectural implications)
Why it works: Shows you understand what changes (grouping, scoping) and what doesn't (patterns themselves). The HLD bridge demonstrates breadth.
8. “I Don’t Know”
Situation: The interviewer asks about distributed cart sync across devices and you haven’t built one.
Say: “I haven’t implemented cross-device sync, but the approach is clear: each cart mutation is already an event (Observer), so I’d persist events to a log and replay them on other devices. Conflict resolution is the hard part — if device A adds item X and device B removes item Y simultaneously, I’d use last-writer-wins with timestamps, or a CRDT for eventual consistency. I’d need to research the CRDT approach more.”
Why it works: Honesty about the gap + clear reasoning about the approach + naming the hard part (conflict resolution) = respect. Bluffing or shutting down = red flag.
Pro Tip — Practice OUT LOUD, not just in your head
Reading these cards silently builds recognition. Saying them aloud builds production. Set a 5-minute timer, explain this Shopping Cart design to an imaginary interviewer. You’ll hear exactly where you hesitate — that’s where you need more practice.
Target three phrases for fluency: the Composite justification ("tree of same-type things, uniform GetPrice()"), the Strategy insight ("genuinely different discount algorithms, not config values"), and the Observer decoupling ("the cart doesn’t know about the UI at all"). These are the exact spots where candidates go vague under pressure.
Section 18
Interview Questions & Answers
12 questions ranked by difficulty. Each has a "Think" prompt, a solid answer, and the great answer that gets "Strong Hire." These aren't hypothetical — they're the exact questions interviewers ask when they see a Shopping Cart design.
Q1: Why did you use the Composite pattern for bundles instead of a flat list?
Easy
Think: What happens if a bundle contains other bundles? Can a flat list represent that?
Think about folders on your computer. A folder can contain files and other folders. You don't need to know whether something is a file or a folder to ask "how big is this?" — both know their own size. The file returns its bytes; the folder sums its children's sizes recursively. That exact same idea applies to shopping cart items and bundles.
Answer: Composite lets both single items and bundles implement ICartComponent. The cart calls GetPrice() uniformly — single items return their price, bundles sum their children's prices recursively.
Great Answer: "A flat list can't represent a 'Gaming Bundle' that contains a 'Starter Pack' bundle inside it. Composite gives me a tree where every node — leaf or branch — implements GetPrice(). The cart doesn't know or care about nesting depth. Adding a third level of bundling requires zero code changes. The trade-off is more classes, but the uniform interface pays for itself the moment bundles can nest."
What to SAY: "Single item or bundle of 50 nested items — the cart just calls GetPrice() and the tree handles itself. That's Composite."
Q2: How does the Strategy pattern make discounts extensible?
Easy
Think: What happens when marketing asks for a new promotion next week? How many files do you touch?
Imagine a restaurant with a fixed menu printed on the wall. Adding a new dish means repainting the wall. Now imagine a chalkboard — you just write the new dish. Strategy is the chalkboard: each discount is its own class implementing IDiscountStrategy. The cart just asks the current strategy "what's the discounted total?" and doesn't care which algorithm runs.
Answer: Each discount type (PercentageDiscount, FlatDiscount, BOGODiscount) implements IDiscountStrategy. Adding a new discount = one new class, zero changes to cart code.
Great Answer: "The cart owns a reference to IDiscountStrategy, not a concrete class. PercentageDiscount multiplies by a factor, FlatDiscount subtracts a fixed amount, BOGODiscount finds matching pairs. Each uses genuinely different logic — not just different parameters. Adding 'Spend $100, get $15 off' on Tuesday means one new class implementing IDiscountStrategy. The cart, the checkout, the UI — nothing changes. That's OCP in action."
What to SAY: "One new class, zero edits. Marketing adds promotions every week — the cart never knows."
Q3: How would you add a "Spend $100, get $15 off" discount?
Easy
Think: Does this discount depend on individual items or the total cart value? Does your strategy interface give you enough context?
This is the simplest follow-up, and it immediately reveals whether your Strategy interface is well-designed. The discount needs the total cart value to decide whether to apply — so ApplyDiscount must receive the full list of items.
Answer: Create a ThresholdDiscount class implementing IDiscountStrategy. It checks if the subtotal exceeds the threshold and subtracts the discount amount.
Great Answer: "One new file: ThresholdDiscount. It takes the threshold ($100) and reward ($15) as constructor parameters. ApplyDiscount(items) sums all items' prices, checks if the sum ≥ $100, and returns sum - $15 if yes. Nothing else changes. And because the parameters are in the constructor, the same class handles 'Spend $200, get $30 off' too — just different constructor args."
ThresholdDiscount.cs — 1 File, 0 Edits
public sealed class ThresholdDiscount(
decimal threshold, decimal reward) : IDiscountStrategy
{
public decimal ApplyDiscount(
IReadOnlyList<ICartComponent> items)
{
var subtotal = items.Sum(i => i.GetPrice());
return subtotal >= threshold
? subtotal - reward
: subtotal; // below threshold, no discount
}
}
// Usage — inject at runtime:
var cart = new ShoppingCart(
new ThresholdDiscount(threshold: 100m, reward: 15m));
What to SAY: "One file, parameterized constructor, zero edits. Same class handles any spend-X-get-Y promotion."
Q4: Walk through GetPrice() on a nested bundle — step by step.
MediumSTAR
Think: If a "Gaming Bundle" contains a "Starter Pack" bundle (which itself contains 2 items), how does GetPrice() walk the tree?
This is the question that separates candidates who memorized Composite from those who understand it. Think of it like opening nested gift boxes — you open the outer box, find smaller boxes inside, open those, find the actual gifts, and add up their values on the way back out.
Answer:GetPrice() is recursive. Leaves return their own price. Bundles sum GetPrice() on each child. The tree handles any nesting depth.
Great Answer: "Gaming Bundle calls GetPrice() on Console ($499), Starter Pack, and Extra Controller ($59). Console and Controller are leaves — they return immediately. Starter Pack is itself a bundle, so it calls GetPrice() on Game ($69) and Headset ($39), summing to $108. Back at the top: $499 + $108 + $59 = $666. The cart called one method. The tree did the rest. If Starter Pack had its own nested bundle, it would work too — same recursion, deeper tree, zero code changes."
What to SAY: "Leaves return their price. Bundles sum their children. The recursion handles any depth. One method call, entire tree priced."
Q5: How does Observer handle price changes without polling?
Medium
Think: If the UI constantly checks "did the price change?" every 100ms, what's the cost? How does push differ?
Polling is like calling your friend every 5 minutes to ask "are you ready yet?" Observer is like telling them "text me when you're ready" — you only get notified when something actually changes. The cart pushes events instead of subscribers pulling.
Answer: The cart fires an OnCartChanged event whenever items are added, removed, or prices update. Subscribers react only when notified — no polling loop.
Great Answer: "The cart implements ICartSubject with Subscribe(ICartObserver) and Notify(). When AddItem() or RemoveItem() runs, the cart calls Notify() at the end. Each observer's OnCartChanged(CartSnapshot) fires with the new state. The UI updates the display, analytics logs the event, stock checker verifies availability — all without knowing about each other. Adding a recommendation engine observer later means one new class, zero changes to cart."
Observer Push vs Polling Pull
// BAD: Polling — UI checks every 100ms
while (true)
{
var total = cart.GetTotal(); // wasteful if nothing changed
UpdateDisplay(total);
Thread.Sleep(100);
}
// GOOD: Observer — cart pushes when it actually changes
public void AddItem(ICartComponent item)
{
_items.Add(item);
NotifyObservers(); // only fires when something changed
}
// UI observer — receives push notification
public class PriceDisplayObserver : ICartObserver
{
public void OnCartChanged(CartSnapshot snapshot)
=> UpdateDisplay(snapshot.Total);
}
What to SAY: "Push, not pull. The cart says 'I changed' and everyone who cares reacts. Zero wasted checks."
Q6: Two users add the last item simultaneously — who gets it?
Medium
Think: If both users check stock (1 left), then both try to add — what prevents overselling?
This is the classic race condition. Both users check: "Is there stock?" Both see "yes, 1 left." Both add. Now you've sold 2 units of an item you only had 1 of.
Answer: Optimistic concurrency. Check stock AND reserve atomically using a version number. If the version changed, the second user gets Result.Fail("Out of stock").
Great Answer: "The naive approach is check-then-act. The gap between check and act is where the race lives. The fix is optimistic concurrency: each inventory record has a version. 'Reserve item WHERE stock > 0 AND version = X' — this is atomic. If User A wins, the version increments. User B's reservation fails because the version no longer matches. User B gets CartResult.Fail("Item no longer available") — no exception, just a clean error."
What to SAY: "Check-then-act has a gap. Optimistic concurrency closes it. Loser gets a clean Result error, not a crash."
Q7: How do you prevent a bundle from containing itself?
Medium
Think: If Bundle A adds Bundle B, and Bundle B adds Bundle A — what happens when you call GetPrice()?
This is the Composite trap most candidates miss entirely. If Bundle A contains Bundle B, and Bundle B contains Bundle A, calling GetPrice() creates an infinite loop — A asks B, B asks A, A asks B... until the stack overflows. Same problem as a circular folder shortcut on your computer.
Answer: In ProductBundle.AddComponent(), walk up the ancestor chain. If the component being added is already an ancestor of the current bundle, reject with Result.Fail("Circular reference").
Great Answer: "Before adding a child to a bundle, I check: 'Is this child already my parent or grandparent?' I walk up the tree from the current bundle. If I encounter the component I'm trying to add, it's a cycle. I also check if the component IS the bundle itself (direct self-reference). Both cases return CartResult.Fail("Circular bundle detected"). The check is O(depth) which is trivially fast for real-world bundle trees. This is the same problem as circular symlinks in file systems — you have to detect cycles before creating the link, not after."
Cycle Detection in 10 Lines
public CartResult<bool> AddComponent(ICartComponent child)
{
if (child == this)
return CartResult<bool>.Fail("Can't add bundle to itself");
if (child is ProductBundle bundle && bundle.ContainsAnywhere(this))
return CartResult<bool>.Fail("Circular bundle detected");
_children.Add(child);
return CartResult<bool>.Ok(true);
}
private bool ContainsAnywhere(ICartComponent target)
=> _children.Any(c => c == target
|| (c is ProductBundle b && b.ContainsAnywhere(target)));
What to SAY: "Walk ancestors before adding. If you find yourself, it's a cycle. Same as circular symlink detection."
Q8: How would you add gift wrapping as an option?
Medium
Think: Gift wrapping adds cost and changes display, but the underlying item stays the same. What pattern adds behavior without modifying the original?
Gift wrapping is a perfect Decorator candidate. You don't change the original item — you wrap it. The wrapped item still implements ICartComponent, so the cart never knows the difference. Its GetPrice() returns the original price plus the wrapping fee.
Answer: Create a GiftWrappedItem Decorator that wraps any ICartComponent. It delegates GetPrice() to the inner component and adds the wrapping fee.
Great Answer: "This is Decorator, not a flag on the product. GiftWrappedItem implements ICartComponent, wraps another ICartComponent, and overrides GetPrice() to add $4.99. The beauty: you can gift-wrap a bundle too — the Decorator wraps ANY ICartComponent, leaf or composite. You could even stack decorators: gift-wrapped + express-shipping. Each decorator adds its cost independently. Zero changes to Product, Bundle, or Cart."
GiftWrappedItem.cs — Decorator
public sealed class GiftWrappedItem(
ICartComponent inner,
decimal wrappingFee = 4.99m) : ICartComponent
{
public string Name => $"{inner.Name} (Gift Wrapped)";
public decimal GetPrice()
=> inner.GetPrice() + wrappingFee;
}
// Usage: wrap any item or bundle
var wrapped = new GiftWrappedItem(gamingBundle);
cart.AddItem(wrapped); // cart doesn't know it's wrapped
What to SAY: "Decorator, not a boolean flag. Wraps any ICartComponent — item or bundle. Stackable. Zero changes to existing classes."
Q9: How would you persist a cart with nested bundles?
Medium
Think: A tree structure needs to be serialized to a flat format (JSON/database). How do you preserve the nesting?
Serializing a Composite tree is a classic challenge. The tree has arbitrary depth, and you need to reconstruct it perfectly when deserializing. Think of it like saving a folder structure to a zip file — you need to preserve which files are inside which folders.
Answer: Use a type discriminator in JSON. Each node has a "type" field ("single" or "bundle") and bundles have a "children" array. The deserializer uses the type field to create the correct class.
Great Answer: "Each ICartComponent serializes with a type discriminator: { "type": "bundle", "name": "Gaming Pack", "children": [...] } for bundles, { "type": "single", "productId": 42, "price": 499 } for leaves. In .NET, I'd use System.Text.Json with a custom JsonConverter<ICartComponent> that reads the discriminator and creates the right type. For database persistence, I'd use an adjacency list table: CartItems(id, cartId, parentId, type, productId, name). Bundles have parentId = null at top level, children reference their parent bundle's id. Reconstruct the tree with a recursive CTE query."
What to SAY: "Type discriminator in JSON, adjacency list in DB. Both preserve the tree structure for lossless round-trips."
Q10: How would you sync a cart across devices?
Hard
Think: If a user adds an item on their phone and removes a different item on their laptop at the same time, what's the final cart state?
Cross-device sync is where LLD meets distributed systems. The core challenge is conflict resolution — what happens when Device A and Device B make simultaneous changes?
Answer: Persist cart events to a server-side event log. Each device syncs by pulling events since its last sync point. Use last-writer-wins with timestamps for conflicts.
Great Answer: "Since our cart already fires Observer events, I'd persist those events to a server-side event log: AddItem(X) at T1, RemoveItem(Y) at T2. Each device stores a 'last sync' timestamp and pulls new events on reconnect. For conflicts — both devices modify the same item simultaneously — I'd use last-writer-wins with server timestamps as the simple approach. For a production system, I'd consider a CRDT set (add-wins or remove-wins semantics) so merges are automatic and deterministic. The Observer pattern in our LLD becomes the event sourcing foundation in the HLD."
What to SAY: "Observer events become the event log. Sync = replay events since last checkpoint. Conflicts via LWW or CRDTs."
Q11: How would you handle a multi-seller marketplace cart?
Hard
Think: If your cart has items from 3 different sellers, each with their own shipping and discounts, how does checkout work?
A marketplace cart isn't one cart — it's a cart of carts. Each seller has their own inventory, shipping, and discount rules. Checkout becomes a parallel operation across sellers. Think of a food court: you order from 3 different stalls, but you pay at each stall separately. One stall being closed doesn't cancel your order at the other two.
Answer: Group items by seller ID. Each seller group gets its own discount strategy and shipping calculation. Checkout processes sellers in parallel — one failure doesn't cancel others.
Great Answer: "MarketplaceCart wraps a Dictionary<SellerId, SellerCart>. Each SellerCart is a regular ShoppingCart with its own IDiscountStrategy — Seller A offers 10% off, Seller B has BOGO, Seller C has nothing. Checkout runs per-seller in parallel using Task.WhenAll(). If Seller A's payment fails, Seller B and C still complete. The user gets a partial order result showing which sellers succeeded. This bridges to HLD: each seller becomes a microservice with its own inventory and checkout endpoint. Our LLD patterns (Composite, Strategy, Observer) carry over unchanged — they're just scoped per seller now."
What to SAY: "Marketplace = dictionary of seller carts. Each seller has own strategy, own checkout. Parallel processing, partial success."
Q12: How would you A/B test discount strategies?
Hard
Think: If 50% of users get "20% off" and 50% get "Spend $100, get $15 off" — where does that selection logic live?
A/B testing discounts is a beautiful example of why Strategy shines. Because each discount is already its own class behind IDiscountStrategy, the A/B framework just needs to choose which strategy to inject. The cart itself has no idea it's in an experiment.
Answer: Create a ABTestDiscountSelector that uses a user's experiment bucket to pick which IDiscountStrategy to inject into their cart. The cart code is completely unaware of A/B testing.
Great Answer: "I'd create an IDiscountStrategyFactory that takes a user ID and returns the appropriate strategy. Inside, it hashes the user ID to a bucket (A or B), and each bucket maps to a different IDiscountStrategy. The factory is the only thing that knows about the experiment. The cart receives a plain IDiscountStrategy — it doesn't know or care that it's part of an A/B test. Analytics Observer tracks which strategy each user got and their conversion rate. When the experiment ends, you just swap the factory to always return the winning strategy. Zero changes to cart, discount classes, or checkout."
A/B Test Strategy Selection
public class ABTestDiscountFactory : IDiscountStrategyFactory
{
private readonly IDiscountStrategy _groupA = new PercentageDiscount(0.20m);
private readonly IDiscountStrategy _groupB = new ThresholdDiscount(100m, 15m);
public IDiscountStrategy GetStrategy(string userId)
{
// Deterministic bucket: same user always gets same group
var bucket = Math.Abs(userId.GetHashCode()) % 2;
return bucket == 0 ? _groupA : _groupB;
}
}
// Cart doesn't know it's in an experiment:
var strategy = abFactory.GetStrategy(user.Id);
var cart = new ShoppingCart(strategy); // just a normal strategy
What to SAY: "Strategy makes A/B trivial: a factory picks which strategy to inject based on user bucket. Cart never knows."
Section 19
10 Deadliest Shopping Cart Interview Mistakes
Every one of these has ended real interviews. Shopping Cart feels like an "easy" problem, so candidates lower their guard and walk straight into traps that interviewers have seen hundreds of times.
●
Critical Mistakes — Interview Enders
#1 Jumping to List<Product> without scoping
Why this happens: "It's a shopping cart — everyone knows what it does." So you skip clarifying questions and start with a flat list. Five minutes in, the interviewer says "what about bundles?" and your flat list can't represent nested items.
What the interviewer thinks: "Doesn't scope requirements. Will build the wrong thing in production."
Fix: Spend 2-3 minutes: "Bundles? Discounts? Multi-seller? Live updates?" Each question reveals a design concern. Scoping a "simple" problem is MORE impressive than scoping a hard one.
#2 God class — ShoppingCart does everything
Why this happens: You put item management, pricing, discounts, validation, and checkout all in one ShoppingCart class. It starts at 50 lines and seems fine. But every new feature bloats it further until changing discounts might break checkout.
Bad — God Class
public class ShoppingCart // does EVERYTHING
{
public void AddItem(Product p) { ... }
public decimal CalculateDiscount(string type) { ... }
public decimal GetShipping() { ... }
public bool Checkout(string paymentMethod) { ... }
public void NotifyUI() { ... }
// 400+ lines of tangled logic
}
Good — Separated Responsibilities
ShoppingCart // owns items, delegates pricing
ICartComponent // Composite: items + bundles
IDiscountStrategy // Strategy: discount algorithms
ICartObserver // Observer: UI, analytics, stock
ICheckoutService // checkout is its own concern
// Each class: one job. Change discounts without touching cart.
Fix: Ask "what changes independently?" Items, discounts, checkout, and notifications all change for different reasons — each gets its own class or interface.
#3 Flat list only — no Composite for bundles
Why this happens: "Bundles are just a list of products with a discount." So you model Bundle as a separate entity that contains a List<Product>. This works until the interviewer asks "What about a bundle inside a bundle?" Your flat structure can't represent trees.
Fix:ICartComponent interface with GetPrice(). SingleItem and ProductBundle both implement it. Bundles contain List<ICartComponent> — enabling arbitrary nesting with zero code changes.
#4 Ignoring concurrency — the "last item" race
Why this happens: "Only one user uses the cart." But inventory is shared. Two users can add the last item simultaneously, causing overselling. This is the #1 edge case interviewers test and most candidates miss.
Fix: Mention optimistic concurrency proactively. "Reserve with version check — if the version changed between check and reserve, return Result.Fail()." Even one sentence shows you think about production concurrency.
●
Serious Mistakes — Red Flags
#5 Over-engineering — patterns everywhere, no justification
Why this happens: You name-drop every pattern: "I'll use Strategy for discounts, Observer for events, Factory for products, Builder for carts, Mediator for checkout..." The interviewer asks "why Factory?" and you can't explain the concrete problem it solves.
Fix: Each pattern must answer a specific "what varies" or "what changes independently" question. If you can't point to the problem, drop the pattern. Three motivated patterns beat six name-dropped ones.
#6 Never mentioning testability
Why this happens: You focus on design and code but never mention how you'd test it. Interviewers see this as a signal you don't write tests in production.
Fix: Mention DI: "ShoppingCart takes IDiscountStrategy via constructor. In tests, I inject a NoDiscount strategy to isolate cart logic from discount logic." One sentence shows testability awareness.
#7 Static singleton for cart — no trade-off discussion
Why this happens: "There's only one cart per user, so I'll make it static." But a static cart can't be mocked in tests, can't support multi-cart scenarios (save for later + active cart), and creates global state that's hard to reason about.
Fix: DI singleton: register ShoppingCart as a scoped service (one per request). Same lifetime control, but injectable and testable. "I'd use DI singleton, not static — same single instance, but testable."
●
Minor Mistakes — Missed Opportunities
#8 Magic numbers — 0.20m hardcoded in discount logic
Why this happens: You write total * 0.20m directly in the discount calculation. If the discount percentage changes, you're hunting through code for magic numbers.
Fix: Constructor parameters: new PercentageDiscount(rate: 0.20m). The rate is configurable, not hardcoded. Even better: load it from configuration so business can change it without a code deploy.
#9 Only handling the happy path
Why this happens: Your code works perfectly for valid inputs. But what about: empty cart checkout? Negative quantities? Bundle containing itself? Price changes mid-session? Each is a failure scenario your code ignores.
Fix: After your design works, run the "What If?" framework: Concurrency? Failure? Boundary? Weird input? Mention at least 3 edge cases proactively. "What if someone checks out with an empty cart? Guard clause returns Result.Fail()."
#10 No scaling or HLD bridge
Why this happens: You finish the LLD and stop. The interviewer was hoping you'd say something about scaling to a marketplace, multiple warehouses, or distributed cart sync. Without this bridge, your design lives in a vacuum.
Fix: In the last 3-4 minutes, mention: "For marketplace, group items by seller with per-seller checkout. For distributed sync, persist Observer events as an event log. For global scale, the cart service becomes stateless with Redis-backed session storage." One or two sentences is enough.
Level
Requirements
Design
Code
Edge Cases
Communication
Strong Hire
Structured F+NF
3 patterns, motivated
Clean modern C#
3+ proactive
Explains WHY
Hire
Key ones covered
1-2 patterns
Mostly correct
When asked
Clear
Lean No
Partial
Forced/wrong
Messy
Misses obvious
Quiet/verbose
No Hire
None
No abstractions
Can't code
None
Can't explain
Section 20
Memory Anchors — Never Forget This
You just built a Shopping Cart from scratch and discovered three design patterns along the way. Now let's lock those patterns into long-term memory. The trick isn't repetition — it's anchoring each concept to something vivid. A story, a place, a picture. When you can "see" it, you can recall it under interview pressure.
"Every system design CREATES a solution." — The same 7 steps, every time. You saw them in the interview timeline: Clarify scope (bundles? discounts?), gather Requirements (F+NF), identify Entities (IProduct, ICartComponent), define the API (AddItem, GetPrice), discuss Trade-offs (Composite vs flat, Strategy vs switch), explore Edge cases (race conditions, cycles), and bridge to Scale (marketplace).
The CSO Trio — Composite, Strategy, Observer
Composite structures what's in the cart •
Strategy decides how to discount •
Observer tells who cares
"What's in the cart? How is it discounted? Who needs to know?" — Three questions, three patterns. Ask them in order and you've covered every Shopping Cart concern.
Memory Palace — Walk Through the Shopping Cart
Imagine walking through a physical store with a shopping cart. Each section of the store maps to a design concept. As you push your cart through, the entire architecture clicks into place.
Memory Palace — The Store → Design Patterns
Smell → Pattern Quick Reference
These are the design instincts you built during this case study. When you notice these smells in ANY system, you already know the fix.
Smell
Signal
Response
Items and groups treated differently
Separate code paths for singles vs bundles
Composite — uniform ICartComponent
Switch/if-else on discount type
Multiple algorithms, same interface
Strategy — each discount is its own class
UI polls for cart changes
Others need to react when cart changes
Observer — push notifications, not polling
Tree of same-type things
Leaf and composite treated the same
Composite — recursive GetPrice()
Flashcards — Quiz Yourself
Click each card to reveal the answer. If you can answer without peeking, the pattern is sticking.
How does the cart price a nested bundle?
Composite recursion.ProductBundle.GetPrice() sums GetPrice() on each child. Leaves return their own price. The tree handles itself.
How do you add a new discount type?
Strategy pattern. Create one new class implementing IDiscountStrategy. Inject it into the cart. Zero changes to existing code.
How does the UI know the cart changed?
Observer pattern. The cart fires NotifyObservers() after any change. The UI subscribes and reacts. Push, not pull.
How do you prevent circular bundles?
Cycle detection. Before adding a child, walk the tree checking if the child is already an ancestor. If yes, return Result.Fail("Circular bundle").
What's the CREATES mnemonic?
Clarify → Requirements → Entities → API → Trade-offs → Edge cases → Scale. The universal 7-step LLD approach for every interview.
5 Things to ALWAYS Mention in a Shopping Cart Interview
Composite for bundles (not flat list)
Strategy for extensible discounts (not switch)
Observer for live updates (not polling)
Concurrency: optimistic locking on inventory
Scaling bridge: marketplace = cart of seller carts
Section 21
Transfer — These Patterns Work Everywhere
You didn't just learn how to build a shopping cart. You learned three structural thinking moves — tree-based composition, swappable algorithms, and push-based notifications. Those ideas appear in virtually every system that manages hierarchies, choices, and real-time updates. Here's the proof: the same techniques applied to four completely different domains.
Technique
Shopping Cart
File System
Organization Chart
UI Component Tree
Real-world walkthrough
Store → Items → Bundles → Discount → Checkout
Desktop → Folders → Files → Sort → Search
CEO → Departments → Teams → Individuals
App → Pages → Panels → Widgets → Render
Key entities
ICartComponent, SingleItem, ProductBundle
IFileSystemNode, File, Directory
IOrgNode, Employee, Department
IComponent, Leaf, Container
What varies?
Discount algorithms
Sort/search algorithms
Salary calculation rules
Layout strategies
Concurrency risk
Two users, last item
Two processes, same file
Two managers, same transfer
Two threads, same DOM node
Key edge case
Circular bundle
Circular symlink
Employee reports to self
Component contains itself
Scale path
Marketplace → seller microservices
Distributed FS → sharding
Multi-company → federated directory
Server-side rendering → hydration
The transfer test: Next time you encounter ANY system with tree-like data, ask: "Does this need Composite?" If you see multiple algorithms for the same operation, ask: "Does this need Strategy?" If something changes and others need to react, ask: "Does this need Observer?" Those three questions alone handle 70% of LLD design decisions.
Section 22
The Reusable Toolkit
Six thinking tools you picked up in this case study. Each one is a portable mental move — not a Shopping Cart trick, but something you can use in any LLD interview or real-world design.
Your Toolkit — 6 Portable Thinking Tools
SCOPE
Before writing code, ask: Size, Complexity, Operations, Performance, Extensions. Each letter reveals a design concern the happy path misses.
Look at each operation: "Is there more than one way to do this? Can the choice change at runtime?" If YES to both → Strategy. If NO → inline it.
Shopping Cart use: Discount algorithms vary (%, flat, BOGO) and change at runtime (promotions rotate weekly).
Tree Shape?
Ask: "Do I have items AND groups of items treated the same way?" If YES, the data is a tree. Use Composite: one interface, leaves and branches both implement it.
Shopping Cart use:SingleItem and ProductBundle both implement ICartComponent.GetPrice().
Who Cares?
After something changes, ask: "Who else needs to know?" List every subscriber. If the list can grow → Observer. The subject pushes; subscribers react.
Shopping Cart use: Cart changed → UI, analytics, and stock checker all need to know.
What If?
After the happy path works, run 4 checks: Concurrency (two actors, one resource), Failure (operation fails mid-way), Boundary (limits hit), Weird Input (unexpected data).
Ask: "Can I test this class without starting the whole app?" If NO, dependencies are too tight. Inject interfaces via constructor. Tests swap in fakes.
Shopping Cart use:ShoppingCart takes IDiscountStrategy via DI. Tests inject NoDiscount to isolate cart logic.
These 6 tools are your permanent inventory. They work for every e-commerce system, every file manager, every org chart, every UI framework. Domains change. The structural questions don't. If you remember nothing else from this page, remember THESE.
Section 23
Practice Exercises
Three exercises that test whether you truly learned the thinking, not just memorized the code. Each one adds a new constraint that forces you to extend the design — exactly like a real interview follow-up question.
Exercise Difficulty Progression
Exercise 1: Gift Wrapping Medium
New constraint: Customers can add gift wrapping to any item or any bundle. Wrapping adds $4.99 to the price and changes the display name to include "(Gift Wrapped)". Premium wrapping costs $9.99 and includes a personalized message.
Think: What pattern adds behavior (extra cost, name change) without modifying the original class? Can you stack wrapping options? Does the cart need to change at all?
Hint
This is a Decorator. GiftWrappedItem implements ICartComponent, wraps another ICartComponent, and overrides GetPrice() to add the fee. Because both SingleItem and ProductBundle implement ICartComponent, you can gift-wrap anything. You can even wrap a wrapped item (Decorator stacking) for double-wrap. The cart doesn't change at all — it just sees ICartComponent.
Solution
GiftWrappedItem.cs + PremiumWrappedItem.cs
// Base gift wrapping decorator
public sealed class GiftWrappedItem(ICartComponent inner) : ICartComponent
{
public string Name => $"{inner.Name} (Gift Wrapped)";
public decimal GetPrice() => inner.GetPrice() + 4.99m;
}
// Premium wrapping with message
public sealed class PremiumWrappedItem(
ICartComponent inner, string message) : ICartComponent
{
public string Name => $"{inner.Name} (Premium Gift: {message})";
public decimal GetPrice() => inner.GetPrice() + 9.99m;
}
// Usage: wrap a single item
var book = new SingleItem("C# in Depth", 49.99m);
var wrapped = new GiftWrappedItem(book);
// wrapped.GetPrice() == 54.98
// Usage: wrap a BUNDLE
var bundle = new ProductBundle("Starter Pack");
bundle.AddComponent(new SingleItem("Game", 69m));
bundle.AddComponent(new SingleItem("Headset", 39m));
var wrappedBundle = new PremiumWrappedItem(bundle, "Happy Birthday!");
// wrappedBundle.GetPrice() == 69 + 39 + 9.99 = 117.99
// Cart sees ICartComponent — doesn't know about wrapping
cart.AddItem(wrappedBundle);
Exercise 2: Cart Expiry Medium
New constraint: Items in abandoned carts should expire after 24 hours and be returned to inventory. The system should periodically check for expired items and remove them, notifying the user if their cart changed.
Think: Where do you store the timestamp for each item? How does a background job find expired items? Which existing pattern notifies the user?
Hint
Each cart item gets a AddedAt timestamp (stored alongside the item, not on the item itself). A background service runs periodically, queries items where DateTime.UtcNow - AddedAt > 24h, removes them, returns stock to inventory, and uses the existing Observer to notify the user. The cart's NotifyObservers() fires with a "items expired" event. Use DateTimeOffset.UtcNow, not DateTime.Now!
Solution
CartExpiryService.cs
public record CartEntry(ICartComponent Item, DateTimeOffset AddedAt);
public class ShoppingCart
{
private readonly List<CartEntry> _entries = new();
private readonly TimeSpan _expiryWindow = TimeSpan.FromHours(24);
public void AddItem(ICartComponent item)
{
_entries.Add(new CartEntry(item, DateTimeOffset.UtcNow));
NotifyObservers();
}
public int RemoveExpiredItems()
{
var cutoff = DateTimeOffset.UtcNow - _expiryWindow;
var expired = _entries.Where(e => e.AddedAt < cutoff).ToList();
foreach (var entry in expired)
{
_entries.Remove(entry);
_inventoryService.ReturnStock(entry.Item);
}
if (expired.Count > 0)
NotifyObservers(); // Observer handles user notification
return expired.Count;
}
}
// Background service (hosted service in ASP.NET Core)
public class CartExpiryHostedService(
ICartRepository carts) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
foreach (var cart in await carts.GetActiveCarts())
cart.RemoveExpiredItems();
await Task.Delay(TimeSpan.FromMinutes(15), ct);
}
}
}
Exercise 3: Dynamic Pricing Hard
New constraint: Product prices change based on demand (surge pricing), time of day (happy hour discounts), and cart size (bulk discounts: buy 5+ of the same item, get 10% off). Prices must update in real-time as the user shops.
Think: Does the price live on the product or the cart? How do you combine multiple pricing rules without a tangled if/else chain? Which pattern handles real-time updates to all interested parties?
Hint
This needs a chain of pricing strategies. Each rule (SurgePricingRule, HappyHourRule, BulkDiscountRule) implements IPricingRule with AdjustPrice(item, context). A PricingEngine runs all rules in sequence, each getting the previous rule's output as input. The Observer pattern notifies the cart when external prices change (e.g., surge pricing kicks in). Use Decorator on ICartComponent to wrap items with dynamic pricing: DynamicallyPricedItem wraps a product and delegates to the pricing engine.
Solution
DynamicPricing.cs
// Each pricing rule adjusts the price independently
public interface IPricingRule
{
decimal AdjustPrice(decimal basePrice, PricingContext ctx);
}
public sealed class SurgePricingRule(
IDemandTracker demand) : IPricingRule
{
public decimal AdjustPrice(decimal price, PricingContext ctx)
{
var multiplier = demand.GetMultiplier(ctx.ProductId);
return price * multiplier; // e.g., 1.2x during high demand
}
}
public sealed class BulkDiscountRule : IPricingRule
{
public decimal AdjustPrice(decimal price, PricingContext ctx)
=> ctx.Quantity >= 5 ? price * 0.90m : price;
}
// Engine chains all rules
public class PricingEngine(IReadOnlyList<IPricingRule> rules)
{
public decimal CalculatePrice(decimal basePrice, PricingContext ctx)
=> rules.Aggregate(basePrice,
(current, rule) => rule.AdjustPrice(current, ctx));
}
// Decorator that makes any item dynamically priced
public sealed class DynamicallyPricedItem(
ICartComponent inner,
PricingEngine engine,
PricingContext ctx) : ICartComponent
{
public string Name => inner.Name;
public decimal GetPrice()
=> engine.CalculatePrice(inner.GetPrice(), ctx);
}
// When surge pricing changes, Observer notifies all carts
// to recalculate their totals
Section 24
Related Topics
You've discovered patterns, principles, and thinking tools in this case study. Here's where to go next — each page below deepens a specific skill you've already started building.
Same difficulty tier. Strategy pattern for rating algorithms (average, weighted, Bayesian). Observer for live rating updates. Extends the same thinking tools.
Skills that transfer: Strategy + Observer combo, edge cases, What If? frameworkComing Soon
Vending Machine
State pattern becomes the primary pattern (idle → has-money → dispensing). Strategy for payment methods. Good contrast — same patterns, different emphasis.
Skills that transfer: State + Strategy, edge cases, What If? frameworkComing Soon
Recommended path: If you enjoyed the Composite + Strategy combination, try Review & Rating next — it focuses on Strategy + Observer with a different data model. Or try Vending Machine for a State-heavy design that contrasts nicely with this cart. The thinking tools (SCOPE, What Varies?, Tree Shape?, Who Cares?, What If?, Can I Test It?) carry forward to every single one.