SOLID principles and why they are so relevant


Motivation

Throughout our lives as developers, we surely go through several projects, which many times are not in the best way and it is very difficult to address and improve them. Many of these problems are often related to the approach taken by developers in that project. Ideally, all developers should use good practices when working and make code much more understandable by other developers who, after us, work on our code. Therefore, it is essential to write maintainable, scalable, clean, and modular code. Now let's explore one of the most recognized and widely adopted approaches in the developer community: the use of SOLID principles.

What are they

The SOLID principles are a set of five fundamental principles that guide us in designing and writing high-quality software. They emerged in the 2000s and were devised by Robert Cecil Martin along with input from Barbara Liskov y Bertrand Meyer and have become a mainstay in the software development community.

The SOLID principles are an invaluable guide for developers in creating quality and robust software, as well as fostering effective collaboration and professional growth in the field of software development.

Each letter associated with SOLID corresponds to a unique principle, these are:

  • S: Single Responsibility Principle.
  • O: Open/Closed Principle.
  • L: Liskov Substitution Principle.
  • I: Interface Segregation Principle.
  • D: Dependency Inversion Principle.

Single Responsibility Principle

This principle states that each class or module should have a unique responsibility. In other words, a class should only have one reason to change. By maintaining a single, well-defined responsibility, we make code easier to understand, maintain, and reuse.

class PaymentService {
  calculateFee() {
    // Some code to calculate fee
  }

  getCurrency() {
    // Some code to get the actual currency
  }

  notifyPayment() {
    // Some code to send notifications
  }
  
  handle() {
    // Code that executes the logic for the payment
  }
}

In this example we have a PaymentService that not only executes the payment logic, but also must calculate a fee for the payment, obtain the payment currency and notify the payment, which leads to problems of having to maintain responsibilities that do not correspond to the service in question. Let's imagine that tomorrow a currency is added or deleted or the way in which notifications are sent is modified, as this should not be because it means having to modify a service whose purpose is to make a payment.

A better implementation following the principle sole responsibility would be:

class PaymentService {
  handle() {
    // Code that executes the logic for the payment
  }
}

class FeeCalculator {
  excecute() {
    // Some code to calculate fee
  }
}

class CurrencyService {
  handle() {
    // Some code to get the actual currency
  }
}

class NotificationService {
  handle() {
    // Some code to send notifications
  }
}

In this way, we respect the principle of sole responsibility and when we need to make implementation modifications, we no longer have to modify logic that has nothing to do with it.

Open/Closed Principle

This principle promotes the extensibility and flexibility of the code. It suggests that entities such as classes and modules should be open for extension but closed for modification. Instead of modifying the existing code, we should be able to extend its functionality through the addition of new classes or methods.

To illustrate this with a practical example, consider the following class:

class PaymentMethod {
  private service: string;
  constructor(service) {
    this.service = service;
  }

  pay() {
    switch (this.service) {
      case 'paypal':
        // Excecutes payment with PayPal Service
        break;
      case 'payoneer':
        // Excecutes payment with Payoneer Service
        break;
      case 'bank-transfer':
        // Excecutes payment with Bank Transfer Service
        break;
      default:
        // Excecutes default logic
    }
  }
}

This class has many problems, and one of them is the inability to extend behavior through the Open Close principle, since to add a payment service the current implementation must be modified. A better implementation of this case could be:

interface PaymentInterface {
  pay();
}

class PayPalService implements PaymentInterface {
  pay() {
    // PayPal-specific logic for processing a payment
  }
}

class PayoneerService implements PaymentInterface {
  pay() {
    // Payoneer-specific logic for processing a payment
  }
}

class BankTransferService implements PaymentInterface {
  pay() {
    // Bank Transfer-specific logic for processing a payment
  }
}

Therefore, now to add a payment method you only have to create a class that implements PaymentInterface and include it in the service that handles the payment processing.

Liskov Substitution Principle:

This principle states that derived classes must be replaceable by their base classes without affecting the correct operation of the program. Its formal definition is as follows:

Let ϕ(x) be a property provable aout objects x of type T. Then ϕ(y) should be true for objects y of type S, where S is a subtype of T.

In other words, an instance of a base class should be able to be replaced by an instance of a derived class without introducing bugs or unexpected behavior. This encourages code reuse and the creation of consistent class hierarchies.

Let's look at an example that doesn't follow this rule:

class Bird {  
    fly() {}
}

class Eagle extends Bird {
    dive(){}
}

const eagle = new Eagle();
eagle.fly();
eagle.dive();

class Penguin extends Bird {
   // We found a problem, penguins can't fly
}

Now let's see how ordering the priorities and the hierarchy of the classes can solve this problem:

class Bird {
  layEgg () {}
}

class FlyingBird extends Bird {
  fly () {}
}

class SwimmingBird extends Bird {
  swim () {}
}

class Eagle extends FlyingBird {}

class Penguin extends SwimmingBird {}

const penguin = new Penguin();
penguin.swim();
penguin.layEgg();

const eagle = new Eagle();
eagle.fly();
eagle.layEgg();

Now we have defined our classes with a better hierarchy and structure, and our children can replace our parents without problems, respecting the Liskov substitution principle.

