CASE STUDY

Shopping Cart

Build it 7 times. Each level adds one constraint that breaks your code. By the end, you won't have memorized a design — you'll have discovered it.

7 Levels 15 Think First Challenges 60+ SVG Diagrams 12 Interview Q&As C# / .NET
Section 1

A Shopping Cart That Hides 3 Design Patterns

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

Easy Med Hard L0 L1 L2 L3 L4 L5 L6 L7 Basic Types Strategy Composite Observer Edge DI Scale

What You'll Build

ShoppingCart ICartEntry SingleItem | Bundle (bundles nest bundles) IDiscountStrategy Percent | Fixed | BOGO (swap at runtime) ICartObserver UI | Stock | Analytics (notified on change) IProduct (Physical | Digital | Sub) Result<T> Composite Strategy Observer Products

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.

Stats

15 Think Firsts • 60+ SVGs • 12 Q&As • 100+ tooltips
~75 min (with challenges) • ~35 min (speed read)

Section 2

Before You Code — See the Real World

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.

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.

1. Browse Product pages Name, price, image Qty selector "Add to Cart" Product entity Physical? Digital? Sub? 2. Cart Item list + qty Line subtotals Running total +/- buttons Cart + CartItem Add, Remove GetTotal 3. Discounts Coupon code Auto-applied deals BOGO badges Stacked savings Strategy Multiple algos Same interface 4. Checkout Address form Payment method Order summary Tax + shipping Order entity Validate, Pay Confirm 5. Post-Order Confirmation Tracking number Stock decrement Receipt email Notifications Observer pattern for live updates

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.

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.

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.

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.

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

REAL WORLD CODE Item on a product page IProduct (interface) Shopping bag with items ShoppingCart (class) Product + quantity in cart CartItem (class) Coupon / sale / BOGO IDiscountStrategy (Strategy) Combo deal / gift set Bundle (Composite)

Patterns Hiding in Plain Sight

