DEV Community

Cover image for Mastering SOLID Principles ✅
Ali Samir
Ali Samir

Posted on

Mastering SOLID Principles ✅

The SOLID principles are design principles in object-oriented programming that help developers create more understandable, flexible, and maintainable software.

Let's dive into each principle and see how they can be applied using JavaScript.


📌 1. Single Responsibility Principle (SRP)

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

class User {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }
}

class UserService {
  createUser(user) {
    // logic to create user
  }

  getUser(id) {
    // logic to get user
  }
}

class UserNotificationService {
  sendWelcomeEmail(user) {
    // logic to send email
  }
}

const user = new User('John Doe', 'john.doe@example.com');
const userService = new UserService();
userService.createUser(user);

const notificationService = new UserNotificationService();
notificationService.sendWelcomeEmail(user);
Enter fullscreen mode Exit fullscreen mode

Here, User handles the user data, UserService handles user-related operations, and UserNotificationService handles notifications. Each class has a single responsibility.


📌 2. Open/Closed Principle (OCP)

Definition: Software entities should be open for extension but closed for modification.

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

  area() {
    return this.width * this.height;
  }
}

class Circle {
  constructor(radius) {
    this.radius = radius;
  }

  area() {
    return Math.PI * Math.pow(this.radius, 2);
  }
}

const shapes = [new Rectangle(4, 5), new Circle(3)];

const totalArea = shapes.reduce((sum, shape) => sum + shape.area(), 0);
console.log(totalArea);
Enter fullscreen mode Exit fullscreen mode

In this example, each shape class's area method (like Rectangle and Circle) can be extended without modifying the existing code of the shape classes. This allows for adding new shapes in the future without changing the existing ones.


📌 3. Liskov Substitution Principle (LSP)

Definition: Subtypes must be substitutable for their base types without altering the correctness of the program.

class Bird {
  fly() {
    console.log('I can fly');
  }
}

class Duck extends Bird {}

class Ostrich extends Bird {
  fly() {
    throw new Error('I cannot fly');
  }
}

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

const duck = new Duck();
makeBirdFly(duck); // Works fine

const ostrich = new Ostrich();
makeBirdFly(ostrich); // Throws error
Enter fullscreen mode Exit fullscreen mode

In this example, Ostrich violates LSP because it changes the expected behavior of the fly method. To comply with LSP, we should ensure that subclasses do not change the behavior expected by the base class.


📌 4. Interface Segregation Principle (ISP)

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

class Printer {
  print() {
    console.log('Printing document');
  }
}

class Scanner {
  scan() {
    console.log('Scanning document');
  }
}

class MultiFunctionPrinter {
  print() {
    console.log('Printing document');
  }

  scan() {
    console.log('Scanning document');
  }
}

const printer = new Printer();
printer.print();

const scanner = new Scanner();
scanner.scan();

const multiFunctionPrinter = new MultiFunctionPrinter();
multiFunctionPrinter.print();
multiFunctionPrinter.scan();
Enter fullscreen mode Exit fullscreen mode

Here, Printer and Scanner classes provide specific functionalities without forcing clients to implement methods they don't need. The MultiFunctionPrinter can use both functionalities, adhering to the ISP.


📌 5. Dependency Inversion Principle (DIP)

Definition: High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.

class NotificationService {
  constructor(sender) {
    this.sender = sender;
  }

  sendNotification(message) {
    this.sender.send(message);
  }
}

class EmailSender {
  send(message) {
    console.log(`Sending email: ${message}`);
  }
}

class SMSSender {
  send(message) {
    console.log(`Sending SMS: ${message}`);
  }
}

const emailSender = new EmailSender();
const notificationService = new NotificationService(emailSender);
notificationService.sendNotification('Hello via Email');

const smsSender = new SMSSender();
const notificationServiceWithSMS = new NotificationService(smsSender);
notificationServiceWithSMS.sendNotification('Hello via SMS');
Enter fullscreen mode Exit fullscreen mode

In this example, the NotificationService depends on an abstraction (sender), allowing it to work with any sender implementation (like EmailSender or SMSSender). This adheres to DIP by making the high-level module (NotificationService) depend on abstractions rather than concrete implementations.



Conclusion ✅

By adhering to the SOLID principles, you can design JavaScript applications that are more robust, maintainable, and scalable.

These principles help to ensure that your codebase remains clean and flexible, making it easier to manage and extend as your application grows.

Applying these principles consistently can significantly improve the quality of your software.


Happy Coding! 🔥

LinkedIn, X (Twitter), Telegram, YouTube, Discord, Facebook, Instagram

Top comments (9)

Collapse
 
dscheglov profile image
Info Comment hidden by post author - thread only accessible via permalink
Dmytro Shchehlov

DIP -- The example is okay, but it is not clear what "inversion" means :)

Inversion means that the abstraction now belongs to the client code, not to the server code. This means that the NotificationService defines which method of the Sender to call, not the EmailSender or SMSSender. This contrasts with a case where the NotificationService explicitly uses one of the implementations:

class NotificationService {
  constructor() {
    this.#sender = new SMSSender();
  }