Interface Segregation Principle

This principle states that no class should extend behavior of an interface it does not use. Rather than having overloaded, monolithic interfaces, it is preferable to have smaller, more specific interfaces that meet the individual needs of each class. This avoids the creation of unnecessary dependencies and makes the code easier to adapt and maintain.

To understand it better let's see the following example:

interface Entity {
  walk();
  run();
  jump();
  attack();
  pick();
}

class Player implements Entity {
  walk() {
    // Implementation of walk
  }
  run() {
    // Implementation of run
  }
  jump() {
    // Implementation of jump
  }
  attack() {
    // Implementation of attack
  }
  pick() {
    // Implementation of pick
  }
}

class Enemy implements Entity {
  walk() {
    // Implementation of walk
  }
  run() {
    // Implementation of run
  }
  jump() {
    // Implementation of jump
  }
  attack() {
    // Implementation of attack
  }
  pick() {
    throw new Error("Enemies can't pick things.");
  }
}

class Hostage {
  walk() {
    // Implementation of walk
  }
  run() {
    // Implementation of run
  }
  jump() {
    // Implementation of jump
  }
  attack() {
    throw new Error("Hostage can't attack.");
  }
  pick() {
    throw new Error("Hostage can't pick things.");
  }
}

If we wanted to improve this, so classes don't have to forcefully implement behavior they don't need, we could break it up into single interfaces, so it would look something like this:

interface BaseEntityInterface {
  walk();
  run();
  jump();
}

interface AttackeableInterface {
  attack();
}

interface PickeableInterface {
  pick();
}

class Player implements BaseEntityInterface, AttackeableInterface, PickeableInterface {
  walk() {
    // Implementation of walk
  }
  run() {
    // Implementation of run
  }
  jump() {
    // Implementation of jump
  }
  attack() {
    // Implementation of attack
  }
  pick() {
    // Implementation of pick
  }
}

class Enemy implements BaseEntityInterface, AttackeableInterface {
  walk() {
    // Implementation of walk
  }
  run() {
    // Implementation of run
  }
  jump() {
    // Implementation of jump
  }
  attack() {
    // Implementation of attack
  }
}

class Hostage implements BaseEntityInterface {
  walk() {
    // Implementation of walk
  }
  run() {
    // Implementation of run
  }
  jump() {
    // Implementation of jump
  }
}

Dependency Inversion Principle

This principle is based on the idea that high-level modules should not depend on low-level modules. Both must depend on abstractions. This principle promotes decoupling and modularity of code, which makes it easy to evolve and test individual components. This principle is also very useful when testing, since by using dependency inversion, it is possible to replace real implementations with mock or test objects with mocks or stubs.

To illustrate this further, let's consider an example implementation:

class UsersReport {
  database: MySQLDatabase;
  constructor(database) {
      this.database = database
  }
  open() {
      this.database.get();
  }
  save() {
      this.database.insert();
  }
}

class MySQLDatabase {
  get() {
    // Get implementation
  }
  insert() {
      // Insert implementation
  }
  update() {
      // Update implementation
  }
  delete() {
      // Delete implementation
  }
}

// When we use this classes
const report = new UsersReport(
new MySQLDatabase()
)
report.open()

This code works, yes, but it has a lot of problems, since the implementation of the user report generator is completely rooted in MySQL, and if tomorrow we want to use another service, such as Postgress, we are in trouble, The exchange of both will not be so simple.

Also, if we think about the tests, it may be that when testing, you want to use an in-memory database to speed up the process as well as not wanting to use the same production database, for obvious reasons.

What is recommended then is to apply the dependency inversion principle:

interface DatabaseInterface {
  get();
  insert();
  update();
  delete();
}

class MySQLDatabase implements DatabaseInterface {
  get() {
    // Get implementation for MySQL
  }
  insert() {
      // Insert implementation for MySQL
  }
  update() {
      // Update implementation for MySQL
  }
  delete() {
      // Delete implementation for MySQL
  }
}

class Postgress implements DatabaseInterface {
  get() {
    // Get implementation for Postgress
  }
  insert() {
      // Insert implementation for Postgress
  }
  update() {
      // Update implementation for Postgress
  }
  delete() {
      // Delete implementation for Postgress
  }
}

class UsersReport {
  database: DatabaseInterface;
  constructor(database) {
      this.database = database;
  }
  open() {
      this.database.get();
  }
  save() {
      this.database.insert();
  }
}
// When we use this classes
const mysql = new MySQLDatabase();
const usersReportMySQL = new UsersReport(mysql);
usersReportMySQL.open();

const postgress = new Postgress();
const usersReportPostgress = new UsersReport(postgress);
usersReportPostgress.open();

Conclusion

To finish and round off the idea, the SOLID principles are just that, principles, that is, recommendations or good practices that can help to write cleaner, more maintainable, scalable and, above all, higher-quality code, which will allow us to lay foundations in our projects and leave the footer so that other developers can continue from there with robust and readable code.

Robert C. Martin himself once stated:

"This is not about rules, or laws, or absolute truths, but rather common sense solutions to common problems."

This allows us to know that although they are not strictly necessary laws or concepts, they do allow a higher quality development and will be better received by third parties.