Skip to content
Menu
Rohit Naik Kundaikar
  • Home
  • Contact
Rohit Naik Kundaikar

Mastering Clean Code: A Deep Dive into SOLID Principles

Posted on July 19, 2025July 19, 2025 by Rohit Naik Kundaikar

In software development, writing functional code is just one part of the challenge. Creating code that is maintainable, flexible, scalable, and easy to understand is equally, if not more, important. This is where the SOLID principles come into play. Coined by Robert C. Martin (Uncle Bob), SOLID is an acronym representing five fundamental design principles that guide developers in building robust and adaptable object-oriented software.

Adhering to SOLID principles helps mitigate common issues like rigid, fragile, and immobile codebases, ultimately leading to higher quality software that is easier to evolve over time.

Let’s explore each principle in detail, with examples primarily in JavaScript (which can easily be adapted to TypeScript).

1. Single Responsibility Principle (SRP)

Definition: A class should have only one reason to change, meaning it should have only one responsibility or job.

Explanation: This principle emphasizes that each class or module should be cohesive, focusing on a single, well-defined task. If a class has multiple responsibilities, changes related to one responsibility might inadvertently affect others, leading to unexpected bugs and a codebase that’s difficult to maintain. By adhering to SRP, code becomes more modular, testable, and easier to understand and modify.

Example (Violation):

Consider a User class that handles both user data management and user authentication.

class User {
  constructor(username, email, password) {
    this.username = username;
    this.email = email;
    this.password = password; // In a real app, never store plain passwords!
  }

  // Responsibility 1: Managing user data
  saveToDatabase() {
    console.log(`Saving user ${this.username} to database.`);
    // Code to save user data to the database
  }

  // Responsibility 2: Authenticating users
  authenticate(username, password) {
    console.log(`Attempting to authenticate ${username}.`);
    return this.username === username && this.password === password;
  }
}

// Usage
const newUser = new User("john.doe", "john@example.com", "securepassword");
newUser.saveToDatabase();
console.log(`Authentication successful: ${newUser.authenticate("john.doe", "securepassword")}`);

In this example, the User class has two distinct responsibilities: managing user data and handling authentication. If the database schema changes, the saveToDatabase method needs modification. If the authentication logic changes (e.g., adding multi-factor authentication), the authenticate method needs modification. These are two separate reasons for the User class to change.

Example (Adherence):

We can refactor this by separating the concerns into distinct classes.

// User class: Responsible only for managing user data
class User {
  constructor(username, email, password) {
    this.username = username;
    this.email = email;
    this.password = password;
  }

  saveToDatabase() {
    console.log(`Saving user ${this.username} to database.`);
    // Actual code to save user data to the database
  }
}

// Authenticator class: Responsible only for authenticating users
class Authenticator {
  static authenticate(user, passwordAttempt) {
    console.log(`Attempting to authenticate user ${user.username}.`);
    // In a real app, compare hashed passwords
    return user.password === passwordAttempt;
  }
}

// Usage
const newUser = new User("jane.doe", "jane@example.com", "anothersecurepass");
newUser.saveToDatabase();
console.log(`Authentication successful: ${Authenticator.authenticate(newUser, "anothersecurepass")}`);

Now, the User class is solely responsible for user data, and the Authenticator class is solely responsible for authentication. Each has only one reason to change.

2. Open/Closed Principle (OCP)

Definition: Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.

Explanation: This principle suggests that you should be able to extend the behavior of a class without altering its existing source code. This is typically achieved through polymorphism, using inheritance, interfaces (conceptually in JavaScript, explicitly in TypeScript), or abstract classes. By adhering to OCP, you can add new features or behaviors to your system without modifying existing, tested code, significantly reducing the risk of introducing bugs and making your system more stable.

Example (Violation):

Consider a ShapeCalculator that calculates the area of different shapes.

class ShapeCalculator {
  calculateArea(shape) {
    if (shape.type === 'circle') {
      return Math.PI * shape.radius * shape.radius;
    } else if (shape.type === 'rectangle') {
      return shape.width * shape.height;
    }
    // What if we add a Triangle? We'd have to modify this method!
    throw new Error('Unknown shape type');
  }
}