Strategy Multiple discount algorithms Same interface, swappable Signal: "Multiple ways to calculate discounts" Discovered in L2 Composite Bundles that contain bundles Single item = leaf, Bundle = branch Signal: "Tree of same-type things, uniform GetPrice()" Discovered in L3 Observer Cart changes → UI refreshes Stock checks, analytics fire Signal: "One event, many independent reactions" Discovered in L4
Discovery Real World Code Type
ProductItem on a product pageIProductinterface (polymorphic types)
Physical ProductShips in a box, has weightPhysicalProductrecord (immutable data)
Digital ProductInstant download linkDigitalProductrecord (immutable data)
Cart ItemProduct + quantity lineCartItemclass (mutable quantity)
Shopping CartBag holding all itemsShoppingCartclass (mutable collection)
DiscountCoupon / sale / BOGOIDiscountStrategyinterface (Strategy pattern)
BundleCombo deal / gift setBundleclass (Composite pattern)
NotificationUI refresh, email, stock updateICartObserverinterface (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.

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:

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

AddItem(product, qty) Product ID in cart? YES qty += quantity NO new CartItem() Total auto-recalculates

Growing Diagram — After Level 0

Class Diagram — Level 0
«record» Product + Id : string + Name : string + Price : decimal (immutable value type) CartItem + Product : Product + Quantity : int + Subtotal : decimal ShoppingCart - _items : List<CartItem> + Items : IReadOnlyList + Total : decimal + AddItem(product, qty) + RemoveItem(productId) has * has 1

System So Far

Product CartItem ShoppingCart 3 types — the simplest starting point

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.

An interface IProduct 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

📦 Physical + Id, Name, Price + Weight : decimal + TaxRate → 0.10 Ships in a box Needs shipping cost e.g., Laptop, Mouse 💾 Digital + Id, Name, Price + DownloadUrl : string + TaxRate → 0.05 Instant download No shipping needed e.g., E-book, License 🔁 Subscription + Id, Name, Price + BillingCycle : enum + TaxRate → 0.00 Recurring charge Monthly or yearly e.g., Cloud Storage

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:

Tax Rules by Product Type

Physical → 10% tax Digital → 5% tax Subscription → 0% tax Physical goods: sales tax applies Digital goods: lower digital services tax Subscriptions: tax-exempt (varies by region)

Why Records, Not Classes?

record (our choice) ✅ Immutable by default ✅ Free Equals + GetHashCode ✅ Free ToString (debugging) ✅ Concise primary constructor ✅ Pattern matching with "is" Best for: data-heavy, immutable entities class ❌ Mutable by default ❌ Must write Equals manually ❌ Default ToString is useless ❌ Verbose constructors ✅ Better for behavior-rich objects Best for: stateful, behavior-heavy entities

System So Far — Level 1

CartItem ShoppingCart IProduct Physical Digital Subscription BillingCycle 6 types — polymorphic product hierarchy ShoppingCart unchanged — works with IProduct transparently

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.

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

«interface» IDiscountStrategy + CalculateDiscount(items) : decimal PercentageDiscount - _percentage : decimal + CalculateDiscount() // total * percentage e.g., "20% off everything" FixedAmountDiscount - _amount : decimal + CalculateDiscount() // flat amount e.g., "$10 off your order" BuyOneGetOneDiscount - _qualifyingProductId + CalculateDiscount() // cheapest qualifying free e.g., "Buy 2 get 1 free"

What Would You Do?

SwitchApproach.cs
public decimal GetTotal()
{
    var subtotal = _items.Sum(i => i.Subtotal);

    return _discountType switch
    {
        "percentage" => subtotal * (1 - _percentage),
        "fixed"      => subtotal - _fixedAmount,
        "bogo"       => subtotal - GetCheapestItemPrice(),
        _            => subtotal
    };
}
// New discount type? Edit GetTotal(). Again. And again.
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

Switch / If-Else 1. Open GetTotal() 2. Add new if/case branch 3. Add new fields to cart 4. Test EVERYTHING again ❌ Modifies existing code ❌ Risk breaking existing discounts Files touched: ShoppingCart.cs Strategy Pattern 1. Create ThresholdDiscount.cs 2. Implement IDiscountStrategy 3. Register in DI / attach to cart 4. Test ONLY the new class ✅ No existing code changed ✅ Existing discounts can't break Files touched: ThresholdDiscount.cs (NEW)

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:

Stacking Discounts — How It Works

Subtotal $500.00 20% Holiday Sale -$100.00 PercentageDiscount $10 Coupon -$10.00 FixedAmountDiscount Final Total $390.00 You saved $110.00! (22% off) _discounts.Sum(d => d.CalculateDiscount(_items)) Each strategy calculates independently — the cart just sums the results

System So Far — Level 2

IProduct Physical Digital Subscr. CartItem Cart IDiscountStrategy PercentageDiscount FixedAmountDiscount BuyOneGetOneDiscount 10 types — Strategy pattern enables infinite discount types

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.

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:

File System Same interface: GetSize() 📁 Project/ 📁 src/ 📄 readme.md 📄 app.cs 📄 utils.cs Folder.GetSize() = sum of children sizes File.GetSize() = its own size Shopping Cart Same interface: GetPrice() 📦 UltimateSetup 📦 BackToSchool 💻 Monitor $300 💻 Laptop 🖱 Mouse Bundle.GetPrice() = sum of children - discount SingleItem.GetPrice() = product.Price Folder maps to Bundle File maps to SingleItem Both share ICartComponent Cart never asks "item or bundle?" — just GetPrice()

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.

Without Composite (flat list) CartItem: Laptop — $999 CartItem: Mouse — $29 BundleItem: BackToSchool — ??? DIFFERENT TYPE! if (item is CartItem) total += item.Price; if (item is BundleItem) total += bundle.GetBundlePrice(); Type checks + special logic for each kind Every new "kind" (mega-bundle, subscription pack) requires another if/else branch With Composite (tree) Laptop $999 BackToSchool Mouse Bag foreach (var item in cart.Items) total += item.GetPrice(); // SAME call! ONE call handles everything uniformly New kinds just implement ICartComponent Zero changes to the cart loop Composite lets the tree compute itself — the cart just asks.

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.

ultimateSetup.GetPrice() — recursive walk UltimateSetup GetPrice() = children.Sum() - $30 1. call 2. call 3. call BackToSchool children.Sum() - $50 Monitor return $299 Keyboard return $79 Laptop return $999 Mouse return $29 Bag return $49 BackToSchool = (999 + 29 + 49) - 50 = $1,027 ↑ $299 ↑ $79 ↑ $1,027 UltimateSetup = (1,027 + 299 + 79) - 30 = $1,375 The tree computed its own price! No external recursion needed. cyan arrows = call down | green values = return up | yellow = bundle sums

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.

ShoppingCart List<ICartComponent> LEAF USB Cable $12 BUNDLE Ultimate Setup (-$30) LEAF Headphones $59 BUNDLE BackToSchool (-$50) Monitor $299 Keyboard $79 Laptop $999 Mouse $29 Bag $49 = Leaf (SingleItem) = Branch (ProductBundle) = Cart root Depth unlimited — bundles inside bundles inside bundles Total = $12 + $1,375 + $59 = $1,446

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.

ShoppingCart Add() | Remove() | GetTotal() «interface» IProduct «interface» IDiscountStrategy «interface» ICartComponent Name | GetPrice() | GetItems() NEW holds List<> SingleItem Product | Quantity NEW (leaf) ProductBundle _children | BundleDiscount | Add() NEW (branch) uses children yellow = new in Level 3 | green = leaf type | dashed = interface | muted = from previous levels

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.

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.

CatalogService PUBLISHER OnPriceChanged OnStockChanged OnCouponExpired "Laptop price changed: $999 → $899" Publisher fires event — doesn't know who listens SUBSCRIBER 1 ShoppingCart "Recalculate total — it's now $80 cheaper!" SUBSCRIBER 2 CartUIPanel "Show animation: price dropped!" SUBSCRIBER 3 AnalyticsService "Log: price_change event for reporting" One event, many reactions. The publisher doesn't import, reference, or know about any subscriber.

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.

CatalogService ShoppingCart CartUIPanel Analytics Price: $999→$899 PriceChanged event Update item price Recalculate total OnPriceChanged OnCartTotalChanged Show price drop animation Log event: price_changed t=0 t=1 t=2 t=3 Events cascade: Catalog → Cart → UI + Analytics. 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.

Passive Cart (Before) User clicks "View Cart" → calculate now Price dropped 5 mins ago → still shows old price Item went out of stock → user finds out at checkout Adding new listener (email) → edit cart code Stale data, bad UX, tight coupling Reactive Cart (After) User clicks "View Cart" → already up-to-date Price dropped 5 mins ago → updated instantly via event Item went out of stock → badge appears immediately Adding new listener (email) → just Subscribe(), zero cart changes Fresh data, great UX, loose coupling Observer turns a passive calculator into a live, reactive component.

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.

ShoppingCart Add() | Remove() | GetTotal() Subscribe() | Unsubscribe() | Notify() IDiscountStrategy ICartComponent «interface» ICartObserver OnPriceChanged | OnOutOfStock | OnTotalChanged NEW notifies List<> CartUIPanel animations, badges, totals NEW AnalyticsService logging, metrics, reports NEW EmailService "back in stock" alerts NEW yellow = new in Level 4 | green/cyan = concrete observers | dashed = interface | muted = from previous levels

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.

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 If?" — Edge Case Discovery Concurrency "What if two actors do this at the same time?" • Two users add last item → overselling • User updates qty while checkout runs → stale data • Price event fires during GetTotal() → partial update lock / atomic 💥 Failure "What if the operation can't complete?" • Out of stock after adding → silent broken cart • Payment gateway timeout → charged but no order • Product discontinued → ghost items in cart Result<T> 📏 Boundary "What if values are at extremes?" • Add 0 or negative quantity → bad data • Cart with 10,000 items → performance • Checkout empty cart → meaningless order validation Timing "What if things happen in unexpected order?" • Coupon expires mid-session → wrong total • Cart abandoned 24h → stale prices/stock • Flash sale ends during checkout → price mismatch re-validate

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.

Race Condition: Last Item in Stock User A User B Stock t=0 t=1 t=2 t=3 stock=1 Check: stock=1 ✅ Check: stock=1 ✅ Reserve! stock=0 Reserve! stock=-1 ❌ stock=-1! Fix: TryReserve() = atomic "check AND decrement" in one step User A succeeds (stock 1→0). User B's TryReserve returns false. No overselling.

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.

AddItem Validation Pipeline Gate 1 Input Valid? qty > 0? pass Gate 2 Stock Available? stock ≥ qty? pass Gate 3 Atomic Reserve? TryReserve() pass ✅ CartResult.Ok() Item added to cart InvalidQuantity OutOfStock ConcurrencyConflict Any gate fails → return CartResult.Fail() immediately. No partial operations.

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.

ShoppingCart AddItem() → CartResult Checkout() → CartResult IDiscountStrategy ICartComponent ICartObserver CartResult IsSuccess | Error | Code NEW CartErrorCode OutOfStock | InvalidQty | CouponExpired... NEW returns «interface» IInventoryService GetStock() | TryReserve() | Release() NEW validates yellow = Result type (new) | red = inventory service (new) | muted = from previous levels

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.

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

Production DI Container WarehouseInventory SqlCatalogService SystemTimeProvider ShoppingCart same code, real deps Unit Tests Test Setup FakeInventory(stock:0) FakeCatalog(price:$999) FakeTime(controllable) ShoppingCart same code, fake deps Same ShoppingCart class. Zero code changes. Different wiring. DI = the cart doesn't create what it needs, it receives it.

Test Isolation: Each Test Controls One Variable

Each Test Controls Exactly One Variable Test: Out of Stock inventory = FakeInventory(0) cart.AddItem(laptop, 1) Assert: OutOfStock error Variable: stock = 0 Everything else: normal Test: Coupon Expired time.AdvanceBy(25 hours) cart.Checkout() Assert: CouponExpired error Variable: time advanced Everything else: normal Test: Bundle Price bundle = A($100) + B($200) - $50 cart.Add(bundle) Assert: total = $250 Variable: bundle math Everything else: normal

Growing Diagram — Level 6

ShoppingCart receives ALL deps via constructor IInventoryService ICatalogService ICouponService ITimeProvider +4 more yellow dashed = injectable interfaces | cart depends on abstractions, not implementations

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

Multi-Seller Marketplace Customer Cart Items from 3 different sellers Seller A: TechWorld Laptop $999 | Mouse $29 Own inventory, own pricing Ships from: Warehouse A Seller B: BookNook C# in Depth $45 Own inventory, free shipping Ships from: Warehouse B Seller C: GadgetHub USB-C Hub $35 Own inventory, premium shipping Ships from: Warehouse C Checkout splits into 3 sub-orders — one per seller Each processed, shipped, and tracked independently

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.

📱 Phone 💻 Laptop 📺 Tablet Cart Service API: add/remove/get Publishes events on every change Redis Cart state (fast) Event Bus CartUpdated events PostgreSQL Cart persistence Observer pattern in L4 → Event Bus in L7. Same concept, distributed.

Growing Diagram — Level 7 (Final Architecture)

CartService (API) ShoppingCart + all patterns inside ICartComponent IDiscountStrategy ICartObserver CartResult DI Interfaces Redis Cache NEW Event Bus LLD patterns INSIDE the service stay the same HLD wraps them with infrastructure: cache, events, persistence, API gateway

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.

COMPLETE TYPE MAP — COLOR = LEVEL INTRODUCED L0–L1 (Foundation) L2–L4 (Patterns) L5–L7 (Advanced) Interfaces MODELS Product record · L0 CartItem class · L0 ShoppingCart class · L0 PhysicalProduct class · L1 DigitalProduct class · L1 Subscription class · L1 INTERFACES IProduct L1 IDiscountStrategy L2 ICartComponent L3 ICartObserver L4 IPricingService L6 DISCOUNT STRATEGIES PercentageDiscount L2 FixedAmountDiscount L2 BuyOneGetOneDiscount L2 COMPOSITE SingleItem L3 ProductBundle L3 OBSERVERS PriceDropAlert L4 StockNotifier L4 CouponWatcher L4 RESULTS & VALIDATION (L5) CartResult<T> record · L5 CartValidator class · L5 ConcurrentCartLock class · L5 SERVICES (L6) PricingService L6 InventoryService L6 ITimeProvider L6 SCALING (L7) MarketplaceCart L7 DeviceSync L7 IEventBus L7 24 types total 6 models · 5 interfaces · 3 strategies · 3 composite · 3 observers · 4 services

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.

Discounts.cs — Strategy pattern: swappable discount algorithms
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.

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.

X-RAY VIEW — PATTERN BOUNDARIES STRATEGY PATTERN IDiscountStrategy PercentageDiscount FixedAmountDiscount BuyOneGetOneDiscount COMPOSITE PATTERN ICartComponent SingleItem (leaf) ProductBundle (composite) contains children OBSERVER PATTERN ICartObserver PriceDropAlert StockNotifier CouponWatcher ShoppingCart orchestrates all patterns · L0–L7 HIDDEN PATTERNS (emerged naturally, never named during the build) Iterator GetChildren() + FlattenRecursive Decorator (stacking) Multiple discounts chain Null Object Empty<ICartComponent>() in leaf Result<T> Railway-oriented errors

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:

PATTERN INTERACTION FLOW — AddItem() + CalculateTotal() 1. AddItem() DI: inventory check via IInventoryService 2. Wrap in Composite new SingleItem(cartItem) added to component list 3. Notify Observers OnItemAdded(item) alerts, analytics, logs 4. Return Result CartResult<CartItem> success or error 5. Sum Components Composite: GetPrice() recursive tree walk 6. Flatten Tree Iterator: GetChildren() build flat item list 7. Apply Discounts Strategy: CalculateDiscount() per strategy, stacked 8. Final Total subtotal - discounts clamped to 0 PATTERN COLLABORATION SUMMARY DI provides services → Composite structures items → Iterator flattens → Strategy calculates → Observer reacts Each pattern handles ONE concern. Together, they handle EVERYTHING.
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.

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.

SYSTEM EVOLUTION — 8 STAGES L0: The Basics Product CartItem ShoppingCart 3 types L1: Product Types IProduct Physical Digital Subscription +4 types (7 total) L2: Discounts IDiscountStrategy Percent Fixed BOGO +4 types (11 total) L3: Bundles ICartComponent SingleItem ProductBundle +3 types (14 total) L4: Observer Events ICartObserver PriceDrop StockNotif CouponWatch +4 types (18 total) L5: Edge Cases & Validation CartResult<T> CartValidator lock +3 types, many changes (21 total) Hardened every existing file L6: DI & Testability IPricingService IInventoryService ITime +3 interfaces (24 total) L7: Marketplace & Scaling MarketplaceCart DeviceSync IEventBus HLD bridge: events, sync, multi-seller Final: 24+ types 6 models · 5 interfaces · 3 strategies 2 composite · 3 observers · 3 services · 3 scaling TYPE COUNT OVER TIME 0 6 12 18 24 L0 L1 L2 L3 L4 L5 L6 L7 3 7 11 14 18 21 24 Steady growth L0–L4 (patterns adding types) → L5+ slows (hardening, not creating) Foundation (L0–L1) Patterns (L2–L4) Hardening (L5–L7) Good architecture grows organically. Every type earned its place.

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?
ProductrecordL0Immutable data carrier — no behavior, just fields
CartItemclassL0Mutable quantity — needs methods like IncreaseQuantity()
ShoppingCartclassL0Orchestrator with mutable state (items list, observers, lock)
IProductinterfaceL1Abstraction: cart doesn't care if product is physical or digital
PhysicalProductrecordL1Has weight + shipping, but still immutable data
DigitalProductrecordL1No shipping, infinite stock — simpler variant
SubscriptionProductrecordL1Recurring billing cycle — a product with time dimension
IDiscountStrategyinterfaceL2Strategy patternDefines a family of algorithms and makes them interchangeable. The cart calls CalculateDiscount() on whatever strategy is plugged in.: one interface, multiple implementations
PercentageDiscountclassL2Algorithm: "X% off total" — needs a percentage field
FixedAmountDiscountclassL2Algorithm: "$X off" with minimum order threshold
BuyOneGetOneDiscountclassL2Algorithm: "every 2nd item free" — item-level logic
ICartComponentinterfaceL3Composite 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
SingleItemclassL3Leaf node in the composite tree (wraps a CartItem)
ProductBundleclassL3Composite node: holds children (items or sub-bundles)
ICartObserverinterfaceL4Observer 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
PriceDropAlertclassL4Observer: watches for price changes, sends alerts
StockNotifierclassL4Observer: alerts when stock is running low
CouponWatcherclassL4Observer: tracks coupon usage and expiration
CartResult<T>recordL5Result type: carry success value or error without exceptions
CartValidatorclassL5Validates stock, expiration, quantity bounds
ConcurrentCartLockclassL5Thread safety wrapper for shared cart mutations
IPricingServiceinterfaceL6DI: injectable pricing (real vs. test)
IInventoryServiceinterfaceL6DI: injectable stock checks (real vs. fake)
ITimeProviderinterfaceL6DI: 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."

ShoppingCartSystem (1200 lines) AddItem() CalculateDiscount() SendEmail() CheckInventory() LogAnalytics() ValidateCoupon() FormatReceipt() UpdateStockCount() SyncToDatabase() Change one thing... break five others should be Separated Concerns ShoppingCart IDiscountStrategy ICartObserver IInventoryService ICartComponent IPricingService Each class: one reason to change
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.

ACTUAL CLASS NAME (not a joke) AbstractCartItemDiscountStrategyCompositeObserverFactory A new developer asks: "Where does adding an item happen?" Answer: Follow the AbstractFactory → Builder → Mediator → Handler → 12 classes later... "I just wanted to add a product to a list."
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.

In Development 1 user Items always in stock Coupons never expire No concurrent requests All tests pass! In Production (Week 1) 2 AM: Two users add last item → oversold by 1 Day 3: Expired coupon still applies → $0 orders Day 5: Bundle contains itself → StackOverflowException Day 7: Race condition corrupts cart state → wrong totals Pager goes off at 2 AM
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.

Flat List (no bundles) Keyboard — $79.99 Mouse Pad — $19.99 "Gaming Pack" — $84.99 (manually priced) Can't show what's inside the "pack" Can't nest bundles inside bundles Composite Tree (any depth) Gaming Pack Keyboard Accessories Headset Mouse Pad Wrist Rest Each node: GetPrice() calculates recursively
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.

Polling (every 5 seconds) PriceChecker ShoppingCart "Any changes?" (5s) "No." (99% of the time) 1000 users = 12,000 checks/minute 99% are "no change" — wasted CPU Worst case: 5 second delay before noticing a price change Observer (event-driven) ShoppingCart PriceAlert StockNotifier CouponWatcher 0 checks when nothing changes Instant notification when something does Cart pushes events — observers react
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?

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?

PR Submitted "LGTM!" Under Review Reading code... 5 Issues Found Request changes Fixes Applied Push new commit Approved + Merged Ship it!

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
        }
    }
}
BUG SEVERITY #1 Thread Safety CONCURRENCY #2 Double for Money PRECISION #3 Composite Loop RECURSION #4 Observer Leak MEMORY #5 Stale Discount LOGIC Concurrency and precision bugs are hardest to find in code review — they surface only under load

