SOLID Principles: It's That Easy! ๐Ÿ˜ฑ STANDOUT ๐ŸŒŸ with SOLID Principles! ๐Ÿง™โ€โ™‚๏ธโœจ

Mohamed Ismail SMohamed Ismail S
11 min read

Hey there, ๐Ÿ‘‹ Awesome Developers! ๐Ÿš€

Today, let's dive into the basics of SOLID principles. If you're ready to level up your coding game! ๐Ÿ‘‡ Let's roll!

In the fast-changing world of coding, making clean and powerful code is crucial for building strong and flexible apps. join us as we explore SOLID Principles - the secret recipe of crafting code that's easy to maintain and works amazingly well! ๐ŸŒŸโœจ

Introduction ๐Ÿš€

"Uncle Bob"

SOLID, introduced by Robert C. Martin (Uncle Bob), is a set of five design principles for creating a clean and effective object-oriented code. Here we'll break down each SOLID principle and see how they work in the context of coding with javascript.


1. Single Responsibility principle (SRP) ๐ŸŽฏ

"Single Responsibility"

This is one of the SOLID Principles of object-oriented design. simply put, it suggests that a class should have only one reason to change, or in other words, it should have only one responsibility. SRP says, "Hey, stick to just one job, and do it really well."

Why is this more important ?

  1. Maintainability: When a class has a single responsibility, it becomes easier to understand, modify, and maintain. if you need to make a change, you know exactly where to look.

  2. Reusability: Smaller, focused classes are often more reusable in different parts of your application. you can use them like building blocks to assemble different functionalities.

  3. Flexibility: With a clear and single responsibility, classes become more adaptable to change. if requirements shift, you can modify or extend individual classes without affecting the entire codebase.

Example

Let's look at the difference between the code before and after applying the Single Responsibility Principle (SRP) clearer:

Before applying SRP:

class Report {
   constructor(data) {
     this.data = data;
   }
   generateReport() {
     console.log(`Generating report for ${this.data}`);
   }
   saveToFile() {
     console.log(`Saving report to file: ${this.data}`);
     // logic for saving report to a file
   }
}
const report = new Report("Sales Data");
report.generateReport();
report.saveToFile();

In this version, the Report class is doing two things: Generating a report and saving it to a file. It's responsible for both creating the report content and managing file operations.

After applying SRP:

class Report {
  constructor(data) {
    this.data = data;
  }
  generateReport() {
    console.log(`Generating report for ${this.data}`);
  }
}

class ReportSaver {
  saveToFile(report) {
    console.log(`Saving report to file: ${report.data}`);
    // logic for saving report to a file
  }
}
const report = new Report("Sales Data");
report.generateReport();
const reportSaver = new ReportSaver();
reportSaver.saveToFile(report);

In the improved version, we've applied SRP. The Report class now focuses solely on generating the report, and a new class, ReportSaver, takes care of saving the report to a file. Each class has a single responsibility, making the code more modular and easier to understand and maintain. This separation adheres to the Single Responsibility Principle, ensuring that each class has only one reason to change.


2. Open/Closed Principle (OCP) ๐Ÿšช

"Open/Closed"

This Suggests that a class should be open for extension but closed for modifications. In simpler terms, this means you should be able to add a new feature or functionalities to a system without altering the existing code.

If the concept is still unclear, let me explain it differently. The Open/Closed Principle (OCP) is like a rule in a programming that says you can add new things in your code without changing the old stuff. imagine your code is like a LEGO set - you can keep adding new pieces without breaking the ones you already snapped together.

Why is OCP Important ?

  1. Maintainability: OCP helps make code easier to handle. When you want to add new things, you don't have to touch the parts that are already working well. This way, there's less chance of making mistakes and messing up the existing code.

  2. Scalability: When the software grows (upgrades), being able to add new features without messing with the current code becomes vital for its growth. This way, your code can expand and adapt to new requirements.

  3. Reduced Risk: Modifying the existing code can bring unexpected problems. OCP helps avoid this by keeping any changes in new code, making it easier to test and fix issues.

  4. Team Collaboration: When multiple developers work on a project, OCP Allows them to add new features independently without interfering with each other's work.

Example

Consider a system that calculates the area of shapes.

Before applying OCP:

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

// Adding a new shape violates OCP
class Square {
  side;
  constructor(side) {
    this.side = side;
  }
  calculateArea() {
    return this.side ** 2;
  }
}
// Object instantiation
const circle = new Circle(5);
const square = new Square(4);
circle.calculateArea(); // 78.54
square.calculateArea(); // 16

In this case, if you want to add anew shape like a square, you have to go back and changing the existing code. This Breaks the Open/Closed principle.

