The Ideal Cart: 5 Design Patterns That Earn Their Keep (and 2 Refused)

It all starts with a review question. I was reworking my notes on the Design Patterns book, the Gang of Four one, with examples that all came from the same universe: an online shop. Shipping fees for Strategy, the order for State, stock for Observer. And the question landed: "so what would the ideal cart be? One class that uses all the patterns?"

Excellent question. Wrong direction. And the answer deserves more than a paragraph, because it contains roughly everything I know about design.

The trap: the ultimate Cart class

The starting intuition is healthy: if all five examples come from the same shop, why not assemble everything? The trap is the word "class". A Cart class stacking Strategy, Decorator, State, Observer and a Facade "to show off" is exactly what the 1994 book calls pattern fever, and it warns against it as early as page 31: a pattern should only be applied when the flexibility it brings is actually needed.

The ideal cart is not a class. It is a small architecture where each pattern holds the exact post where it earns its keep. And the hiring criterion fits in one question, the most useful one in the book: what is going to change?

Five hires, five nameable problems

So I built the full checkout flow, starting from zero and introducing each pattern only when a real request demanded it. Here is the hiring log:

Shipping fees vary → Strategy. Standard post, express, store pickup: three calculations for the same question, "how much?". One ShippingFee interface, one class per carrier. Adding a new carrier tomorrow: one class, zero modification elsewhere.

interface ShippingFee {
    public function compute(Cart $c): float;
}
class StandardPost implements ShippingFee {
    public function compute(Cart $c): float { return 4.99; }
}

The promo stacks → Decorator. "Free shipping over €50" is not a property of the carriers, it is a wrapper around them. Same interface as what it wraps: to the rest of the code, a decorated carrier is a carrier.

class FreeShippingOver implements ShippingFee {
    public function __construct(private ShippingFee $base, private float $threshold) {}
    public function compute(Cart $c): float {
        return $c->total() >= $this->threshold ? 0.0 : $this->base->compute($c);
    }
}

The order has life stages → State. "Cancel" does not mean the same thing in the cart (empty it), paid (refund) or shipped (carrier return). Instead of an if/elseif on the status duplicated in every method, the order carries a state object it swaps at each stage, and cancel() delegates. Zero ifs, forever.

Paying must trigger the unknown → Observer. Email, stock, accounting, and the SMS marketing will ask for next month. The pay() method must not know that list: it announces, and subscribers react. A list of callbacks and one loop: that is the whole pattern, and it is the same one as your addEventListener.

The controller wants one button → Facade. A Checkout object receives the shipping strategy (possibly decorated) and the event bus, and exposes one placeOrder() method. The code receiving the POST knows only it.

The detail that changes everything: where decisions live

The most important piece of the ideal cart is none of the five patterns. It is the assembly:

// The ONLY place in the program that knows the concrete classes.
$checkout = new Checkout(
    new FreeShippingOver(new StandardPost(), 50),   // a Decorator around a Strategy
    $events,
);

Every concrete choice is made at the root, at assembly time. Checkout receives interfaces: it does not know whether shipping is decorated, nor who subscribed to the events. If you have read my Clean Architecture notes, you recognize the dependency rule: the two books, written twenty-three years apart, converge on exactly the same move.

And the second lesson is my favorite: the most central class is the dumbest. The Cart itself uses no pattern. It adds up lines. The patterns live around it, at the places that change, never at the center.

The two refusals (as important as the hires)

Along the way, two patterns applied and were turned down. A Singleton (Cart::getInstance(), "to access the cart from anywhere"): a global variable in disguise, untestable, and root injection already covers the need. A Command with an undo/redo history, "just in case": nobody asked to cancel step by step, and a pattern installed for an imaginary need is complexity paid upfront.

A good design shows in its absent patterns as much as its present ones. The list of what your code refuses to do says more than the list of what it can do; it is the same idea as language progress by subtraction, one floor down.

The result, playable

None of this stayed a drawing. The shop actually runs, and I turned it into the 13th guided project of the learning section: "The pattern shop" (in French), where the checkout gets built step by step, with the real AI prompts, the first drafts that crack, two predictions to make before reading, and a final challenge (a second promo, stacked on the first, without modifying a single existing class).

Every button in the demo goes through a pattern, and an event log at the bottom shows the Observer subscribers waking up when you pay. If you just want the skeleton, it is also in the book notes, as a boxed aside.

Conclusion

What stays with me from this exercise is not the code, it is the order of operations. Not once did I ask "which pattern should I put here?". I listed the shop's likely requests, and each pattern arrived as the answer to one precise request, with a problem name attached. The two times a pattern showed up without a problem to solve, it was sent home.

Maybe that is the real definition of the ideal cart: not the one containing the most patterns, but the one where every pattern can answer the question "what are you here for?" without stammering.

Comments (0)