Design Patterns · Foundations
Design Principles Behind the Patterns
Good design aims for reuse and painless change. Three principles get you there, and most patterns apply one of them.
This lesson builds on Relationships Between Objects. Before learning patterns, learn what they are trying to achieve. Otherwise a pattern looks like ceremony.
Good software design has two practical goals: reuse code instead of rewriting it, and absorb change without breaking what works. Patterns are proven ways to reach those goals. Three principles explain most of them.
Aim for reuse and extensibility
Code reuse
Cost and time matter for every software product. Reusing existing code is one of the most common ways to cut both. The idea is obvious, but reuse is rarely free. Code that is tightly coupled, depends on concrete classes, or hard-codes its behavior is hard to move into a new context.
Erich Gamma, one of the GoF authors, describes three levels of reuse. At the bottom you reuse classes: libraries and containers. At the top you reuse frameworks, which define the main abstractions and call your code when they need it. Frameworks follow the Hollywood principle: “Do not call us, we will call you.” Design patterns sit in the middle. They are smaller and more abstract than frameworks, and they let you reuse a design idea without committing to someone else's code.
Extensibility
Change is the one constant in a programmer's life. It arrives for three common reasons:
- you understand the problem better once the first version exists, and the old code now looks wrong
- something outside your control changes, such as a payment provider retiring the API you built on
- requirements change, and the customer wants features nobody mentioned during planning
There is a bright side. If someone asks you to change a program, someone still cares about it. Experienced developers design so the likely changes are cheap.
Encapsulate what varies
Identify the parts of your program that vary and separate them from what stays the same.
The goal is to limit the damage a change can cause. Think of a home's breaker panel. A fault in the kitchen trips one breaker, and the rest of the house keeps its power. Put each changing part behind its own boundary, and a change stays inside that boundary.
At the method level
A store's checkout computes a cart total. Discounts depend on promotions that marketing changes every few weeks. In the first version, the discount rules sit inside the total calculation:
total(): number {
let total = this.items.reduce((sum, item) => sum + item.price * item.qty, 0);
if (this.promoCode === "SPRING20") total *= 0.8;
else if (this.promoCode === "LOYAL5" && this.customer.orders > 10) total -= 5;
return total;
}
Every new promotion edits total(), a method whose real job is adding up items. Move the rules into their own method. The changing part now has one home:
total(): number {
const subtotal = this.items.reduce((sum, item) => sum + item.price * item.qty, 0);
return subtotal - this.discountFor(subtotal);
}
private discountFor(subtotal: number): number {
if (this.promoCode === "SPRING20") return subtotal * 0.2;
if (this.promoCode === "LOYAL5" && this.customer.orders > 10) return 5;
return 0;
}
At the class level
Over time, a method that did one simple job attracts more responsibilities, plus the helper fields and methods they need. When the discount logic grows its own data and rules, give it its own class. Cart then delegates to it.
Try it
Add a holiday promotion. What has to change?
edit Cart.total() ← mixes summing and promotions
retest every path through total()
edit Cart.discountFor()
total() stays untouched
Cart still grows with every promotion
edit or add a DiscountPolicy class
Cart is untouched
promotions are testable without a cart UI
Each step moves the changing part further from the stable part. Stop at the step the change rate justifies.
Program to an interface, not an implementation
Depend on abstractions, not on concrete classes.
A design is flexible when you can extend it without editing the code that already works. A charger that accepts any USB-C device is more flexible than one built for a single phone model. It still charges that phone.
When two classes must collaborate, the quick move is to make one depend directly on the other. A more flexible setup takes four steps:
- Work out exactly what one object needs from the other. Which methods does it call?
- Describe those methods in a new interface or abstract class.
- Make the class being depended on implement that interface.
- Make the dependent class depend on the interface instead of the concrete class.
You can still pass the original objects, but the connection is now flexible. Right after the change you may see no benefit, and the code is more complex than before. Make the change when you see a real extension point, or when other code needs to plug in its own version.
Step through
Loosen a game level's grip on its enemies
A game has levels that spawn enemies. Watch the coupling drop in three refactors.
1. Level knows every enemy class
Level.spawnWave() calls new Goblin(), new Troll(), and new Bat(), and calls a different method on each. Every new enemy edits Level.
2. Extract an Enemy interface
All enemies now implement attack(), so Level can treat them uniformly through polymorphism. But Level still constructs concrete enemies, so a new kind of level still means rewriting Level.
3. Let subclasses decide what to create
Level declares an abstract createEnemies(). Each level subclass returns its own enemies. Level.spawnWave() no longer names a single concrete enemy. You just applied Factory Method.
Favor composition over inheritance
Inheritance is the most obvious way to reuse code between classes. Two classes share code, so you move it into a common base class. The trouble shows up later, when the hierarchy is large and every change is hard:
- a subclass cannot shrink its parent's interface, so it must implement abstract methods it does not need
- an overriding method must stay compatible with the parent, because any code that expects the parent may receive the child
- inheritance can expose protected implementation details to subclasses and make them depend on how the parent works
- subclasses are tightly coupled to the parent, so a change in the parent can break every child
- reuse through inheritance creates parallel hierarchies, because inheritance extends along one dimension and every new dimension multiplies the classes
Composition is the alternative. Inheritance models an is a relationship: an engineer is an employee. Composition models a has a relationship: an employee has a pay policy. The principle also covers aggregation, where the object refers to another object without owning its lifetime.
Consider an HR system. Employees vary by role, by how they are paid, and by where they work. With inheritance, each combination needs its own subclass: SalariedRemoteEngineer, HourlyOfficeDesigner, and so on. With composition, an Employee holds a pay policy and a work location and delegates to them.
Try it
Count the classes as the dimensions grow
Choose how many variants each dimension has.
| Approach | Concrete classes | Adding one more location |
|---|---|---|
| Inheritance (one subclass per combination, not counting the base class) | 8 | +4 classes |
| Composition (one class per variant, not counting the two interfaces) | 6 | +1 class |
| Approach | Concrete classes | Adding one more location |
|---|---|---|
| Inheritance (one subclass per combination, not counting the base class) | 12 | +4 classes |
| Composition (one class per variant, not counting the two interfaces) | 7 | +1 class |
| Approach | Concrete classes | Adding one more location |
|---|---|---|
| Inheritance (one subclass per combination, not counting the base class) | 16 | +4 classes |
| Composition (one class per variant, not counting the two interfaces) | 8 | +1 class |
| Approach | Concrete classes | Adding one more location |
|---|---|---|
| Inheritance (one subclass per combination, not counting the base class) | 12 | +6 classes |
| Composition (one class per variant, not counting the two interfaces) | 7 | +1 class |
| Approach | Concrete classes | Adding one more location |
|---|---|---|
| Inheritance (one subclass per combination, not counting the base class) | 18 | +6 classes |
| Composition (one class per variant, not counting the two interfaces) | 8 | +1 class |
| Approach | Concrete classes | Adding one more location |
|---|---|---|
| Inheritance (one subclass per combination, not counting the base class) | 24 | +6 classes |
| Composition (one class per variant, not counting the two interfaces) | 9 | +1 class |
| Approach | Concrete classes | Adding one more location |
|---|---|---|
| Inheritance (one subclass per combination, not counting the base class) | 16 | +8 classes |
| Composition (one class per variant, not counting the two interfaces) | 8 | +1 class |
| Approach | Concrete classes | Adding one more location |
|---|---|---|
| Inheritance (one subclass per combination, not counting the base class) | 24 | +8 classes |
| Composition (one class per variant, not counting the two interfaces) | 9 | +1 class |
| Approach | Concrete classes | Adding one more location |
|---|---|---|
| Inheritance (one subclass per combination, not counting the base class) | 32 | +8 classes |
| Composition (one class per variant, not counting the two interfaces) | 10 | +1 class |
| Approach | Concrete classes | Adding one more location |
|---|---|---|
| Inheritance (one subclass per combination, not counting the base class) | 12 | +6 classes |
| Composition (one class per variant, not counting the two interfaces) | 7 | +1 class |
| Approach | Concrete classes | Adding one more location |
|---|---|---|
| Inheritance (one subclass per combination, not counting the base class) | 18 | +6 classes |
| Composition (one class per variant, not counting the two interfaces) | 8 | +1 class |
| Approach | Concrete classes | Adding one more location |
|---|---|---|
| Inheritance (one subclass per combination, not counting the base class) | 24 | +6 classes |
| Composition (one class per variant, not counting the two interfaces) | 9 | +1 class |
| Approach | Concrete classes | Adding one more location |
|---|---|---|
| Inheritance (one subclass per combination, not counting the base class) | 18 | +9 classes |
| Composition (one class per variant, not counting the two interfaces) | 8 | +1 class |
| Approach | Concrete classes | Adding one more location |
|---|---|---|
| Inheritance (one subclass per combination, not counting the base class) | 27 | +9 classes |
| Composition (one class per variant, not counting the two interfaces) | 9 | +1 class |
| Approach | Concrete classes | Adding one more location |
|---|---|---|
| Inheritance (one subclass per combination, not counting the base class) | 36 | +9 classes |
| Composition (one class per variant, not counting the two interfaces) | 10 | +1 class |
| Approach | Concrete classes | Adding one more location |
|---|---|---|
| Inheritance (one subclass per combination, not counting the base class) | 24 | +12 classes |
| Composition (one class per variant, not counting the two interfaces) | 9 | +1 class |
| Approach | Concrete classes | Adding one more location |
|---|---|---|
| Inheritance (one subclass per combination, not counting the base class) | 36 | +12 classes |
| Composition (one class per variant, not counting the two interfaces) | 10 | +1 class |
| Approach | Concrete classes | Adding one more location |
|---|---|---|
| Inheritance (one subclass per combination, not counting the base class) | 48 | +12 classes |
| Composition (one class per variant, not counting the two interfaces) | 11 | +1 class |
| Approach | Concrete classes | Adding one more location |
|---|---|---|
| Inheritance (one subclass per combination, not counting the base class) | 16 | +8 classes |
| Composition (one class per variant, not counting the two interfaces) | 8 | +1 class |
| Approach | Concrete classes | Adding one more location |
|---|---|---|
| Inheritance (one subclass per combination, not counting the base class) | 24 | +8 classes |
| Composition (one class per variant, not counting the two interfaces) | 9 | +1 class |
| Approach | Concrete classes | Adding one more location |
|---|---|---|
| Inheritance (one subclass per combination, not counting the base class) | 32 | +8 classes |
| Composition (one class per variant, not counting the two interfaces) | 10 | +1 class |
| Approach | Concrete classes | Adding one more location |
|---|---|---|
| Inheritance (one subclass per combination, not counting the base class) | 24 | +12 classes |
| Composition (one class per variant, not counting the two interfaces) | 9 | +1 class |
| Approach | Concrete classes | Adding one more location |
|---|---|---|
| Inheritance (one subclass per combination, not counting the base class) | 36 | +12 classes |
| Composition (one class per variant, not counting the two interfaces) | 10 | +1 class |
| Approach | Concrete classes | Adding one more location |
|---|---|---|
| Inheritance (one subclass per combination, not counting the base class) | 48 | +12 classes |
| Composition (one class per variant, not counting the two interfaces) | 11 | +1 class |
| Approach | Concrete classes | Adding one more location |
|---|---|---|
| Inheritance (one subclass per combination, not counting the base class) | 32 | +16 classes |
| Composition (one class per variant, not counting the two interfaces) | 10 | +1 class |
| Approach | Concrete classes | Adding one more location |
|---|---|---|
| Inheritance (one subclass per combination, not counting the base class) | 48 | +16 classes |
| Composition (one class per variant, not counting the two interfaces) | 11 | +1 class |
| Approach | Concrete classes | Adding one more location |
|---|---|---|
| Inheritance (one subclass per combination, not counting the base class) | 64 | +16 classes |
| Composition (one class per variant, not counting the two interfaces) | 12 | +1 class |
Inheritance multiplies. Composition adds. The gap widens with every dimension.
class Employee {
constructor(private pay: PayPolicy, private place: WorkLocation) {}
monthlyCost(): number {
return this.pay.monthlyAmount() + this.place.monthlyOverhead();
}
convertToSalaried(salary: number) {
this.pay = new Salaried(salary); // swap behavior at runtime
}
}
Composition brings a bonus: you can swap behavior at runtime. A contractor who joins full-time gets a new pay policy object. With inheritance you would have to replace the whole employee object. This structure is the core of the Strategy pattern.
Composition is not free either
Every delegation adds an object to create and a hop to follow when debugging. Use inheritance for a true, stable is a relationship along one dimension. Reach for composition when a second dimension appears or behavior must change at runtime.
Retrieve and apply
Check yourself
Notification classes vary by channel (email, SMS, push) and by urgency (normal, urgent). The team has built six subclasses. A new channel is planned. What does the principle suggest?
Not quite. That keeps multiplying classes. The next dimension makes it worse.
Correct. Two dimensions are two small hierarchies. A new channel is one new class.
Not quite. Flags remove classes but bury both dimensions in conditionals, which breaks encapsulate what varies.
Check yourself
You extract an interface between two classes, and the code now has more files and no visible benefit. When was that worth it?
Not quite. An interface without a second implementation or a test seam is complexity you pay for today.
Correct. Program to an interface where variation is likely or present. The flexibility has to be used to earn its cost.
Continue to SOLID Without the Dogma for five sharper principles that refine these three.
Source: Alexander Shvets, Dive Into Design Patterns (深入设计模式), Chinese edition v2021-1.25. Software design principles, printed pages 32–50. Explanations, examples, and exercises are adapted for this course.