Understanding SOLID Principles in Object-Oriented Design

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.

Tags: TypeScript SOLID OOP design-patterns software-architecture

Posted on Tue, 02 Jun 2026 17:00:49 +0000 by elwadhos