After applying OCP:

Now, Let's make it better with the Open/Closed Principle:

class Shape(){
  calculateArea(){
    throw new Error("This method should be overridden by subclasses")
  }
}
class Circle extends Shape {
  radius;
  constructor(){
    super();
    this.radius = radius;
  }
  calculateArea(){
    return Math.PI * this.radius ** 2
  }
}
class Square extends Shape {
  side;
  constructor(side){
    super();
    this.side = side;
  }
  calculateArea(){
    return this.side ** 2;
  }
}
// Object instantiation
const circle = new Circle(5);
const square = new Square(4);
circle.calculateArea(); // 78.54
square.calculateArea(); // 16

In this code, Shape is the parent class, and Circle and Square are its subclasses. when you create an instance of Circle or Square, the super() statement is used to call the constructor of the Shape class. This is important because it allows the initialization of any properties or setup defined in the Shape class before adding the specific properties and behavior in the constructor of the subclass.

In Simpler terms, super() ensures that both the parent (Shape) and the Child (Circle or Square) classes get properly initialized. It's a way to extend the behavior of the parent class while keeping everything consistent.


3. Liskov Substitution Principle (LSP) ๐Ÿ”„

"Liskov Substitution"

The Liskov Substitution Principle states that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program.

In the simpler terms, if a class is subclass of another class, you should be able to use objects of the subclass wherever objects of the superclass are used, without introducing errors.

If you're still not getting it, Let me simplify it further. Imagine you have a big category of things (a superclass) and some more specific things that belong to that category (subclasses). LSP suggests that you can use the specific things whenever you use the big category things messing up your program.

Why is this more important ?

  1. Code Flexibility: LSP promotes the interchangeability of objects, allowing for flexibility in code design.

  2. Consistent Behavior: It ensures that subclasses maintain consistent behavior with the superclasses, reducing unexpected surprises.

Example

Let's use a straightforward code example. ๐Ÿฆ…๐Ÿง

Before applying LSP:

class Bird {
  fly(){
    console.log("Flying High!");
  }
}
class Penguin extends Bird {
  // Penguins can't fly, violation LSP
  fly(){
    console.log("I can't fly!");
  }
}
// Object Instantiation
const bird = new Bird();
const penguin = new Penguin();
bird.fly(); // "Flying High!"
penguin.fly(); // "I can't fly!"

In this, The Penguin class violates LSP by not being able to fly, which is expected from its superclass Bird.

After applying LSP:

class Bird {
  fly() {
    console.log("Flying high!");
  }
}
class Penguin extends Bird {
  // Penguins should override fly appropriately
  swim() {
    console.log("Swimming gracefully!");
  }
}
// Object instantiation
const bird = new Bird();
const penguin = new Penguin();
bird.fly();      // Output: "Flying high!"
penguin.fly();   // Output: "Flying high!"
penguin.swim();  // Output: "Swimming gracefully!"

In this, The Penguin class adheres to LSP by introducing a new behavior swim without changing the expected behavior of flying. Both Bird and Penguin instances can be used interchangeably where a Bird is expected, ensuring consistency.


4. Interface Segregation Principle (ISP) ๐Ÿค

"Interface Segregation"

The Interface Segregation Principle suggests that a class should not be forced to implement interfaces it doesn't use. In simpler terms, it's better to have several small, specific interfaces than one large, all-encompassing interface.

If the concept is still unclear, Let me explain it differently, The Interface Segregation Principle (ISP) is like a rule in coding that says: "Don't make a class do things it doesn't need to do. It's better to have many small and specific sets of tasks (interfaces) for a class rather than one big set that does everything."

It's like telling a chef to focus on their specialty dishes instead of making them handle every type of cuisine. ๐Ÿณ๐Ÿ•

Why is this more Important ?

  1. Flexibility: It allows for more flexibility in implementing interfaces, preventing unnecessary dependencies on methods that aren't relevant.

  2. Avoiding Bloat: Classes don't need to implement methods they don't use, keeping the codebase clean and avoiding unnecessary bloat.

In JavaScript, lacking an explicit interface keyword, I'm using TypeScript in this example instead of JavaScript. If you want to implement a similar approach in JavaScript, you can rely on implicit interfaces, where classes or objects share a common set of methods.

Example

Let's create a Example in Typescript for Implementation of Interface Segregation Principle (ISP).

Before applying ISP:

interface Worker {
  work(): void;
  eat(): void;
}
class Engineer implements Worker {
  work() {
    console.log("Engineer working...");
  }
  eat() {
    console.log("Engineer eating...");
  }
}
class Manager implements Worker {
  work() {
    console.log("Manager working...");
  }
  eat() {
    console.log("Manager eating...");
  }
}