Found them? Reveal one at a time:

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

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.

Taught in: Level 0 — Data types for money

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;
}

Taught in: Level 3 + Level 5 — Composite pattern with defensive validation

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

Taught in: Level 4 — Observer pattern with proper lifecycle management

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

Taught in: Level 5 (expiration validation) + Level 6 (ITimeProvider for testable time)

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.

What Interviewers Score on a Shopping Cart Question 1. SCOPE FIRST Bundles? Discounts? Multi-seller? Shows system thinking 2. ENTITY MODELING IProduct vs ProductBundle (Composite) Leaf vs composite decisions 3. PATTERN USAGE Strategy for discounts, not if/else Motivated by problem, not memorized 4. EDGE CASES Race condition on last item, empty cart Proactive = Strong Hire signal 5. ARTICULATION Explain WHY Composite, not just WHAT Say trade-offs out loud Strong Hire = all 5 visible. No Hire = jumped to code, skipped 1-2-4-5. You can stumble on any criterion and recover. You can't skip them entirely.
TimeCandidate SaysInterviewer 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.
10:00 Starts coding: IProduct, ICartComponent, SingleItem, ProductBundle, IDiscountStrategy, ShoppingCart... Watching for: sealed classes, Result<T>, clean Composite recursion
22:00 "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.
TimeCandidate SaysInterviewer 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.

