Lesson 07
Five principles for maintainable, testable, flexible software
SOLID principles are the foundation of object-oriented design. They reduce coupling, increase cohesion, and make your codebase resilient to change.
"A class should have only one reason to change."
Each module or class should own exactly one piece of functionality. When a class does too much, a change in one concern ripples into unrelated areas.
A restaurant chef cooks. A waiter serves. A cashier handles payments. If one person did all three, any change to the menu, service style, or payment system would affect the same person.
class UserService {
createUser(data) { /* save to DB */ }
sendWelcomeEmail(user) { /* send email */ }
generateReport(users) { /* build PDF */ }
}
This class changes if the database schema changes, if the email template changes, or if the report format changes. Three reasons to change.
class UserRepository {
create(data) { /* save to DB */ }
}
class EmailService {
sendWelcome(user) { /* send email */ }
}
class ReportGenerator {
generate(users) { /* build PDF */ }
}
Each class has one reason to change. They can be tested, deployed, and modified independently.
"Software entities should be open for extension but closed for modification."
You should be able to add new behavior without changing existing, tested code. The strategy pattern is the classic tool for this.
A power strip is closed (you do not rewire it) but open for extension (you plug in new devices).
function calculateDiscount(type, amount) {
if (type === 'regular') return amount * 0.05;
if (type === 'premium') return amount * 0.10;
if (type === 'vip') return amount * 0.20; // added later
// Every new type = modify this function
}
const discountStrategies = {
regular: (amount) => amount * 0.05,
premium: (amount) => amount * 0.10,
};
function calculateDiscount(type, amount) {
const strategy = discountStrategies[type];
if (!strategy) throw new Error(`Unknown type: ${type}`);
return strategy(amount);
}
// Adding VIP = just register, no modification:
discountStrategies.vip = (amount) => amount * 0.20;
"Subtypes must be substitutable for their base types without altering program correctness."
If your code expects a Shape, any subclass of Shape must work without surprises. Violating this breaks polymorphism.
If you rent a "car," any car should work โ sedan, SUV, electric. But if they gave you a shopping cart, the "car" contract is violated.
class Rectangle {
setWidth(w) { this.width = w; }
setHeight(h) { this.height = h; }
area() { return this.width * this.height; }
}
class Square extends Rectangle {
setWidth(w) { this.width = w; this.height = w; }
setHeight(h) { this.width = h; this.height = h; }
}
// Breaks expectations:
const shape = new Square();
shape.setWidth(5);
shape.setHeight(3);
shape.area(); // Returns 9, not 15!
class Shape {
area() { throw new Error('Not implemented'); }
}
class Rectangle extends Shape {
constructor(w, h) { super(); this.width = w; this.height = h; }
area() { return this.width * this.height; }
}
class Square extends Shape {
constructor(side) { super(); this.side = side; }
area() { return this.side * this.side; }
}
"Clients should not be forced to depend on interfaces they do not use."
Large, bloated interfaces force implementers to stub out methods they do not need. Split them into small, focused contracts.
A universal remote with 80 buttons is worse than a simple remote with just the 5 you need. Most buttons go unused and add confusion.
interface Worker {
work(): void;
eat(): void;
sleep(): void;
attendMeeting(): void;
}
// A Robot must implement eat() and sleep()? Nonsensical.
class Robot implements Worker {
work() { /* ok */ }
eat() { throw new Error('Robots don\'t eat'); }
sleep() { throw new Error('Robots don\'t sleep'); }
attendMeeting() { /* ok */ }
}
interface Workable {
work(): void;
}
interface Feedable {
eat(): void;
}
interface Restable {
sleep(): void;
}
class HumanWorker implements Workable, Feedable, Restable {
work() { /* ... */ }
eat() { /* ... */ }
sleep() { /* ... */ }
}
class Robot implements Workable {
work() { /* ... */ }
// No eat() or sleep() โ not needed
}
"Depend on abstractions, not concretions."
High-level modules should not import low-level modules directly. Both should depend on abstractions (interfaces). This is the foundation of dependency injection.
A lamp depends on a "power outlet" interface, not on a specific power plant. You can switch power sources without rewiring the lamp.
class OrderService {
constructor() {
this.db = new MySQLDatabase(); // tightly coupled
this.mailer = new SendGridMailer(); // tightly coupled
}
}
class OrderService {
constructor(db, mailer) {
this.db = db; // any Database implementation
this.mailer = mailer; // any Mailer implementation
}
}
// Inject at composition root:
const service = new OrderService(
new MySQLDatabase(),
new SendGridMailer()
);
// In tests โ inject mocks:
const testService = new OrderService(
new InMemoryDatabase(),
new MockMailer()
);
With dependency inversion, swapping databases, email providers, or payment gateways is a configuration change โ not a rewrite. Tests become trivial because you inject mocks.
| Principle | One-Liner | Violation Smell |
|---|---|---|
| SRP | One reason to change | God classes, 500+ line files |
| OCP | Extend, don't modify | Growing if/switch chains |
| LSP | Subtypes honor contracts | Overrides that throw or no-op |
| ISP | Small, focused interfaces | Unused method stubs |
| DIP | Depend on abstractions | new inside constructors |
SOLID principles are guidelines, not laws. A 20-line script does not need dependency injection. Apply SOLID when the cost of rigidity exceeds the cost of abstraction.
Which SOLID principle states "depend on abstractions, not concretions"?