The SOLID acronym represents five fundamental principles of object-oriented design (OOD) introduced by Robert C. Martin. These principles facilitate the creation of software that is extensible, maintainable, and easy to refactor. They form an essential part of agile and adaptive development methodologies.
Note: This is not a basic introduction but rather a comprehensive exploration of what SOLID entails.
SOLID stands for:
- S - Single Responsibility Principle
- O - Open/Closed Principle
- L - Liskov Substitution Principle
- I - Interface Segregation Principle
- D - Dependency Inversion Principle
Let's examine each principle to understand how SOLID helps us become better developers.
Single Responsibility Principle
A class should have only one reason to change, meaning it should have only one responsibility.
Consider a scenario where we have various shapes and want to calculate their total area. Initially, we might create shape classes:
class Circle {
constructor(public radius: number) {}
}
class Square {
constructor(public side: number) {}
}
Then, we create a calculator class that handles both calculation and output:
class AreaCalculator {
constructor(private shapes: any[]) {}
public total() {
// calculation logic
}
public display() {
return `Sum of areas: ${this.total()}`;
}
}
The problem is that AreaCalculator handles both calculation and display logic. If users want JSON output or HTML formatting, we'd need to modify this class, violating SRP.
The solution is to separate concerns:
class OutputFormatter {
constructor(private calculator: AreaCalculator) {}
public toJSON() {
return JSON.stringify({ sum: this.calculator.total() });
}
public toHTML() {
return `<p>Total area: ${this.calculator.total()}</p>`;
}
}
Open/Closed Principle
Software entities should be open for extension but closed for modification.
In our AreaCalculator, the total() method might look like this:
public total() {
let sum = 0;
for (const shape of this.shapes) {
if (shape instanceof Square) {
sum += shape.side ** 2;
} else if (shape instanceof Circle) {
sum += Math.PI * shape.radius ** 2;
}
}
return sum;
}
Adding new shapes requires modifying this method, violating OCP. Instead, let each shape handle its own calculation:
interface Shape {
area(): number;
}
class Square implements Shape {
constructor(public side: number) {}
area() {
return this.side ** 2;
}
}
class Circle implements Shape {
constructor(public radius: number) {}
area() {
return Math.PI * this.radius ** 2;
}
}
Now the calculator becomes:
public total() {
return this.shapes.reduce((sum, shape) => sum + shape.area(), 0);
}
Liskov Substitution Principle
Subtypes must be substitutable for their base types without altering program correctness.
Consider extending our calculator:
class VolumeCalculator extends AreaCalculator {
public total() {
// volume calculation logic
return [200]; // Returns array instead of number
}
}
This breaks LSP because the return type differs. If code expects a number from total(), it will fail with VolumeCalculator. The fix:
class VolumeCalculator extends AreaCalculator {
public total(): number {
// volume calculation logic
return 200;
}
}
Interface Segregation Principle
Clients should not be forced to depend on interfaces they don't use.
If we add a volume() method to Shape:
interface Shape {
area(): number;
volume(): number; // Problematic for 2D shapes
}
This forces 2D shapes to implement a meaningless method. Better approach:
interface Shape {
area(): number;
}
interface SolidShape {
volume(): number;
}
class Cube implements Shape, SolidShape {
area() {
// surface area calculation
}
volume() {
// volume calculation
}
}
Dependency Inversion Principle
High-level modules should not depend on low-level modules; both should depend on abstractions.
Consider:
class MySQLDatabase {
connect() {
// MySQL connection logic
}
}
class UserService {
constructor(private database: MySQLDatabase) {}
}
UserService is tightly coupled to MySQLDatabase. Better to depend on abstraction:
interface DatabaseConnection {
connect(): void;
}
class MySQLDatabase implements DatabaseConnection {
connect() {
// MySQL connection logic
}
}
class PostgresDatabase implements DatabaseConnection {
connect() {
// PostgreSQL connection logic
}
}
class UserService {
constructor(private database: DatabaseConnection) {}
}
Now UserService can work with any database implementation without modification.