In the world of software development, some principles stand the test of time. Among the most influential are the SOLID principles, a set of five design guidelines that empower developers to create software that is more understandable, flexible, and maintainable. Coined by Robert C. Martin (Uncle Bob), SOLID isn't a silver bullet, but rather a powerful framework for thinking about object-oriented design.
Let's break down each principle with practical examples.
S - Single Responsibility Principle (SRP)
"A class should have only one reason to change."
This principle advocates for a class to have a single, well-defined purpose. If a class has multiple responsibilities, changes to one responsibility might inadvertently affect others, leading to unexpected bugs and a tangled codebase.
Bad Example (Violates SRP)
Imagine a Report
class that's responsible for both generating the report data and printing it.
-
Python π
class Report: def __init__(self, data): self.data = data def generate_report(self): # Logic to process report data return f"Report Data: {self.data}" def print_report(self): report_content = self.generate_report() print(f"Printing: {report_content}")
-
JavaScript π
class Report { constructor(data) { this.data = data; } generateReport() { // Logic to process report data return `Report Data: ${this.data}`; } printReport() { const reportContent = this.generateReport(); console.log(`Printing: ${reportContent}`); } }
Why it's bad: This class has two reasons to change: a change in the data generation logic or a change in the printing mechanism (e.g., printing to a file, sending an email).
Good Example (Adheres to SRP)
We split these responsibilities into separate classes.
-
Python π
class ReportGenerator: def generate(self, data): return f"Report Data: {data}" class ReportPrinter: def print(self, report_content): print(f"Printing: {report_content}") # Usage generator = ReportGenerator() printer = ReportPrinter() report_data = generator.generate("Sales Figures") printer.print(report_data)
-
JavaScript π
class ReportGenerator { generate(data) { return `Report Data: ${data}`; } } class ReportPrinter { print(reportContent) { console.log(`Printing: ${reportContent}`); } } // Usage const generator = new ReportGenerator(); const printer = new ReportPrinter(); const reportData = generator.generate("Sales Figures"); printer.print(reportData);
Now, ReportGenerator
only cares about generating data, and ReportPrinter
only cares about printing. Each has one, and only one, reason to change.
O - Open/Closed Principle (OCP)
"Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification."
This principle suggests that you should be able to add new functionality without altering existing, working code. This is typically achieved through abstraction and polymorphism.
Bad Example (Violates OCP)
Consider a function to calculate the total area of shapes, but it has to know about every concrete shape type.
-
Python π
# Violates OCP def calculate_total_area(shapes): total_area = 0 for shape in shapes: if isinstance(shape, Circle): total_area += 3.14 * shape.radius ** 2 elif isinstance(shape, Rectangle): total_area += shape.width * shape.height # To add a Triangle, we must modify this function! return total_area
-
JavaScript π
// Violates OCP function calculateTotalArea(shapes) { let totalArea = 0; for (const shape of shapes) { if (shape.type === 'circle') { totalArea += Math.PI * shape.radius ** 2; } else if (shape.type === 'rectangle') { totalArea += shape.width * shape.height; } // To add a Triangle, we must modify this function! } return totalArea; }
Why it's bad: Every time a new shape is introduced, the calculate_total_area
function needs to be modified. It's not closed for modification.
Good Example (Adheres to OCP)
We use polymorphism. Each shape knows how to calculate its own area.
-
Python π
class Shape: def calculate_area(self): raise NotImplementedError class Circle(Shape): def __init__(self, radius): self.radius = radius def calculate_area(self): return 3.14 * self.radius ** 2 class Rectangle(Shape): def __init__(self, width, height): self.width, self.height = width, height def calculate_area(self): return self.width * self.height # We can add new shapes without changing calculate_total_area class Triangle(Shape): def __init__(self, base, height): self.base, self.height = base, height def calculate_area(self): return 0.5 * self.base * self.height def calculate_total_area(shapes): return sum(shape.calculate_area() for shape in shapes)
-
JavaScript π
class Circle { constructor(radius) { this.radius = radius; } calculateArea() { return Math.PI * this.radius ** 2; } } class Rectangle { constructor(width, height) { this.width = width; this.height = height; } calculateArea() { return this.width * this.height; } } // We can add new shapes without changing calculateTotalArea class Triangle { constructor(base, height) { this.base = base; this.height = height; } calculateArea() { return 0.5 * this.base * this.height; } } function calculateTotalArea(shapes) { return shapes.reduce((sum, shape) => sum + shape.calculateArea(), 0); }
Now, the calculate_total_area
function is closed for modification. To add new shapes, we simply extend our system with new classes.
L - Liskov Substitution Principle (LSP) Good Example
The best way to adhere to LSP in the shape example is to avoid making Square
a subtype of Rectangle
. Instead, both should inherit from a common abstraction like Shape
that doesn't define setters that could be misused, ensuring the behavior of the derived classes is consistent with what the client expects from the base class.
Good Example (Adheres to LSP)
Both Circle
and Rectangle
(and Square
, if needed) are substitutes for the general Shape
base type, as neither breaks the contract defined by Shape
's calculate_area
method.
Python π
class Shape:
# Base class defining the expected contract: calculation of area
def calculate_area(self):
raise NotImplementedError
class Circle(Shape):
def __init__(self, radius): self.radius = radius
# Behavior is extended, not broken
def calculate_area(self): return 3.14 * self.radius ** 2
class Rectangle(Shape):
def __init__(self, width, height): self.width, self.height = width, height
# Behavior is extended, not broken
def calculate_area(self): return self.width * self.height
# A function that expects a Shape will work correctly with any subtype (LSP is maintained)
def check_area(shape: Shape):
# The client only calls a method that is correctly implemented by all subtypes
print(f"Calculated area: {shape.calculate_area()}")
# Usage: Both Circle and Rectangle are substitutable for Shape
circle = Circle(5)
rectangle = Rectangle(4, 6)
check_area(circle)
check_area(rectangle)
JavaScript π
// A common base class/interface is used.
class Shape {
calculateArea() {
throw new Error("calculateArea must be implemented");
}
}
class Circle extends Shape {
constructor(radius) { super(); this.radius = radius; }
// Behavior is extended, not broken
calculateArea() { return Math.PI * this.radius ** 2; }
}
class Rectangle extends Shape {
constructor(width, height) { super(); this.width = width; this.height = height; }
// Behavior is extended, not broken
calculateArea() { return this.width * this.height; }
}
// A function that expects a Shape (i.e., an object with a calculateArea method)
function checkArea(shape) {
// The client only calls a method that is correctly implemented by all subtypes
console.log(`Calculated area: ${shape.calculateArea()}`);
}
// Usage: Both Circle and Rectangle are substitutable for the abstract Shape contract
const circle = new Circle(5);
const rectangle = new Rectangle(4, 6);
checkArea(circle);
checkArea(rectangle);
By ensuring that derived classes don't alter the assumptions made by the client code when dealing with the base class (by using immutable properties or constructors), we uphold the Liskov Substitution Principle.
I - Interface Segregation Principle (ISP)
"Clients should not be forced to depend on interfaces they do not use."
This principle suggests that large, monolithic interfaces should be broken down into smaller, more specific ones. This way, classes only need to implement methods that are relevant to their functionality.
Bad Example (Violates ISP)
Imagine a single, "fat" Worker
interface.
-
Python π
class Worker: def work(self): raise NotImplementedError def eat(self): raise NotImplementedError class Human(Worker): def work(self): print("Human working") def eat(self): print("Human eating") class Robot(Worker): def work(self): print("Robot working") def eat(self): pass # Robots don't eat, but must implement the method.
-
JavaScript π
class Worker { work() { throw new Error("Method not implemented!"); } eat() { throw new Error("Method not implemented!"); } } class Human extends Worker { work() { console.log("Human working"); } eat() { console.log("Human eating"); } } class Robot extends Worker { work() { console.log("Robot working"); } eat() { /* Robots don't eat, but must implement the method. */ } }
Why it's bad: The Robot
class is forced to implement an eat
method it doesn't need. This creates an unnatural dependency.
Good Example (Adheres to ISP)
Segregate the interfaces into smaller, role-specific ones.
-
Python π
class Workable: def work(self): raise NotImplementedError class Eatable: def eat(self): raise NotImplementedError class Human(Workable, Eatable): def work(self): print("Human working") def eat(self): print("Human eating") class Robot(Workable): # Only implements what it needs def work(self): print("Robot working")
-
JavaScript π
// In JS, this is often done with composition or smaller classes. const workable = { work() { console.log(`${this.name} working`); } }; const eatable = { eat() { console.log(`${this.name} eating`); } }; class Human { constructor(name) { this.name = name; } } Object.assign(Human.prototype, workable, eatable); class Robot { constructor(name) { this.name = name; } } Object.assign(Robot.prototype, workable); // Only implements what it needs const human = new Human("Alice"); const robot = new Robot("C-3PO"); human.work(); // Alice working human.eat(); // Alice eating robot.work(); // C-3PO working // robot.eat(); // TypeError: robot.eat is not a function
Now, Robot
only depends on the Workable
behavior. Clients can depend on smaller, more focused abstractions.
D - Dependency Inversion Principle (DIP)
"1. High-level modules should not depend on low-level modules. Both should depend on abstractions."
"2. Abstractions should not depend on details. Details should depend on abstractions."
This principle is about decoupling. Instead of high-level logic depending directly on concrete low-level implementations, both should depend on an abstraction (like an interface). This is often achieved through Dependency Injection.
Bad Example (Violates DIP)
A PasswordReminder
(high-level) module is directly coupled to a MySQLDatabase
(low-level) module.
-
Python π
class MySQLDatabase: def connect(self): print("Connecting to MySQL...") def get_user_data(self): return "User data from MySQL" class PasswordReminder: def __init__(self): self.db_connection = MySQLDatabase() # <-- Direct dependency! def get_user(self): self.db_connection.connect() return self.db_connection.get_user_data()
-
JavaScript π
class MySQLDatabase { connect() { console.log("Connecting to MySQL..."); } getUserData() { return "User data from MySQL"; } } class PasswordReminder { constructor() { this.dbConnection = new MySQLDatabase(); // <-- Direct dependency! } getUser() { this.dbConnection.connect(); return this.dbConnection.getUserData(); } }
Why it's bad: The PasswordReminder
is completely tied to MySQLDatabase
. If we want to switch to a PostgreSQL database or use a mock database for testing, we have to change the PasswordReminder
class.
Good Example (Adheres to DIP)
We introduce an abstraction that both modules can depend on. The dependency is "injected" into the high-level module.
-
Python π
class DatabaseConnection: # The Abstraction def connect(self): raise NotImplementedError def get_user_data(self): raise NotImplementedError class MySQLDatabase(DatabaseConnection): # A Detail def connect(self): print("Connecting to MySQL...") def get_user_data(self): return "User data from MySQL" class PostgreSQLDatabase(DatabaseConnection): # Another Detail def connect(self): print("Connecting to PostgreSQL...") def get_user_data(self): return "User data from PostgreSQL" class PasswordReminder: # High-level module def __init__(self, db_connection: DatabaseConnection): # Depends on abstraction self.db_connection = db_connection def get_user(self): self.db_connection.connect() return self.db_connection.get_user_data() # We can now 'inject' any database that follows the contract. mysql = MySQLDatabase() reminder = PasswordReminder(mysql)
-
JavaScript π
// In JS, the abstraction is often an implicit contract (duck typing). // We expect any database object to have `connect` and `getUserData` methods. class MySQLDatabase { // A Detail connect() { console.log("Connecting to MySQL..."); } getUserData() { return "User data from MySQL"; } } class PostgreSQLDatabase { // Another Detail connect() { console.log("Connecting to PostgreSQL..."); } getUserData() { return "User data from PostgreSQL"; } } class PasswordReminder { // High-level module constructor(dbConnection) { // Depends on the abstraction (the contract) this.dbConnection = dbConnection; } getUser() { this.dbConnection.connect(); return this.dbConnection.getUserData(); } } // We can now 'inject' any database that follows the contract. const mysql = new MySQLDatabase(); const reminder = new PasswordReminder(mysql); console.log(reminder.getUser());
By depending on an abstraction, our PasswordReminder
is now decoupled from the specific database implementation, making it more flexible, reusable, and testable.
Conclusion
Adopting the SOLID principles is an investment in the long-term health of your codebase. It might seem like more work upfront, but it leads to software that is easier to scale, refactor, and understand. By building systems with single responsibilities, that are open to extension, with substitutable parts, lean interfaces, and inverted dependencies, you create a foundation for truly robust and elegant software. Happy coding!
Top comments (0)