C Clarify 0:00-2:00 R Requirements 2:00-4:00 E Entities 4:00-6:00 A API 6:00-10:00 T Trade-offs 10:00-22:00 E Edge Cases 22:00-26:00 S Scale 26:00-30:00 Clarify → Requirements → Entities → API → Trade-offs → Edge cases → Scale

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.

The Articulation Order — Problem First, Pattern Second 1. THE PROBLEM "Items + bundles of items..." 2. PATTERN NAME "That's Composite." 3. TRADE-OFF "More classes, but uniform API" 4. CODE "Here's ICartComponent..." BAD order: "I'll use Composite" (step 2 first) — sounds memorized, not motivated Pattern Decision Tree for Shopping Cart Tree of same-type things? → COMPOSITE (bundles) Multiple algorithms? → STRATEGY (discounts) Others need to react? → OBSERVER (live updates) Each pattern answers a DIFFERENT question. Name the question first, then the pattern.
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.”

Don’t say: “I don’t know distributed systems.” (full stop, no reasoning)

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.

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

Recursive GetPrice() Walk — Step by Step Gaming Bundle GetPrice() = ? Console GetPrice() = $499 Starter Pack GetPrice() = ? Extra Controller GetPrice() = $59 Game GetPrice() = $69 Headset GetPrice() = $39 The Recursive Walk Step 1: Gaming Bundle calls GetPrice() on each child: Console, Starter Pack, Extra Controller Step 2: Console is a leaf → returns $499. Extra Controller is a leaf → returns $59. Step 3: Starter Pack is a bundle → calls GetPrice() on Game ($69) + Headset ($39) → returns $108. Step 4: Gaming Bundle sums: $499 + $108 + $59 = $666. Each node only knew its own children. The cart called ONE method. The tree did the rest. That's the power of Composite.

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