const circle = { type: 'circle', radius: 5 };
const rectangle = { type: 'rectangle', width: 4, height: 6 };

const calculator = new ShapeCalculator();
console.log(`Area of circle: ${calculator.calculateArea(circle)}`);
console.log(`Area of rectangle: ${calculator.calculateArea(rectangle)}`);

If we want to add a new shape (e.g., Triangle), we would have to modify the calculateArea method, violating OCP.

Example (Adherence):

We can achieve OCP by defining a common interface or base class for shapes and having each shape implement its own area calculation.

// Define an interface (conceptually in JavaScript, or a true interface in TypeScript)
// All shapes should have an 'area()' method.

class Circle {
  constructor(radius) {
    this.radius = radius;
  }
  area() {
    return Math.PI * this.radius * this.radius;
  }
}

class Rectangle {
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }
  area() {
    return this.width * this.height;
  }
}

// New shape: Triangle, added without modifying existing code
class Triangle {
  constructor(base, height) {
    this.base = base;
    this.height = height;
  }
  area() {
    return 0.5 * this.base * this.height;
  }
}

class AreaCalculator {
  // This method is closed for modification, but open for extension (new shapes)
  calculateAreas(shapes) {
    return shapes.map(shape => shape.area());
  }
}

const shapes = [
  new Circle(5),
  new Rectangle(4, 6),
  new Triangle(3, 8) // Easily add new shapes
];

const areaCalculator = new AreaCalculator();
console.log(`Areas: ${areaCalculator.calculateAreas(shapes)}`);

Now, to add a new shape, you simply create a new class that implements the area() method, and the AreaCalculator remains unchanged.

3. Liskov Substitution Principle (LSP)

Definition: Objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program.

Explanation: This principle, named after Barbara Liskov, states that any derived class (subclass) should be substitutable for its base class (superclass) without altering the desirable properties of the program. In simpler terms, if a function expects an object of type A, it should work correctly if given an object of type B, where B is a subclass of A. Violating LSP can lead to unexpected behavior, runtime errors, and broken functionalities in code that relies on polymorphism.

Example (Violation):

Consider a Bird class with a fly method, and a Penguin subclass.

class Bird {
  fly() {
    console.log("Bird is flying.");
  }
}

class Penguin extends Bird {
  fly() {
    // Penguins cannot fly, so this method either throws an error or does nothing,
    // which violates the expectation of a 'Bird' being able to 'fly'.
    throw new Error("Penguins cannot fly!");
  }
}

function makeBirdFly(bird) {
  bird.fly();
}

const commonBird = new Bird();
const emperorPenguin = new Penguin();

makeBirdFly(commonBird); // Works
try {
  makeBirdFly(emperorPenguin); // Throws an error, violating LSP
} catch (e) {
  console.error(e.message);
}

Here, Penguin is a Bird, but it cannot perform the fly action in the way a generic Bird is expected to. This violates LSP because substituting Bird with Penguin breaks the program’s expected behavior.

Example (Adherence):

To adhere to LSP, we should design our hierarchy based on capabilities rather than strict “is-a” relationships if those relationships lead to behavioral inconsistencies.

// Define a capability (interface concept)
class Flyable {
  fly() {
    throw new Error("This method must be implemented by subclasses.");
  }
}

class Bird {
  // Base class for all birds, not necessarily flyable
  layEggs() {
    console.log("Bird is laying eggs.");
  }
}

class FlyingBird extends Bird {
  // Only flying birds implement the fly capability
  fly() {
    console.log("Flying bird is soaring.");
  }
}

class Penguin extends Bird {
  // Penguins are birds, but they don't fly, they swim
  swim() {
    console.log("Penguin is swimming.");
  }
}

function makeFlyingBirdFly(bird) {
  if (bird instanceof FlyingBird) {
    bird.fly();
  } else {
    console.log("This bird cannot fly in the sky.");
  }
}

