Published on

A Comprehensive Guide to SOLID Principles in Object-Oriented Design

In the world of software engineering, particularly in object-oriented programming (OOP), the pursuit of "good design" is a constant endeavor. But what constitutes good design? A well-designed system is typically one that is easy to understand, simple to change, and straightforward to maintain over its lifecycle. Conversely, poorly designed systems are often described as rigid (difficult to change), fragile (easy to break), and viscous (hard to work with) [43]. To combat this software entropy, a set of guiding principles is needed. Among the most influential are the SOLID principles.

Promoted by renowned software engineer Robert C. Martin ("Uncle Bob"), the SOLID principles are a collection of five foundational guidelines for object-oriented design [48]. While the individual concepts predate their grouping, Martin began to codify them in the late 1990s and early 2000s, most notably in his 2000 paper, "Design Principles and Design Patterns" [43]. The acronym SOLID was later coined by Michael Feathers. These principles are not strict laws but rather heuristics that, when applied judiciously, lead to more robust, scalable, and maintainable software architectures [47]. They provide a common language for developers to discuss design decisions and serve as a powerful toolkit for refactoring code towards a cleaner state.

This article provides a deep and comprehensive dive into each of the five SOLID principles. We will explore the "why" behind each principle, illustrate violations and correct implementations with practical code examples, and discuss their collective impact on building high-quality, long-lasting software systems.

Table of Contents

1. S: The Single Responsibility Principle (SRP)

The Single Responsibility Principle is perhaps the most fundamental and easiest to grasp, yet it is frequently violated.

The Principle: A class should have one, and only one, reason to change [44].

This definition is deceptively simple. The key is to understand what constitutes a "reason to change." Martin clarifies this by linking it to actors or stakeholders. A reason to change is a change request that originates from a particular user or stakeholder group. Therefore, a class should be responsible to a single actor.

When a class has more than one responsibility, those responsibilities become coupled. A change made for one reason might have unintended and detrimental effects on the other responsibilities. This coupling leads to a fragile design where modifications are risky and complex.

Violation Example

Consider a Employee class that manages employee data but also includes logic for calculating payroll and generating a report for the HR department.

// VIOLATION of SRP
class Employee {
    constructor(public name: string, public salary: number) {}

    // Responsibility 1: Data Management (for DBAs, Core Ops)
    getEmployeeDetails() {
        return { name: this.name, salary: this.salary };
    }

    // Responsibility 2: Financial Calculation (for Finance Dept)
    calculatePay(): number {
        const taxRate = 0.2;
        return this.salary * (1 - taxRate);
    }

    // Responsibility 3: Reporting (for HR Dept)
    generateReport(): string {
        const details = this.getEmployeeDetails();
        return `Employee: ${details.name}\nSalary: ${details.salary}`;
    }
}

This class has three reasons to change:

  1. The database schema for employee data might change (affecting getEmployeeDetails).
  2. The tax calculation logic might change (affecting calculatePay).
  3. The format of the HR report might change (affecting generateReport).

A change requested by the finance department could inadvertently break a feature used by the HR department.

Refactored Example

To adhere to SRP, we separate these concerns into distinct classes, each with a single responsibility.

// ADHERENCE to SRP

// Responsibility 1: Core Employee Data
class EmployeeData {
    constructor(public name: string, public salary: number) {}
}

// Responsibility 2: Financial Calculations
class PayCalculator {
    calculatePay(employee: EmployeeData): number {
        const taxRate = 0.2;
        return employee.salary * (1 - taxRate);
    }
}

// Responsibility 3: Reporting
class ReportGenerator {
    generateForHR(employee: EmployeeData): string {
        return `Employee: ${employee.name}\nSalary: ${employee.salary}`;
    }
}

Now, each class has only one reason to change. The PayCalculator can be modified by the finance team without any risk to the ReportGenerator used by HR. This design is more modular, easier to understand, and significantly easier to test in isolation [49].

2. O: The Open/Closed Principle (OCP)

The Open/Closed Principle is at the heart of creating extensible and reusable software.

The Principle: Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification [40].

