Chapter 02
SOLID principles
Five principles for classes that are easy to extend and test, each with the smell it prevents. Learn them as questions to ask of a design, not as rules to recite.
Single responsibility principle (SRP)
Also called SRP, one reason to change, cohesion
- What
- A class should have one reason to change, meaning it answers to one group of people or one kind of decision. Pricing rules, printing and saving are three reasons.
- Why
- When a change to the invoice layout cannot break tax calculation, you change code with confidence and tests stay small.
// Before: Invoice calculated, printed and saved itself.
class Invoice {
total(): number { return 0; } // pricing rules only
}
class InvoicePrinter {
print(i: Invoice) { return "Total: " + i.total(); }
}
class InvoiceRepository {
async save(i: Invoice): Promise<void> { /* database only */ }
}Watch out
Reading it as one method per class and splitting code into dozens of tiny classes that always change together.
Open/closed principle (OCP)
Also called OCP, open for extension, closed for modification
- What
- Code should be open for extension and closed for modification: you add behaviour by adding new code, such as a new class, rather than editing working code.
- Why
- Every edit to tested code risks breaking it. When a new discount type is a new class, the checkout code that already works is left alone.
type Discount = (price: number) => number;
const none: Discount = (p) => p;
const student: Discount = (p) => p * 0.8;
// A new discount is a new function. checkout never changes.
function checkout(price: number, discount: Discount) {
return discount(price);
}Watch out
Building extension points for changes nobody has asked for; wait until the second or third variation appears.
Liskov substitution principle (LSP)
Also called LSP, substitutability, behavioural subtyping
- What
- A subtype must work anywhere its parent type is expected without surprising the caller: it may not demand more, promise less or throw where the parent would not.
- Why
- Polymorphism only works if every subtype keeps the parent's promises. Breaking them brings back type checks in calling code.
- Example
- If Square extends Rectangle, setWidth on a Square must also change its height, so code that sets width 5 and height 4 and expects area 20 gets 16. Square is not a safe Rectangle.
Watch out
Overriding a method to throw "not supported", which tells you the subtype was never a true is-a.
Interface segregation principle (ISP)
Also called ISP, role interface, fat interface
- What
- Clients should not be forced to depend on methods they do not use. Prefer several small, role-based interfaces to one large one.
- Why
- A basic printer should not have to implement fax. Small interfaces keep implementations honest and make test doubles tiny.
// Before: interface Machine { print; scan; fax }
interface Printer { print(doc: Doc): void }
interface Scanner { scan(): Doc }
class BasicPrinter implements Printer {
print(doc: Doc) { /* ... */ }
}
class OfficeMachine implements Printer, Scanner {
print(doc: Doc) { /* ... */ }
scan(): Doc { return new Doc(); }
}Watch out
Splitting every interface into one-method pieces that no client actually uses separately.
Dependency inversion principle (DIP)
Also called DIP, depend on abstractions
- What
- High-level policy should not depend on low-level details; both should depend on an abstraction. OrderService talks to a Notifier interface, not to an SMTP client.
- Why
- You can switch email for SMS, or use a fake in a unit test, without touching the business rules. The rules become the stable centre of the code.
interface Notifier { send(to: string, msg: string): void }
class OrderService {
constructor(private notifier: Notifier) {} // injected
place(email: string) {
this.notifier.send(email, "Order placed");
}
}
// new OrderService(new EmailNotifier()) in production
// new OrderService(fakeNotifier) in a unit testWatch out
Creating the dependency with new inside the class and calling it DIP because the field type is an interface.
Dependency injection
Also called DI, IoC container, constructor injection, inversion of control
- What
- Passing an object the things it needs, usually through its constructor, instead of letting it create or look them up itself. A DI container can wire this up automatically.
- Why
- It is the everyday technique that makes dependency inversion real, and the main reason code becomes unit testable.
- Example
- Spring in Java and the built-in container in ASP.NET Core wire constructors for you. In Python and TypeScript, passing arguments by hand in one composition root is often enough.
Watch out
Constructors with eight injected services, which is a single responsibility problem that DI makes visible, not one it caused.
DRY, KISS and YAGNI
Also called DRY, KISS, YAGNI, don't repeat yourself, you aren't gonna need it
- What
- DRY: every piece of knowledge has one home. KISS: prefer the simplest design that works. YAGNI: do not build for needs you only imagine.
- Why
- They balance SOLID: principles applied too early create layers nobody needs, and these three keep designs small until change proves otherwise.
Watch out
Merging two pieces of code that look alike but change for different reasons, so every later change needs a flag.