const eagle = new FlyingBird();
const arcticPenguin = new Penguin();

makeFlyingBirdFly(eagle);
makeFlyingBirdFly(arcticPenguin); // No error, handles non-flying birds gracefully

By introducing FlyingBird and making Penguin a Bird but not a FlyingBird, we ensure that any object passed to makeFlyingBirdFly will behave as expected, upholding LSP.

4. Interface Segregation Principle (ISP)

Definition: Clients should not be forced to depend on interfaces they do not use.

Explanation: ISP advises against creating large, “fat” interfaces that bundle too many methods. Instead, it promotes designing smaller, more focused interfaces that are specific to the needs of the clients that use them. This prevents clients from being burdened with methods they don’t need, making systems easier to understand, maintain, and extend. It also reduces coupling and improves the flexibility of the design.

Example (Violation):

Consider a monolithic Worker interface.

// A "fat" interface that combines too many responsibilities
class IWorker { // Conceptual interface
  work() { throw new Error("Not implemented"); }
  eat() { throw new Error("Not implemented"); }
  sleep() { throw new Error("Not implemented"); }
  manageTeam() { throw new Error("Not implemented"); }
  codeReview() { throw new Error("Not implemented"); }
}

class SimpleWorker extends IWorker {
  work() { console.log("Simple worker is working."); }
  eat() { console.log("Simple worker is eating lunch."); }
  sleep() { console.log("Simple worker is sleeping."); }
  // SimpleWorker doesn't manage a team or do code reviews, but is forced to implement these.
  manageTeam() { throw new Error("SimpleWorker cannot manage a team."); }
  codeReview() { throw new Error("SimpleWorker cannot do code review."); }
}

const worker = new SimpleWorker();
worker.work();
worker.eat();
worker.sleep();
try {
  worker.manageTeam(); // This will throw an error, indicating a design flaw.
} catch (e) {
  console.error(e.message);
}

The SimpleWorker is forced to implement manageTeam and codeReview even though it doesn’t perform these tasks, leading to unnecessary complexity and potential errors.

Example (Adherence):

We can segregate the IWorker interface into smaller, more specific interfaces.

// Specific interfaces for different capabilities
class IWorkable { // Conceptual interface
  work() { throw new Error("Not implemented"); }
}

class IEatable {
  eat() { throw new Error("Not implemented"); }
}

class ISleepable {
  sleep() { throw new Error("Not implemented"); }
}

class IManageable {
  manageTeam() { throw new Error("Not implemented"); }
}

class IReviewable {
  codeReview() { throw new Error("Not implemented"); }
}

// SimpleWorker only implements what it needs
class SimpleWorker extends IWorkable { // Implements IWorkable
  work() { console.log("Simple worker is working."); }
  // Also implements eat and sleep (implicitly, for simplicity in JS)
  eat() { console.log("Simple worker is eating lunch."); }
  sleep() { console.log("Simple worker is sleeping."); }
}

// TeamLead implements more specific interfaces
class TeamLead extends IWorkable { // Implements IWorkable
  work() { console.log("Team Lead is working on tasks."); }
  manageTeam() { console.log("Team Lead is managing the team."); }
  codeReview() { console.log("Team Lead is performing code review."); }
  eat() { console.log("Team Lead is eating lunch."); }
  sleep() { console.log("Team Lead is sleeping."); }
}

const simple = new SimpleWorker();
simple.work();
simple.eat();

const lead = new TeamLead();
lead.work();
lead.manageTeam();
lead.codeReview();

Now, SimpleWorker only implements the methods relevant to its role, and TeamLead implements additional methods without forcing SimpleWorker to carry unnecessary baggage.

5. Dependency Inversion Principle (DIP)

Definition:

  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.

Explanation: DIP promotes decoupling between modules by introducing interfaces or abstract classes that define the interactions between them. Instead of high-level modules (which contain core business logic) directly depending on concrete low-level modules (which handle specific tasks like database access or logging), both should depend on abstractions (interfaces). This makes the system more flexible, testable, and easier to modify, as concrete implementations can be swapped out without affecting the high-level logic.