This means that you should be able to add new functionality to a system without changing existing, tested code. The "closed" part protects existing code from being broken, while the "open" part allows the system to grow and adapt to new requirements. This is typically achieved through abstractions, such as interfaces or abstract base classes.

Violation Example

Imagine a PaymentProcessor class that processes payments. If you need to add a new payment method, you have to modify the class.

// VIOLATION of OCP
enum PaymentMethod { CREDIT_CARD, PAYPAL, STRIPE }

class PaymentProcessor {
    processPayment(amount: number, method: PaymentMethod) {
        if (method === PaymentMethod.CREDIT_CARD) {
            console.log(`Processing ${amount} via Credit Card.`);
        } else if (method === PaymentMethod.PAYPAL) {
            console.log(`Processing ${amount} via PayPal.`);
        } else if (method === PaymentMethod.STRIPE) {
            console.log(`Processing ${amount} via Stripe.`);
        }
        // To add a new payment method, we must MODIFY this class!
    }
}

Every time a new payment gateway is introduced, this processPayment method must be changed. This makes the class brittle, and each modification requires re-testing the entire class.

Refactored Example

To fix this, we can use an interface that defines a contract for payment processing. Each payment method will be its own class implementing this interface.

// ADHERENCE to OCP

// The abstraction (our contract)
interface IPaymentGateway {
    process(amount: number): void;
}

// Concrete implementations (our "extensions")
class CreditCardGateway implements IPaymentGateway {
    process(amount: number) {
        console.log(`Processing ${amount} via Credit Card.`);
    }
}

class PayPalGateway implements IPaymentGateway {
    process(amount: number) {
        console.log(`Processing ${amount} via PayPal.`);
    }
}

class StripeGateway implements IPaymentGateway {
    process(amount: number) {
        console.log(`Processing ${amount} via Stripe.`);
    }
}

// The high-level class is now "closed" for modification
// but "open" to new payment gateways.
class PaymentProcessor {
    processPayment(amount: number, gateway: IPaymentGateway) {
        gateway.process(amount);
    }
}

// We can now add a new CryptoGateway without touching PaymentProcessor.
class CryptoGateway implements IPaymentGateway {
    process(amount: number) {
        console.log(`Processing ${amount} via Cryptocurrency.`);
    }
}

The PaymentProcessor is now closed for modification but open for extension. We can create any number of new gateway classes that implement IPaymentGateway, and the PaymentProcessor will work with them seamlessly without a single line of its code being changed. This embodies the OCP and leverages patterns like the Strategy Pattern.

3. L: The Liskov Substitution Principle (LSP)

The Liskov Substitution Principle is the most mathematically rigorous of the SOLID principles and is crucial for creating correct and reliable class hierarchies. It was introduced by Barbara Liskov in 1987 [45].

The Principle: If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2, then S is a subtype of T [50].

In simpler terms: subtypes must be substitutable for their base types without altering the correctness of the program. This means a subclass should behave in all the same ways as its parent class. It must not require more (stronger preconditions) and must not promise less (weaker postconditions).

Violation Example

The classic example is the Rectangle/Square problem. Mathematically, a square is a rectangle. But in OOP, inheriting Square from Rectangle can violate LSP.

// VIOLATION of LSP
class Rectangle {
    constructor(public width: number, public height: number) {}

    setWidth(width: number) { this.width = width; }
    setHeight(height: number) { this.height = height; }

    getArea(): number { return this.width * this.height; }
}

class Square extends Rectangle {
    constructor(side: number) {
        super(side, side);
    }

    // To maintain the "square" property, we must override the setters
    setWidth(width: number) {
        this.width = width;
        this.height = width; // Side effect not present in the base class!
    }

    setHeight(height: number) {
        this.width = height;
        this.height = height; // Side effect not present in the base class!
    }
}

// A function that uses the base class
function useRectangle(rect: Rectangle) {
    rect.setWidth(5);
    rect.setHeight(4);
    const area = rect.getArea();
    // The user of this function expects the area to be 5 * 4 = 20
    console.log(`Expected area: 20, Got: ${area}`);
}