  sendMessage(message: string) {
    this.#sender.sendSMS(message.slice(0, 255));
  }
}
Enter fullscreen mode Exit fullscreen mode

Your example shows how to achieve this inversion with dependency injection. There are other ways to do this, such as using a service locator, but only in cases where the use of a service locator is appropriate.

Collapse
 
sudha_p_developer profile image
Sudha P

Clean code and clear information

Collapse
 
dscheglov profile image
Info Comment hidden by post author - thread only accessible via permalink
Dmytro Shchehlov

ISP -- miss. Totaly.

ISP for Javascript doesn't have any sence ) We don't have a way to declare the dependency of a function or a class.

For TS, it makes sense to look on the UserService example taken from you SRP:

type User = {
  id: string;
  name: string;
  email: string;
};

interface UserByIdProvider {
  findById(id: string): Promise<User | null>;
}

interface UserCreator {
  create(userInfo: Omit<User, "id">): Promise<User>;
}

interface UserRepo extends UserByIdProvider, UserCreator {}

class UserRepoImpl implements UserRepo {
  private users = new Map<string, User>();

  async create(userInfo: Omit<User, "id">): Promise<User> {
    const id = Math.random().toString(36).slice(2, 11);
    const user = { id, ...userInfo };

    this.users.set(id, user);

    return user;
  }

  async findById(id: string): Promise<User | null> {
    return this.users.get(id) ?? null;
  }
}

class UserServiceImpl {
  constructor(private readonly users: UserCreator) {}

  createUser(name: string, email: string): Promise<User> {
    return this.users.create({ name, email });
  }
}

class UserNotificationServiceImpl {
  constructor(private readonly users: UserByIdProvider) {}

  async sendWelcomeEmail(userId: string) {
    const user = await this.users.findById(userId);
    if (user === null) return;

    const { name, email } = user;

    console.log(`Sending email to: ${email}`);
    console.log(`Welcome to SOLID, ${name}`);
  }
}

const users = new UserRepoImpl();
const userService = new UserServiceImpl(users);
const notificationService = new UserNotificationServiceImpl(users);

userService
  .createUser("Ali Samir", "alisamir.eng@gmail.com")
  .then((user) => notificationService.sendWelcomeEmail(user.id));
Enter fullscreen mode Exit fullscreen mode

Here we have UserServiceImpl and NotificationServiceImpl dependent on the different interface implemented by the same class.

First of all it allows us to write unit tests with more simple mocking and what is more important we can provide different implementation to this classes.

For example we can provide original users: UserRepoImpl to the UserService and we can patt the object with method findById that decorates the original one with some caching logic, meaning we will apply Decorator Pattern but only for UserByIdProvider interface, not for UserRepo entier one.

And yes, for JS -- it has no sense, because we don't have any declaration

Collapse
 
dscheglov profile image
Info Comment hidden by post author - thread only accessible via permalink
Dmytro Shchehlov

@alisamirali

In this example, Ostrich violates LSP because it changes the expected behavior of the fly method. To comply with LSP, we should ensure that subclasses do not change the behavior expected by the base class.

Unfortunately, it is not a rule that throwing an exception violates the LSP. TypeScript and JavaScript don't have the capability to express whether exception throwing is expected behavior or not. In some cases, it will be expected; in others, it will not.

Let's suppose we are adding an exception for the Duck class in case the duck is "shot by a hunter." This means that the Bird class must assume that an exception can be thrown.

The Liskov Substitution Principle (LSP) is one of the most difficult principles to understand and explain.

A good example is as follows:

class Bird {
  fly() {
    console.log("I can fly");
  }
}

class Plane extends Bird {
  private fuelLevelLiters: number = 0;

  fuel(liters: number) {
    this.fuelLevelLiters += liters;
  }

  fly() {
    if (this.fuelLevelLiters <= 0) {
      throw new Error("Not enough fuel for flight");
    }
    console.log("Flying...");
    this.fuelLevelLiters = 0;
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, to make the plane fly, we need to fuel it first by calling the method plane.fuel(100), whereas for birds, this is not needed.

Collapse
 
dscheglov profile image
Info Comment hidden by post author - thread only accessible via permalink
Dmytro Shchehlov

@alisamirali
Can you explain your point:

In this example, each shape class's area method (like Rectangle and Circle) can be extended without modifying the existing code of the shape classes. This allows for adding new shapes in the future without changing the existing ones.

How does the apportunity to extend Rectangle or Circle help us to add a new figure class?

And actually I'd like to ask, which code in your example is open and which one is closed for modification?

Thank you

Collapse
 
barsie profile image
Sirineo Barila

Extremely useful!
I didn't know about SOLID.
I only knew some few generalized characteristics of classes but your sharing have gave me more specific understanding; I was wondering if you can share any other resources to these topics?

Collapse
 
supportic profile image
Info Comment hidden by post author - thread only accessible via permalink
Supportic

Passing a user to create a user seems wild 😜

Collapse
 
noid profile image
NoiD

Would it be bad to have MultiFunctionPrinter extend Printer and Scanner?

Some comments may only be visible to logged-in visitors. Sign in to view all comments. Some comments have been hidden by the post's author - find out more