Race Condition: Last Item User A Inventory (stock=1) User B CheckStock() → 1 left CheckStock() → 1 left "OK, available" "OK, available" AddToCart() → stock=0 AddToCart() → stock=-1! OVERSOLD! Fix: Atomic reserve with version check. Loser gets Result.Fail("Out of stock")

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

The Circular Bundle Trap Without cycle detection Bundle A Bundle B contains contains back! GetPrice() → StackOverflow! With cycle detection Bundle A Bundle B contains B.Add(A) → walks ancestors → finds A is already above B → Result.Fail("Circular bundle!")

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

Marketplace Cart = Cart of Seller Carts MarketplaceCart Seller A Cart Console: $499 Controller: $59 Discount: 10% off Shipping: $5.99 Seller B Cart Headphones: $149 Cable: $12 Discount: BOGO cables Shipping: Free Seller C Cart Game: $69 Discount: None Shipping: $3.99 Checkout = parallel per-seller: each seller's cart processes independently. Seller A failure doesn't cancel Seller B's order. Partial checkout is OK.

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

Mistake Severity Pyramid CRITICAL #1 No scoping • #2 God class • #3 Flat list only #4 Ignoring concurrency Any one = likely No Hire SERIOUS #5 Over-engineering • #6 No tests • #7 Static singleton Drops you from Hire to Lean No MINOR #8 Magic numbers • #9 Happy path only • #10 No HLD bridge Interviewer thinks: "Can't decompose a simple system." "Will create unmaintainable code." Interviewer thinks: "Junior-level habits." "Not production-ready yet." Interviewer thinks: "Solid but not senior-level." "Could shine with more depth."