const rect = new Rectangle(10, 10);
const square = new Square(10);

useRectangle(rect);   // Output: Expected area: 20, Got: 20
useRectangle(square); // Output: Expected area: 20, Got: 16

When we substitute a Square for a Rectangle, the useRectangle function breaks. The Square subclass changes the behavior of the setHeight and setWidth methods in a way that the client code does not expect. This is a clear violation of LSP. The fix is often to rethink the hierarchy; perhaps Square and Rectangle should not have an inheritance relationship but instead implement a common IShape interface.

4. I: The Interface Segregation Principle (ISP)

The Interface Segregation Principle addresses the problem of "fat" interfaces—interfaces that are too broad and force clients to depend on methods they do not use.

The Principle: Clients should not be forced to depend on interfaces that they do not use.

Large, monolithic interfaces lead to unnecessary coupling. If a class implements an interface with methods it doesn't need, it is still forced to provide an implementation (even if it's just throwing an exception). Furthermore, if that "fat" interface changes for a reason that doesn't concern our class, our class may still be forced to recompile or change.

Violation Example

Consider a "smart" printer that can print, staple, and fax. We create a single large interface for it.

// VIOLATION of ISP ("Fat" Interface)
interface IMultiFunctionDevice {
    print(document: any): void;
    staple(document: any): void;
    fax(document: any): void;
}

// A full-featured machine is fine.
class SmartPrinter implements IMultiFunctionDevice {
    print(doc: any) { /* ... */ }
    staple(doc: any) { /* ... */ }
    fax(doc: any) { /* ... */ }
}

// What about a cheap, old printer?
class OldFashionedPrinter implements IMultiFunctionDevice {
    print(doc: any) { /* ... */ }
    
    // Forced to implement methods it doesn't support!
    staple(doc: any) {
        throw new Error("Stapling not supported.");
    }

    fax(doc: any) {
        throw new Error("Faxing not supported.");
    }
}

The OldFashionedPrinter is forced to implement staple and fax, methods it cannot perform. This is a clear sign that the IMultiFunctionDevice interface is too "fat."

Refactored Example

ISP suggests breaking down large interfaces into smaller, more specific ones based on client needs.

// ADHERENCE to ISP (Segregated Interfaces)
interface IPrinter {
    print(document: any): void;
}

interface IStapler {
    staple(document: any): void;
}

interface IFax {
    fax(document: any): void;
}

// Now we can compose interfaces as needed.
class SmartPrinter implements IPrinter, IStapler, IFax {
    print(doc: any) { /* ... */ }
    staple(doc: any) { /* ... */ }
    fax(doc: any) { /* ... */ }
}

class OldFashionedPrinter implements IPrinter {
    print(doc: any) { /* ... */ }
}

Now, OldFashionedPrinter only needs to concern itself with the IPrinter interface. Clients can depend on just the functionality they need (e.g., a client that only ever prints will depend on IPrinter, not IStapler).

5. D: The Dependency Inversion Principle (DIP)

The Dependency Inversion Principle is a key part of creating loosely coupled systems and is the enabling principle behind dependency injection frameworks.

The Principle: A. High-level modules should not depend on low-level modules. Both should depend on abstractions. B. Abstractions should not depend on details. Details should depend on abstractions [51].

In a traditional software architecture, high-level policy-setting modules (e.g., business logic) depend on low-level implementation detail modules (e.g., database access, network calls). DIP inverts this relationship. The high-level module defines an abstraction (an interface) that it needs, and the low-level module implements that abstraction. This "inverts" the direction of dependency—the implementation details now depend on the abstraction defined by the high-level policy.

It is crucial to distinguish between DIP (the principle) and Dependency Injection (DI), a design pattern. DI is a technique for supplying dependencies to an object from an outside source, which is one way to achieve DIP [46].

Violation Example

A PasswordReminder class (high-level module) directly depends on a MySQLDatabase class (low-level module).

// VIOLATION of DIP
class MySQLDatabase {
    getUsers(): any[] { /* ... fetches users from MySQL */ return []; }
}