// Object instantiation
const engineer = new Engineer();
const manager = new Manager();
// Example usage
engineer.work();  // Output: "Engineer working..."
engineer.eat();   // Output: "Engineer eating..."
manager.work();   // Output: "Manager working..."
manager.eat();    // Output: "Manager eating..."

In this, the Worker interface has both work and eat methods, and both Engineer and Manager are forced to implement both methods.

After applying ISP:

// With ISP
interface Workable {
  work(): void;
}
interface Eatable {
  eat(): void;
}
class Worker implements Workable, Eatable {
  work() {
    console.log("Working...");
  }
  eat() {
    console.log("Eating...");
  }
}
class Engineer implements Workable {
  work() {
    console.log("Engineer working...");
  }
}
class Manager implements Workable, Eatable {
  work() {
    console.log("Manager working...");
  }
  eat() {
    console.log("Manager eating...");
  }
}

// Object instantiation
const worker = new Worker();
const engineer = new Engineer();
const manager = new Manager();
// Example usage
worker.work();    // Output: "Working..."
worker.eat();     // Output: "Eating..."
engineer.work();  // Output: "Engineer working..."
manager.work();   // Output: "Manager working..."
manager.eat();    // Output: "Manager eating..."

In this, we split the interface into Workable and Eatable, allowing classes to implement only the interfaces relevant to their functionality.


5. Dependency Inversion Principle (DIP) ๐Ÿ”„

"Dependency Inversion"

The Dependency Inversion Principle emphasizes high-level modules should not depend on low-level modules but rather both should depend on abstractions. Additionally, it advocates that abstractions should not depend on details; details should depend on abstractions.

If you're still not getting it, Let's simplify it further.

The Dependency Inversion Principle (DIP) is like a rule in coding that says: "Don't have important parts of your code rely too much on each other. Instead, make them both rely on general plans (abstractions). And remember, these general plans shouldn't worry about specific details; the details should take their cues from the general plans."

It's like building with LEGO bricks, where each brick follows a common design, and the specifics of each brick don't bother the overall structure. ๐Ÿงฑ๐ŸŒ

Why is this more Important ?

  1. Flexibility: It promotes a flexible and extensible design by decoupling high-level and low-level components.

  2. Easy Maintenance: Changes in low-level details don't impact high-level policies, making the system easier to maintain.

Example

Let's consider a system called Smart Home Control.

Before applying DIP:

class LightBulb {
  turnOn() {
    console.log("LightBulb: Turning on...");
  }
  turnOff() {
    console.log("LightBulb: Turning off...");
  }
}
class Switch {
  constructor(bulb) {
    this.bulb = bulb;
  }
  operate() {
    this.bulb.turnOn();
    // Some other operations
    this.bulb.turnOff();
  }
}
// Object instantiation
const bulb = new LightBulb();
const switch = new Switch(bulb);
switch.operate();

In this, Switch directly depends on LightBulb and that violates the Dependency Inversion Principle.

After applying DIP:

// Interface-like abstraction
class Switchable {
  turnOn() {
    throw new Error("Method not implemented");
  }
  turnOff() {
    throw new Error("Method not implemented");
  }
}
class LightBulb extends Switchable {
  turnOn() {
    console.log("LightBulb: Turning on...");
  }
  turnOff() {
    console.log("LightBulb: Turning off...");
  }
}
class Switch {
  constructor(device) {
    this.device = device;
  }
  operate() {
    this.device.turnOn();
    // Some other operations
    this.device.turnOff();
  }
}
// Object instantiation
const bulb = new LightBulb();
const switch = new Switch(bulb);

switch.operate();

In this, an abstraction (Switchable) is used, and both LightBulb and Switch depend on this abstraction, following the Dependency Inversion Principle. Note that JavaScript doesn't have explicit interfaces, so we use a class as an abstraction here.


Conclusion ๐ŸŒŸ

In a nutshell, This post introduces you to the SOLID principles โ€” a set of rules for writing cleaner and more maintainable code. While we've touched on the basics here, there's more to explore for a complete grasp.


That's it ๐Ÿ˜

Thanks for diving into this blog ๐Ÿ™. If you found it helpful, share your thoughts in the comments ๐Ÿ“ฉ.

And Don't forget to show your love with "๐Ÿ’– * 10" by giving ten hearts if you enjoyed it on Hashnode! Your support is truly heartwarming! ๐Ÿš€โœจ

"Thank you"

1
Subscribe to my newsletter

Read articles from Mohamed Ismail S directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Mohamed Ismail S
Mohamed Ismail S