Clean Code - SOLID Principles



In clean code practices, SOLID principles are standards that help you write better and more maintainable code. Theyre basically five rules that let us know how to design your code in a way that's easy to understand, change, and even extend. These principles make your code-base less of a mess and easy to deal with over time. Lets us see what SOLID is all about and why these principles matter for writing clean code.

Why Use SOLID Principles?

Using SOLID principles helps keep your code-base clean and organized. Without, turning into a giant mess. It makes easier to add new features or make changes without breaking everything else. Think of it like having a set of rules and guidelines that prevent you from creating messy code, These principles make you write in a certain way. SOLID principles make your code flexible, easier to understand, and a lot more manageable, especially as your project gets bigger and more complicated.

When to Apply SOLID Principles?

It is good to keep practicing SOLID principles. And use it whenever youre working on code thats going to stay around for a long, or when youre working in a team where other people might need to understand or change the code.

Its not like you have to use them for every tiny script or experiment youre doing, but when it comes to larger projects, having these principles in practice can make a big difference in the long run. Its like organizing your workbench before starting a big project; it keeps everything in the right place. It helps to avoid confusion and also makes a lot easier for you to understand a code-base.

What is SOLID ?

SOLID is a short form for five different principles that let you know how you should write your code. Heres what each one stands for and a simple idea of what its about:

Single Responsibility Principle (SRP)

The Single Responsibility Principle states that a class should have only one job or responsibility to handle. This means it should have only one reason to change. If a class handles multiple jobs it becomes difficult to maintain and update. Because, if we need to change one functionality that change might affect others as well.

For example, In a hotel cook handles everything such as cooking, taking orders, cleaning tables, and managing finances. It becomes very difficult for him to manage all these tasks.

Instead, if we have different people for each task, like a cook for cooking, a waiter for taking orders, a cleaner for cleaning tables, and an accountant for managing finances, it becomes much easier to handle and maintain. Similarly, in programming, each class should have a single responsibility.

// Example: The below code shows how to use the single responsibility principle in a class
class User {
   constructor(name, email) {
      this.name = name;
      this.email = email;
   }
}

class Authenticator {
   login(user, password) {
      if (user.email && password) {
         console.log(`User ${user.email} logged in with password ${password}`);
      } else {
         console.log('Login failed: Email or password missing');
      }
   }

   logout(user) {
      console.log(`User ${user.email} logged out.`);
   }
}

// Example usage:
const user = new User('Alice', 'alice@example.com');
const auth = new Authenticator();

auth.login(user, 'securePassword123'); // Logs in
auth.logout(user); // Logs out

Open/Closed Principle(OCP)

This principle states that classes, modules, and functions should be open for extension but strictly should closed for modification. This means, you should be able to add new features and functionalities to your application but you are not allowed to change existing code. This helps to prevent bugs in already tested and stable code.

For example, if you have a class that calculates discounts, you can extend it to support more discount types without modifying the existing discount logic. You can use inheritance or interfaces to add new functionality.

// Example: Below is example code for extend a discount calculator with new discount types for open/closed principle
class Discount {
   calculate(price) {
      return price; // No discount applied
   }
}

class PercentageDiscount extends Discount {
   constructor(percentage) {
      super();
      this.percentage = percentage;
   }

   calculate(price) {
      return price - (price * this.percentage / 100);
   }
}

// Example usage:
const originalPrice = 100; // Original price of the item

const noDiscount = new Discount();
console.log(`Price with no discount: $${noDiscount.calculate(originalPrice)}`); // Price with no discount: $100

const tenPercentDiscount = new PercentageDiscount(10);
console.log(`Price after 10% discount: $${tenPercentDiscount.calculate(originalPrice)}`); // Price after 10% discount: $90

const twentyPercentDiscount = new PercentageDiscount(20);
console.log(`Price after 20% discount: $${twentyPercentDiscount.calculate(originalPrice)}`); // Price after 20% discount: $80

Liskov Substitution Principle(LSP)

