Introduction
In software development, adhering to design principles is essential for creating maintainable, flexible, and efficient applications. It helps us maintain consistency and quality in code and reduces bugs, sometimes petty ones, that may arise when appropriate coding standards or design principles are not followed. In this blog post, we will explore three of the most common software design principles: SOLID, DRY, and KISS. By understanding and applying these principles, developers can enhance the quality and sustainability of their codebase.
SOLID Principles
The SOLID principles are a set of design principles introduced by Robert C. Martin (aka Uncle Bob) that promote modularity and extensibility. Let’s briefly discuss them with examples in NodeJs.
Credit: Geeksforgeeks
Single Responsibility Principle (SRP)
A class should have a single responsibility. By separating concerns into distinct classes, the SRP ensures that each class is focused (having one purpose), cohesive (strong relationship between methods and data of a class), and easier to maintain. The Single Responsibility Principle states that a class should have only one reason to change, a single responsibility or purpose. By sticking to this principle, we ensure that each class focuses on a specific task, making it easier to understand, maintain, and modify.
Example:
class Employee {
constructor(name, age, designation, salary) {
this.name = name;
this.age = age;
this.designation = designation;
this.salary = salary;
}
getName() {
return this.name;
}
getAge() {
return this.age;
}
getDesignation() {
return this.designation;
}
getSalary() {
return this.salary;
}
printEmployeeDetails() {
console.log(`Name: ${this.name}`);
console.log(`Age: ${this.age}`);
console.log(`Designation: ${this.designation}`);
console.log(`Salary: ${this.salary}`);
}
}
Explanation
In the above example, we have an Employee class that has only one purpose, to represent the employee data, and we have methods that we can use to access its properties like getName(), getAge(), getDesignation(), and getSalary(). The beauty of this principle is that if any changes in any of the methods are required, then only that function is affected, and the rest remain untouched.
Open-Closed Principle (OCP)
This principle states that software entities like classes, modules, functions, etc., should be open for extension but closed for modification. To simplify, you should be able to extend the behavior of a class without making any modifications to the existing code. This principle is usually achieved using inheritance and abstraction and promotes modularity, flexibility, and code reusability.
Example:
class Vehicle {
constructor (fuelCapacity, fuelEfficiency) {
this.fuelCapacity = fuelCapacity;
this.fuelEfficiency = fuelEfficiency;
}
getRange() {
return this.fuelCapacity * this.fuelEfficiency;
}
}
class HybridVehicle extends Vehicle {
getRange() {
return super.getRange() * 1.5;
}
}
const hybridVehicle = new HybridVehicle(100, 50);
console.log(`Hybrid Vehicle range: ${hybridVehicle.getRange()}`);
Explanation:
In the above example, we wanted to extend the functionality to our Vehicle class without modifying the original code, so we used inheritance to create a new class extending our Vehicle class. We avoided altering the original code and leveraged the getRange method to calculate the range of hybrid vehicles.
Liskov Substitution Principle (LSP)
LSP states that objects of a superclass can be replaceable by objects of a subclass. In other words, if you have a class Vehicle and a subclass as Car, then you should be able to use the Car class anywhere you can use the Vehicle class. This principle prevents unexpected behavior and enables the correct usage of inheritance.
Example:
class Vehicle {
constructor (name) {
this.name = name;
}
move() {
console.log(`${this.name} is moving.`);
}
}
class Car extends Vehicle {
constructor (name, engine) {
super(name);
this.engine = engine;
}
move() {
console.log(`${this.name} is moving with an engine.`);
}
}
const car = new Car(‘My Car’, ‘V8’);
car.move(); // My Car is moving with an engine.
Explanation:
In the example, we use the Car subclass to inherit the Vehicle class and override the move() method. The move() method in the Car subclass prints a different message than the move() method in the Vehicle class. However, the move() method in the Car subclass still behaves like a Vehicle object.
Interface Segregation Principle (ISP)
ISP states that classes should not implement interfaces not required by them. The ISP encourages the creation of smaller, focused interfaces to prevent unnecessary dependencies. By following this principle, you can design more flexible and maintainable systems.
Example:
interface Vehicle {
move();
stop();
honk();
refuel();
}
interface Movable {
move();
}
interface Stoppable {
stop();
}
class Car implements Movable, Stoppable {
move() {
console.log(‘The car is moving.’);
}
stop() {
console.log(‘The car is stopping.’);
}
}
const car = new Car();
car.move(); // The car is moving.
car.stop(); // The car is stopping.
Explanation:
As can be seen, interface Vehicle is a bad practice as we are exposing too many methods. On the other hand, we can see interfaces Movable and Stoppable, which are smaller and more manageable interfaces that prevent unnecessary dependency.
Dependency Inversion Principle (DIP)
Dependency inversion principle states that 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. The DIP promotes loose coupling by allowing dependencies to be injected rather than hardcoded. This principle enables easier testing, modularity, and scalability.
Example:
// Bad example
class EmailService {
constructor(smtpClient) {
this.smtpClient = smtpClient;
}
sendEmail(to, from, subject, body) {
this.smtpClient.sendEmail(to, from, subject, body);
}
}
// This is a better example. The `EmailService` class depends on the `EmailSender` interface.
interface EmailSender {
sendEmail(to, from, subject, body);
}
class SMTPClient implements EmailSender {
sendEmail(to, from, subject, body) {
// Send the email using the SMTP protocol.
}
}
class EmailService {
constructor(emailSender) {
this.emailSender = emailSender;
}
sendEmail(to, from, subject, body) {
this.emailSender.sendEmail(to, from, subject, body);
}
}
Explanation:
If you check the EmailService class in the bad example, you’ll notice that it depends on the SMTPClient class, which is a bad practice. Simply put, if we want to change the way emails are sent, we have to change the EmailService class. On the other hand, in the next example, we can see that the EmailService is dependent on the EmailSender interface where we can change the implementation of sending emails.
DRY (Don’t Repeat Yourself)
The DRY principle emphasizes the avoidance of code duplication. It suggests that code should be written in a way that maximizes reusability and minimizes redundancy. By extracting common functionality into reusable components, functions, or modules, the DRY principle improves maintainability, reduces the chance of introducing bugs, and enhances code readability.
KISS (Keep It Simple, Stupid)
KISS emphasizes simplicity over unnecessary complexity. It suggests that solutions should be kept straightforward, understandable, and easy to maintain. By following this principle, developers can avoid over-engineering and focus on creating lean and efficient software.
Conclusion
By adhering to the SOLID principles and embracing the DRY principle, developers can create well-structured, maintainable, and reusable software solutions. The SOLID principles guide the design process, promoting modular and flexible architectures, while the DRY principle encourages code reuse and reduces redundancy. By consistently applying these principles, developers can improve code quality, facilitate future enhancements, and streamline the maintenance process. Remember, these principles are not strict rules but guidelines that should be adapted to suit the specific needs of each project.