Critical Mistakes — Interview Enders

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.

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.

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.

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

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.

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.

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

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.

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

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.

LevelRequirementsDesignCodeEdge CasesCommunication
Strong HireStructured F+NF3 patterns, motivatedClean modern C#3+ proactiveExplains WHY
HireKey ones covered1-2 patternsMostly correctWhen askedClear
Lean NoPartialForced/wrongMessyMisses obviousQuiet/verbose
No HireNoneNo abstractionsCan't codeNoneCan'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.

CREATES — The Universal LLD Approach

Clarify  •  Requirements  •  Entities  •  API  •  Trade-offs  •  Edge cases  •  Scale

"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
Memory palace mapping store sections to design patterns and CREATES steps THE STORE Items Aisle "Pick single items or grab a bundle box" COMPOSITE Entities + tree structure Discount Counter "Which coupon today? 20% off or spend $100?" STRATEGY Trade-offs + algorithms Checkout Lane "Beep — everyone notified: receipt, stock, analytics" OBSERVER Edge cases + notifications ENTER EXIT "Items aisle, Discount counter, Checkout lane — that's the whole cart."

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.

SmellSignalResponse
Items and groups treated differentlySeparate code paths for singles vs bundlesComposite — uniform ICartComponent
Switch/if-else on discount typeMultiple algorithms, same interfaceStrategy — each discount is its own class
UI polls for cart changesOthers need to react when cart changesObserver — push notifications, not polling
Tree of same-type thingsLeaf and composite treated the sameComposite — recursive GetPrice()