LSP is all about making sure that we are able to replace the object of a subclass with the object of a parent class without changing anything in the program. It makes sure that the derived class behaves in the same way its parent class expects.

For example, You create a base class Bird that has a method called fly(). Now, you create a subclass called Sparrow. We know that sparrows can fly so the fly() method would work fine. But, you create a subclass called Penguin and extend Bird class. It will cause problems since we know penguins can't fly. In this case, we can make two different base classes FlyinBird, NonFlyingBird.

// Example: The below code shows how to use different base classes for flying and non-flying birds
class Bird {
   fly() {
      console.log('Flying');
   }
}
class NonFlyingBird {
   swim() {
      console.log('Swimming');
   }
   walk() {
      console.log('Walking');
   }

}

class Penguin extends NonFlyingBird {
   swim() {
      console.log('Swimming like a penguin');
   }
}
class Sparrow extends Bird {
   fly() {
      console.log('Flying like a sparrow');
   }
}

const penguin = new Penguin();
penguin.swim(); // Swimming like a penguin
penguin.walk(); // Walking

const sparrow = new Sparrow();
sparrow.fly(); // Flying like a sparrow

Interface Segregation Principle(ISP)

ISP states that a single interface should not have too many methods altogether. Because, this forces classes to implement methods that may not be relevant to that class, which increases unnecessary complexity in code. It is better to create many small, specific interfaces rather than a single one with many.

For example, if you have a class that can drive a car and fly an airplane, you can create separate interfaces for driving and flying, so that classes can implement only the interfaces they need without having to create a mess around.

// Example: Below code shows how to use different interfaces for driving and flying
Interface Drivable {
   drive();
}

Interface Flyable {
   fly();
}

class Car implements Drivable {
   drive() {
      console.log('Driving a car');
   }
}

class Airplane implements Flyable {
   fly() {
      console.log('Flying an airplane');
   }
}

const car = new Car();
car.drive(); // Driving a car

const airplane = new Airplane();
airplane.fly(); // Flying an airplane

Dependency Inversion Principle(DIP)

The Dependency Inversion Principle states that we should design our code to make it easier to maintain and change. This means, that high-level parts of the program, which have main logic, should not depend on low-level logic. Instead, both high-level and low-level should depend on something in between, like an interface.

For example, if you have a class that sends notifications, you can create an interface for the notification service and can create different classes that implement the interface for different types of notifications, like email, SMS, or push notifications. This way, the main class would not need to know how the notifications will be sent, it just needs to know that it would send them anyhow.

 // Define an interface for sending notifications
 class NotificationSender {
    send(message) {
       throw new Error("Method 'send()' must be implemented.");
    }
 }
 // Implementation for sending email notifications
 class EmailService extends NotificationSender {
    send(message) {
       console.log(`Sending Email: ${message}`);
    }
 }
 // Implementation for sending SMS notifications
 class SMSService extends NotificationSender {
    send(message) {
       console.log(`Sending SMS: ${message}`);
    }
 }
 // High-level module that uses the NotificationSender interface
 class NotificationService {
    constructor(notificationSender) {
       this.notificationSender = notificationSender; // Dependency injection
    }
    notify(message) {
       this.notificationSender.send(message);
    }
 }
 // Main function to run the example
 function main() {
    // Use EmailService
    const emailSender = new EmailService();
    const emailNotification = new NotificationService(emailSender);
    emailNotification.notify("Hello, this is an email notification!");
    // Use SMSService
    const smsSender = new SMSService();
    const smsNotification = new NotificationService(smsSender);
    smsNotification.notify("Hello, this is an SMS notification!");
 }
 // Execute the main function
 main();

Conclusion

SOLID principle helps us to organize our code-base in a better way. It makes it easy to navigate, add new features, and make changes without causing much problem. All principle has the ability to make code more cleaner and neat. It's best practice to use these in everyday code. We've learned many things about clean code and the SOLID principle is the most important one.

Advertisements