class PasswordReminder {
    private dbConnection: MySQLDatabase;

    constructor() {
        // The high-level class directly creates and depends on the low-level one.
        this.dbConnection = new MySQLDatabase();
    }

    sendReminders() {
        const users = this.dbConnection.getUsers();
        // ... logic to send reminders
    }
}

The PasswordReminder is tightly coupled to MySQLDatabase. If we want to switch to a different database, like PostgreSQL, we have to modify the PasswordReminder class. It's also very difficult to test PasswordReminder without a running MySQL database.

Refactored Example

We introduce an abstraction that the high-level module owns.

// ADHERENCE to DIP

// 1. The high-level module defines the abstraction it needs.
interface IDatabase {
    getUsers(): any[];
}

// 2. The low-level modules implement that abstraction.
class MySQLDatabase implements IDatabase {
    getUsers(): any[] { /* ... fetches users from MySQL */ return []; }
}

class PostgreSQLDatabase implements IDatabase {
    getUsers(): any[] { /* ... fetches users from PostgreSQL */ return []; }
}

// 3. The high-level module depends only on the abstraction.
class PasswordReminder {
    private dbConnection: IDatabase;

    // The dependency is "injected" from the outside.
    constructor(database: IDatabase) {
        this.dbConnection = database;
    }

    sendReminders() {
        const users = this.dbConnection.getUsers();
        // ... logic to send reminders
    }
}

// At the highest level of our application (the "main" function),
// we compose the concrete classes.
// const reminder = new PasswordReminder(new MySQLDatabase());
// or
// const reminder = new PasswordReminder(new PostgreSQLDatabase());

Now, the PasswordReminder is no longer dependent on any specific database technology. It depends only on the IDatabase interface that it defined for its own needs. This makes the system flexible, modular, and easy to test with mock database implementations.

Conclusion: The Whole is Greater than the Sum of its Parts

Individually, each SOLID principle addresses a specific problem. But together, they form a cohesive philosophy for designing software that is resilient to change.

  • SRP starts the process by breaking down complexity into manageable, focused units.
  • OCP and DIP provide the mechanisms for these units to collaborate in a loosely coupled way, using abstractions.
  • LSP ensures that these abstractions are reliable and that inheritance hierarchies are behaviorally sound.
  • ISP keeps the interfaces for these abstractions clean and focused.

While there are criticisms that strict adherence can lead to over-engineering or an explosion of small classes, this is a misunderstanding of their purpose. SOLID principles are not dogmatic rules but guidelines that require context and professional judgment. As modern research into their application in complex domains like AI and machine learning shows, their core ideas remain profoundly relevant for managing complexity [36, 41]. By mastering these principles, developers can move from simply writing code that works now to engineering systems that are built to last.

References

  1. Martin, R. C. (2000). "Design Principles and Design Patterns". objectmentor.com. [43, 48]
  2. Liskov, B., & Wing, J. (1994). "A behavioral notion of subtyping". ACM Transactions on Programming Languages and Systems. [45, 50]
  3. BMC Blogs. (2024). "SOLID Principles in Object Oriented Design". [43]
  4. ITNext. (2025). "The Single Responsibility Principle (SRP) in Kotlin | SOLID...". [44]
  5. Stack Overflow. "Difference between dependency injection and dependency inversion". [46]
  6. Okoone. (2025). "Why top engineering leaders swear by SOLID principles". [47]
  7. Darsh Patel's Blog. (2024). "Understanding the Single Responsibility Principle in S.O.L.I.D". [49]
  8. Dev.to. (2025). "Dependency Inversion Principle (DIP) & Dependency Injection (DI) in C# (Easy Examples)". [51]
  9. GeeksforGeeks. (2022). "Single Responsibility in SOLID Design Principle". [52]
  10. The SCIPUB. (2021). "Impact of Design Principles and Patterns on Software Flexibility". [40]
  11. ArXiv. (2025). "Evaluating the Application of SOLID Principles in Modern AI Framework Architectures". [36]
  12. ArXiv. (2024). "Investigating the Impact of SOLID Design Principles on Machine Learning Code Understanding". [41]