Flashcards — Quiz Yourself

Click each card to reveal the answer. If you can answer without peeking, the pattern is sticking.

Composite recursion. ProductBundle.GetPrice() sums GetPrice() on each child. Leaves return their own price. The tree handles itself.

Strategy pattern. Create one new class implementing IDiscountStrategy. Inject it into the cart. Zero changes to existing code.

Observer pattern. The cart fires NotifyObservers() after any change. The UI subscribes and reacts. Push, not pull.

Cycle detection. Before adding a child, walk the tree checking if the child is already an ancestor. If yes, return Result.Fail("Circular bundle").

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.

Same Patterns, Different Domains Pattern Shopping Cart File System Org Chart UI Components Composite Items + Bundles GetPrice() recursive Files + Folders GetSize() recursive Employees + Depts GetHeadcount() recursive Widgets + Panels Render() recursive Strategy Discount algorithms %, flat, BOGO, threshold Sort algorithms name, size, date, type Salary calculations hourly, salary, commission Layout algorithms flex, grid, absolute Observer Cart change events UI, analytics, stock File change watcher IDE, backup, search index Org change events HR, payroll, directory State change events re-render, accessibility
TechniqueShopping CartFile SystemOrganization ChartUI 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 Key Insight Systems share STRUCTURE even when domains differ completely. The skills transfer because they target the structure, not the domain.
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
Toolbox with six labeled design thinking tools TOOLKIT SCOPE Clarify before code S-C-O-P-E questions What Varies? → Strategy pattern Discount algorithms Tree Shape? → Composite pattern Bundles, folders Who Cares? → Observer pattern UI, analytics, stock What If? → Edge cases Race, cycle, empty Can I Test It? → DI + interfaces Inject, mock, verify
SCOPE

Before writing code, ask: Size, Complexity, Operations, Performance, Extensions. Each letter reveals a design concern the happy path misses.

Shopping Cart use: "Bundles?" revealed Composite. "Discounts?" revealed Strategy. "Live updates?" revealed Observer.

What Varies?

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

Shopping Cart use: Two users/last item, circular bundle, empty cart checkout, negative quantities.

Can I Test It?

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 difficulty progression from medium to hard 1 Gift Wrapping MEDIUM 2 Cart Expiry MEDIUM 3 Dynamic Pricing HARD
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?

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.

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?

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!

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?

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.

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