Clean Code: An Introduction to the SOLID Principles
The SOLID principles are a cornerstone of modern object-oriented design. Coined by Robert C. Martin ("Uncle Bob"), they are a set of five principles that, when applied together, make it easier to create systems that are maintainable, scalable, and easy to understand.
Think of them not as strict rules, but as guidelines that help you avoid common pitfalls and build healthier, more robust applications. Let's walk through each one.
[S] - Single Responsibility Principle (SRP)
A class should have only one reason to change.
This means that a class should have one, and only one, job. It's not about a class having only one method, but about all the methods in that class being focused on a single purpose.
The Problem
When a class does too many things, it becomes a "God Class". It's hard to understand, difficult to test, and a change to one of its responsibilities can accidentally break another.
// 👎 Bad: This class has four different reasons to change.
class UserManager {
saveUserToDb(user: User) { /* ... database logic ... */ }
sendWelcomeEmail(user: User) { /* ... email logic ... */ }
validateUserData(user: User) { /* ... validation logic ... */ }
generateUserReport() { /* ... reporting logic ... */ }
}
If you need to change how reports are generated, you have to touch this massive class, which also contains sensitive database logic.
The Solution
Break the class down into smaller classes, each with a single responsibility.
// 👍 Good: Each class has one clear job.
class UserRepository {
save(user: User) { /* ... database logic ... */ }
}
class EmailService {
sendWelcomeEmail(user: User) { /* ... email logic ... */ }
}
class UserValidator {
validate(user: User) { /* ... validation logic ... */ }
}
class ReportGenerator {
generate(users: User[]) { /* ... reporting logic ... */ }
}
Now, each class is focused, easy to test, and can be modified without impacting the others.
[O] - Open/Closed Principle (OCP)
Software entities should be open for extension, but closed for modification.
This principle states that you should be able to add new functionality to a system without changing existing code. This is usually achieved through abstractions like interfaces or abstract classes.
The Problem
Imagine a payment processor that needs to support new payment types. The naive approach is to modify the class every time a new method is added.
// 👎 Bad: Adding a new payment type requires modifying this class.
class PaymentProcessor {
process(paymentType: string, amount: number) {
if (paymentType === 'credit') {
// process credit card
} else if (paymentType === 'paypal') {
// process paypal
} // a new 'else if' is needed for every new payment type
}
}
This violates OCP because the PaymentProcessor
class is not closed for modification.
The Solution
Use a common interface (PaymentStrategy
) that all payment methods can implement. The processor can then work with any payment method that adheres to this interface.
// 👍 Good: We can add new payment methods without touching the processor.
interface PaymentStrategy {
process(amount: number): void;
}
class CreditCardStrategy implements PaymentStrategy {
process(amount: number) { /* ... credit card logic ... */ }
}
class PayPalStrategy implements PaymentStrategy {
process(amount: number) { /* ... paypal logic ... */ }
}
// To add a new method, we just create a new class:
class StripeStrategy implements PaymentStrategy {
process(amount: number) { /* ... stripe logic ... */ }
}
class PaymentProcessor {
process(strategy: PaymentStrategy, amount: number) {
// The processor doesn't know the details, it just calls `process`.
strategy.process(amount);
}
}
[L] - Liskov Substitution Principle (LSP)
If it looks like a duck and quacks like a duck, but needs batteries, you probably have a wrong abstraction.
This is the core of LSP. It means that a subclass should be substitutable for its superclass without breaking the logic of the program. If a subclass behaves in a way that the superclass's contract doesn't expect, it violates LSP.
The Problem
The classic example is the Rectangle
vs. Square
problem. A square "is-a" rectangle, so we might make Square
a subclass of Rectangle
. But a square has a constraint (width must equal height) that a rectangle doesn't.
// 👎 Bad: The Square subclass breaks the Rectangle's contract.
class Rectangle {
protected width: number = 0;
protected height: number = 0;
setWidth(width: number) { this.width = width; }
setHeight(height: number) { this.height = height; }
getArea(): number { return this.width * this.height; }
}
class Square extends Rectangle {
setWidth(width: number) {
this.width = width;
this.height = width; // This side effect breaks the contract.
}
setHeight(height: number) {
this.width = height;
this.height = height; // So does this.
}
}
// A function that works with a Rectangle will fail with a Square.
function testArea(rect: Rectangle) {
rect.setWidth(5);
rect.setHeight(4);
console.assert(rect.getArea() === 20); // Fails for Square, which gives 16!
}
The Solution
Rethink the hierarchy. In this case, Square
and Rectangle
should be distinct classes that might implement a common Shape
interface. Don't force an "is-a" relationship where it doesn't behaviorally fit.
// 👍 Good: A common interface and separate, valid implementations.
interface Shape {
getArea(): number;
}
class Rectangle implements Shape {
constructor(private width: number, private height: number) {}
getArea(): number { return this.width * this.height; }
}
class Square implements Shape {
constructor(private side: number) {}
getArea(): number { return this.side * this.side; }
}
[I] - Interface Segregation Principle (ISP)
Clients should not be forced to depend on methods they do not use.
This principle is about keeping your interfaces lean. It's better to have many small, specific interfaces (often called "Role Interfaces") than one large, "fat" interface.
The Problem
A fat interface forces clients to implement methods they don't need, which leads to bloated, confusing code.
// 👎 Bad: A "fat interface" forces Robot to implement irrelevant methods.
interface Worker {
work(): void;
eat(): void;
sleep(): void;
}
class Human implements Worker {
work() { console.log('Working...'); }
eat() { console.log('Eating...'); }
sleep() { console.log('Sleeping...'); }
}
class Robot implements Worker {
work() { console.log('Working...'); }
// These methods are meaningless for a Robot.
eat() { /* do nothing */ }
sleep() { /* do nothing */ }
}
The Solution
Break the fat interface into smaller, more cohesive ones.
// 👍 Good: We segregate the interface into smaller roles.
interface Workable {
work(): void;
}
interface Eatable {
eat(): void;
}
interface Sleepable {
sleep(): void;
}
// Now classes can implement only the interfaces that make sense for them.
class Human implements Workable, Eatable, Sleepable {
work() { console.log('Working...'); }
eat() { console.log('Eating...'); }
sleep() { console.log('Sleeping...'); }
}
class Robot implements Workable {
work() { console.log('Working...'); }
}
[D] - Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on abstractions.
This principle is key to creating decoupled systems. Your business logic (high-level) shouldn't be tied to specific implementation details like a database or a network client (low-level). Instead, both should depend on a shared abstraction, like an interface.
The Problem
When a high-level module directly instantiates a low-level module, they become tightly coupled. This makes the system rigid and hard to test.
// 👎 Bad: The high-level `NotificationService` depends directly on the low-level `EmailService`.
class EmailService {
sendEmail(to: string, message: string) { /* ... sends email via SMTP ... */ }
}
class NotificationService {
// A direct, tight coupling.
private emailService = new EmailService();
notifyUser(user: User, message: string) {
this.emailService.sendEmail(user.email, message);
}
}
What if you want to send an SMS instead? You have to change NotificationService
. How do you test it without sending real emails?
The Solution
Invert the dependency. Make the NotificationService
depend on an interface that the low-level module implements. This is often done using Dependency Injection.
// 👍 Good: Now both modules depend on an abstraction (`MessageProvider`).
interface MessageProvider {
sendMessage(to: string, message: string): void;
}
// Low-level module implementing the abstraction.
class EmailProvider implements MessageProvider {
sendMessage(to: string, message: string) { /* ... sends email via SMTP ... */ }
}
// Another low-level module.
class SMSProvider implements MessageProvider {
sendMessage(to: string, message: string) { /* ... sends SMS via Twilio ... */ }
}
// High-level module depending on the abstraction.
class NotificationService {
// The dependency is "injected" via the constructor.
constructor(private messageProvider: MessageProvider) {}
notifyUser(user: User, message:string) {
const recipient = user.email; // or user.phoneNumber for SMS
this.messageProvider.sendMessage(recipient, message);
}
}
Now our NotificationService
is flexible. We can give it an EmailProvider
or an SMSProvider
. And for testing, we can give it a MockMessageProvider
, making our tests fast and reliable.