No history yet

SOLID Principles Mastery

Beyond Functional Code

Writing code that works is just the first step. Writing code that lasts—that can be maintained, extended, and understood by others—is a different skill entirely. This is where software design principles come in. They are not strict rules, but rather guidelines that help us avoid common pitfalls that lead to brittle, tangled codebases.

One of the most famous sets of these guidelines is known by the acronym SOLID. Coined by Michael Feathers, these five principles build on the work of Robert C. Martin and focus on creating understandable, flexible, and maintainable systems using object-oriented programming.

S: The Single Responsibility Principle

The Single Responsibility Principle (SRP) states that a class should have only one reason to change. This doesn't mean a class can only have one method, but that all the methods and properties in a class should be focused on a single, cohesive purpose.

When a class does too much, it becomes what's known as a "god object." It knows everything and does everything. This creates tight coupling, where a change to one of its many responsibilities can cause a cascade of bugs in others. By giving each class one job, you isolate responsibilities. If you need to change how data is saved, you only have to touch the data-saving class.

Imagine a chef who also waits tables, washes dishes, and manages the restaurant's finances. If the menu changes, their financial process might accidentally break. SRP is about hiring a dedicated person for each job.

Consider a class that manages employee data. A bad design would lump everything together.

// BAD: This class has three responsibilities.
class Employee {
  constructor(name, title, salary) {
    this.name = name;
    this.title = title;
    this.salary = salary;
  }

  // Responsibility 1: Data storage
  getName() { return this.name; }

  // Responsibility 2: Business logic
  calculateYearlyBonus() {
    return this.salary * 0.1;
  }

  // Responsibility 3: Persistence
  saveToDatabase() {
    // logic to connect to DB and save employee...
    console.log(`Saving ${this.name} to the database.`);
  }
}

A change in the bonus calculation logic could unintentionally affect the database logic. Applying SRP, we separate these concerns into distinct classes.

// GOOD: Each class has a single responsibility.

// Responsibility 1: Store employee data.
class Employee {
  constructor(name, title, salary) {
    this.name = name;
    this.title = title;
    this.salary = salary;
  }
}

// Responsibility 2: Handle financial calculations.
class PayCalculator {
  calculateYearlyBonus(employee) {
    return employee.salary * 0.1;
  }
}

// Responsibility 3: Manage database interactions.
class EmployeeRepository {
  save(employee) {
    // logic to connect to DB and save employee...
    console.log(`Saving ${employee.name} to the database.`);
  }
}

O: The Open/Closed Principle

The Open/Closed Principle (OCP) says that software entities (classes, modules, functions) should be open for extension but closed for modification. In other words, you should be able to add new functionality without changing existing, working code.

Every time you modify existing code, you risk introducing bugs into a system that was previously stable. OCP encourages the use of abstractions like interfaces or abstract base classes. This allows new features to be added as new classes that implement the abstraction, rather than by adding more if statements to a core class. This is a classic application of polymorphism to achieve flexible design.

Imagine a system that generates reports. If it's built to only handle PDF, adding CSV support would require modifying the main class.

// BAD: This class must be modified for each new format.
class ReportGenerator {
  generate(reportData, format) {
    if (format === 'pdf') {
      console.log('Generating PDF report...');
    } else if (format === 'csv') {
      console.log('Generating CSV report...');
    } // Must add new 'else if' for JSON, XML, etc.
  }
}

The OCP-compliant way is to define a contract (an interface) and let each format provide its own implementation.

// GOOD: Open for extension, closed for modification.

// The "contract" or abstraction
class ReportFormatter {
  format(data) { throw new Error('Must implement format method'); }
}

// Extensions
class PdfFormatter extends ReportFormatter {
  format(data) { console.log('Formatting data for PDF...'); }
}

class CsvFormatter extends ReportFormatter {
  format(data) { console.log('Formatting data for CSV...'); }
}

// The core class no longer needs to change.
class ReportGenerator {
  generate(reportData, formatter) {
    // formatter is an instance of a ReportFormatter subclass
    formatter.format(reportData);
  }
}

L, I, and D: Structuring Abstractions

The final three principles are closely related and guide how we create and use abstractions like interfaces and base classes.

Liskov Substitution Principle

other

Objects of a superclass shall be replaceable with objects of its subclasses without breaking the application. In essence, a subclass must honor the contract of its parent class.

LSP is about trust and predictability. If a function expects a Bird object, it should work correctly whether you pass it a Sparrow or a Pigeon object. However, if you pass it an Ostrich object and the function calls a fly() method, the program might crash. The Ostrich subclass breaks the implicit contract of the Bird superclass. This means Ostrich is not a valid substitute for Bird, violating LSP.

This principle helps you create reliable inheritance hierarchies.

If it looks like a duck and quacks like a duck but needs batteries, you probably have a Liskov Substitution problem.

The (ISP) is SRP for interfaces. It states that clients should not be forced to depend on methods they do not use. Instead of one large, general-purpose interface, it's better to have several smaller, specific ones.

For example, an interface for a Worker might have work() and eat() methods. A HumanWorker can implement both. But a RobotWorker can't eat(). Forcing the robot class to implement an eat() method (even an empty one) is a design smell. The better solution is to segregate the interface into Workable and Eatable interfaces.

Finally, the Dependency Inversion Principle (DIP) turns traditional software flow on its head. It has two parts:

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions (e.g., interfaces).
  2. Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.

This sounds complex, but it's about decoupling. Instead of a high-level PasswordReminder class directly creating and depending on a low-level MySQLDatabase class, both should depend on a DatabaseConnection interface. This allows you to swap MySQLDatabase for a PostgresDatabase or even a mock database for testing without changing the PasswordReminder class at all.

// BAD: High-level module depends directly on low-level module.
class PasswordReminder {
  constructor() {
    // Tight coupling to a specific database type
    this.dbConnection = new MySQLDatabase();
  }
  // ...
}

// GOOD: Both depend on an abstraction (interface).
class PasswordReminder {
  // The dependency is "injected" via the constructor.
  constructor(databaseConnection) {
    this.dbConnection = databaseConnection;
  }
  // ...
}

// The calling code decides which concrete implementation to use.
const mySQL = new MySQLDatabase();
const reminder = new PasswordReminder(mySQL);

Let's check your understanding of these core principles.

Quiz Questions 1/6

What is the core idea behind the Single Responsibility Principle (SRP)?

Quiz Questions 2/6

A software module is said to follow the Open/Closed Principle (OCP) if...

Mastering these five principles is a significant step toward writing professional, scalable, and maintainable software. They encourage you to think about the relationships between your classes and the long-term health of your codebase.