Example (Violation):

Consider a NotificationService that directly depends on a concrete EmailSender.

class EmailSender {
  sendEmail(recipient, message) {
    console.log(`Sending email to ${recipient}: "${message}"`);
  }
}

class NotificationService {
  constructor() {
    this.emailSender = new EmailSender(); // Direct dependency on concrete class
  }

  sendWelcomeNotification(userEmail) {
    this.emailSender.sendEmail(userEmail, "Welcome to our platform!");
  }
}

const service = new NotificationService();
service.sendWelcomeNotification("user@example.com");

Here, NotificationService (high-level module) directly depends on EmailSender (low-level module). If we want to add SMS notifications, we’d have to modify NotificationService.

Example (Adherence):

We can introduce an abstraction (an interface or abstract class) for sending messages.

// Abstraction: IMessageSender interface (conceptual)
class IMessageSender {
  sendMessage(recipient, message) {
    throw new Error("This method must be implemented by subclasses.");
  }
}

// Low-level module: EmailSender depends on IMessageSender abstraction
class EmailSender extends IMessageSender {
  sendMessage(recipient, message) {
    console.log(`Sending email to ${recipient}: "${message}"`);
  }
}

// Low-level module: SMSSender depends on IMessageSender abstraction
class SMSSender extends IMessageSender {
  sendMessage(recipient, message) {
    console.log(`Sending SMS to ${recipient}: "${message}"`);
  }
}

// High-level module: NotificationService depends on IMessageSender abstraction
class NotificationService {
  constructor(messageSender) {
    // Dependency injected through constructor
    this.messageSender = messageSender;
  }

  sendWelcomeNotification(userContact, message) {
    this.messageSender.sendMessage(userContact, message);
  }
}

// Usage with EmailSender
const emailService = new NotificationService(new EmailSender());
emailService.sendWelcomeNotification("user@example.com", "Welcome via Email!");

// Usage with SMSSender (easily swappable without changing NotificationService)
const smsService = new NotificationService(new SMSSender());
smsService.sendWelcomeNotification("123-456-7890", "Welcome via SMS!");

Now, NotificationService depends on the IMessageSender abstraction, not a concrete implementation. We can inject any IMessageSender implementation without modifying NotificationService, demonstrating true decoupling.

Conclusion

The SOLID principles are more than just a set of rules; they are a mindset for designing software. By internalizing and applying these principles, developers can create systems that are:

  • Maintainable: Easier to fix bugs and make small changes.
  • Flexible: Adaptable to new requirements and technologies.
  • Understandable: Clearer structure and intent, making it easier for new team members to onboard.
  • Testable: Components are isolated, simplifying unit testing.
  • Scalable: Can grow and evolve without becoming a tangled mess.

While applying SOLID principles might seem like an overhead initially, the long-term benefits in terms of code quality, development speed, and reduced technical debt are invaluable for any serious software project. Start incorporating them into your daily coding practices, and you’ll soon see the difference!

Good Coding Habits

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Recent Posts

  • Mastering Clean Code: A Deep Dive into SOLID Principles
  • Building a Context-Aware Q&A System with LangChain.js and Web Scraping
  • TypeScript Best Practices for React
  • Vite Micro Frontend
  • React Best Practices for Performance and Maintainability

Recent Comments

    Archives

    • July 2025
    • August 2024
    • January 2024
    • September 2021
    • July 2021
    • June 2021
    • May 2021
    • April 2021
    • December 2020
    • November 2020
    • October 2020
    • September 2020

    Categories

    • Angular
    • API
    • Best Practice
    • Compiler
    • Context
    • DevOps
    • Docker
    • FAANG
    • Forms
    • Good Coding Habits
    • GraphQL
    • Java
    • Javascript
    • LangChain
    • LLM
    • Machine Learning
    • MobX
    • Python
    • ReactJS
    • Redux Toolkit
    • Spring Boot
    • Typescript
    • Uncategorized
    • Vite
    • Webpack
    ©2025 Rohit Naik Kundaikar | Powered by WordPress & Superb Themes