SOLID - “The Golden Rules” of Object-Oriented Programming
SOLID is an acronym representing five object-oriented design principles created by the “master” Robert C. Martin (Uncle Bob). These principles are considered “guidelines” that help developers write clean, readable, maintainable, extensible, and reusable code. In this article, we will explore these five “sacred” principles and how to apply them in practice.
What is SOLID?
SOLID stands for the following five principles:
- S - Single Responsibility Principle (SRP)
- O - Open/Closed Principle (OCP)
- L - Liskov Substitution Principle (LSP)
- I - Interface Segregation Principle (ISP)
- D - Dependency Inversion Principle (DIP)
Breaking Down the 5 SOLID Principles
1. Single Responsibility Principle (SRP) - One Class, One Responsibility
- Content: A class should have only one reason to change, meaning it should only have one responsibility or function.
- Why? It makes the code easier to understand, maintain, test, and reuse. When changes occur, you only need to modify one place, reducing the risk of affecting other parts of the system.
- Example:
// Not good - User class has too many responsibilities
class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
saveUserToDatabase() {
console.log(`Saving user ${this.name} to database...`);
}
sendEmail(message) {
console.log(`Sending email to ${this.email}: ${message}`);
}
}
// Better - Responsibilities are separated
class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
}
class UserDatabase {
save(user) {
console.log(`Saving user ${user.name} to database...`);
}
}
class EmailService {
send(email, message) {
console.log(`Sending email to ${email}: ${message}`);
}
}
const user = new User("John Doe", "john.doe@example.com");
const userDb = new UserDatabase();
const emailService = new EmailService();
userDb.save(user);
emailService.send(user.email, "Welcome!");
- Explanation: Instead of making the
User
class responsible for both saving to the database and sending emails, we separate these responsibilities into different classes:User
,UserDatabase
, andEmailService
.
2. Open/Closed Principle (OCP) - Open for Extension, Closed for Modification
- Content: A class should be “open” for extension (i.e., it should allow adding new features), but “closed” for modification (i.e., no need to change old code when adding new features).
- Why? This makes the code easier to maintain and reduces the risk when adding new features. You don’t have to “reinvent” the old code, just add new modules.
- Example:
// Not good - Modifying existing class when adding new features
class Order {
constructor(items, paymentMethod) {
this.items = items;
this.paymentMethod = paymentMethod;
}
processOrder() {
if (this.paymentMethod === "creditCard") {
console.log("Processing with credit card...");
} else if (this.paymentMethod === "paypal") {
console.log("Processing with PayPal...");
}
// If we want to add new payment methods, we have to modify the Order class
}
}
// Better - Extend via inheritance or interface
class Order {
constructor(items) {
this.items = items;
}
processPayment(paymentProcessor) {
paymentProcessor.process(this.items);
}
}
class CreditCardProcessor {
process(items) {
console.log("Processing with credit card...");
}
}
class PayPalProcessor {
process(items) {
console.log("Processing with PayPal...");
}
}
const order = new Order(["Product 1", "Product 2"]);
const creditCardProcessor = new CreditCardProcessor();
const payPalProcessor = new PayPalProcessor();
order.processPayment(creditCardProcessor); // Output: Processing with credit card...
order.processPayment(payPalProcessor); // Output: Processing with PayPal...
- Explanation: Instead of embedding all payment methods in the
Order
class, we create separate classes for each payment method (CreditCardProcessor
,PayPalProcessor
). TheOrder
class simply calls theprocess
method of thepaymentProcessor
object. When we need to add a new payment method, we just create a new class without modifying theOrder
class.
3. Liskov Substitution Principle (LSP) - Substituting Subclasses for Superclasses
- Content: Objects of a subclass should be able to replace objects of the superclass without affecting the correctness of the program.
- Why? Ensures that inheritance is used properly, making the code flexible and easier to maintain.
- Example:
// Not good - Square violates LSP by changing the behavior of Rectangle
class Rectangle {
constructor(width, height) {
this.width = width;
this.height = height;
}
setWidth(width) {
this.width = width;
}
setHeight(height) {
this.height = height;
}
getArea() {
return this.width * this.height;
}
}
class Square extends Rectangle {
setWidth(width) {
this.width = width;
this.height = width;
}
setHeight(height) {
this.width = height;
this.height = height;
}
}
function testRectangle(rect) {
rect.setWidth(5);
rect.setHeight(4);
console.log(rect.getArea()); // Expected: 20
}
const rectangle = new Rectangle(2, 3);
const square = new Square(2, 2);
testRectangle(rectangle); // Output: 20
testRectangle(square); // Output: 16 (Incorrect)
// Better - Use composition instead of inheritance
class Shape {
constructor(width, height) {
this.width = width;
this.height = height;
}
getArea() {
return this.width * this.height;
}
}
class Rectangle {
constructor(width, height) {
this.shape = new Shape(width, height);
}
getArea() {
return this.shape.getArea();
}
}
class Square {
constructor(side) {
this.shape = new Shape(side, side);
}
getArea() {
return this.shape.getArea();
}
}
function testShapeArea(shape) {
console.log(shape.getArea());
}
const rectangle = new Rectangle(5, 4);
const square = new Square(5);
testShapeArea(rectangle); // Output: 20
testShapeArea(square); // Output: 25
- Explanation: In the first example,
Square
inherits fromRectangle
, but when we change thewidth
orheight
, it changes both dimensions, which results in incorrect calculations for the area. In the second example, we use composition by creating an instance ofShape
within each class, ensuring thatRectangle
andSquare
behave correctly.
4. Interface Segregation Principle (ISP) - Prefer Many Small Interfaces Over One Large One
- Content: Instead of using a large, general interface, break it down into smaller interfaces with specific methods for each group of objects.
- Why? Helps prevent classes from being forced to depend on methods they don’t use, making the code more flexible and reducing clutter.
- Example:
// Not good - Worker interface is overloaded
class Worker {
work() {
throw new Error("Not implemented");
}
eat() {
throw new Error("Not implemented");
}
}
class HumanWorker extends Worker {
work() {
console.log("Human working...");
}
eat() {
console.log("Human eating...");
}
}
class RobotWorker extends Worker {
work() {
console.log("Robot working...");
}
eat() {
// Robots don't eat!
}
}
// Better - Split into smaller interfaces
class Workable {
work() {
throw new Error("Not implemented");
}
}
class Eatable {
eat() {
throw new Error("Not implemented");
}
}
class HumanWorker extends Workable, Eatable {
work() {
console.log("Human working...");
}
eat() {
console.log("Human eating...");
}
}
class RobotWorker extends Workable {
work() {
console.log("Robot working...");
}
}
- Explanation: Instead of overloading the
Worker
interface with bothwork
andeat
methods, we split them into two smaller interfaces:Workable
andEatable
. Now,RobotWorker
only implementsWorkable
, whileHumanWorker
implements both.
5. Dependency Inversion Principle (DIP) - Depend on Abstractions, Not on Concrete Implementations
- Content: High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.
- Why? Reduces dependencies between modules, making the code more flexible, maintainable, and easier to extend.
- Example:
// Not good - High-level module depends directly on low-level module
class LightBulb {
turnOn() {
console.log("LightBulb: turned on...");
}
turnOff() {
console.log("LightBulb: turned off...");
}
}
class Switch {
constructor() {
this.bulb = new LightBulb();
this.on = false;
}
press() {
this.on = !this.on;
if (this.on) {
this.bulb.turnOn();
} else {
this.bulb.turnOff();
}
}
}
const lightSwitch = new Switch();
lightSwitch.press(); // Output: LightBulb: turned on...
lightSwitch.press(); // Output: LightBulb: turned off...
// Better - Depend on abstraction
class Switchable {
turnOn() {
throw new Error("Not implemented");
}
turnOff() {
throw new Error("Not implemented");
}
}
class LightBulb extends Switchable {
turnOn() {
console.log("LightBulb: turned on...");
}
turnOff() {
console.log("LightBulb: turned off...");
}
}
class Switch {
constructor(device) {
this.device = device;
this.on = false;
}
press() {
this.on = !this.on;
if (this.on) {
this.device.turnOn();
} else {
this.device.turnOff();
}
}
}
const bulb = new LightBulb();
const lightSwitch = new Switch(bulb);
lightSwitch.press(); // Output: LightBulb: turned on...
lightSwitch.press(); // Output: LightBulb: turned off...
- Explanation: Instead of making the
Switch
class depend directly on theLightBulb
class, we create an abstractionSwitchable
.Switch
now depends on this abstraction, andLightBulb
implements it. This allows for easy replacement of theLightBulb
with other devices, like a fan, without modifyingSwitch
.
Conclusion
SOLID is not a “rule,” but rather a “guideline” to follow. Applying SOLID principles flexibly and appropriately to specific cases will help you create clean, maintainable, scalable, and reusable code. Treat SOLID as valuable “secrets,” and practice regularly to become a true “coding master”!