SOLID principles are a set of software designs introduced by Robert C. “Uncle Bob” Martin. These principles guide developers in building robust, maintainable applications while minimizing the cost of changes.
Although SOLID principles are often used with object-oriented programming, we can use them with other languages like JavaScript. In this article, we will discuss how to use SOLID principles in JavaScript and demonstrate them with code examples.
A class, a module, or a function should be only responsible for one actor. So, it should have one and only one reason to change.
The single responsibility principle is one of SOLID’s simplest principles. However, developers often misinterpret it, thinking a module should do a single thing.
Let’s consider a simple example to understand this principle. The following JavaScript code snippet has a class named ManageEmployee and several functions to manage employees.
class ManageEmployee { constructor(private http: HttpClient) SERVER_URL = 'http://localhost:5000/employee'; getEmployee (empId){ return this.http.get(this.SERVER_URL + `/${empId}`); } updateEmployee (employee){ return this.http.put(this.SERVER_URL + `/${employee.id}`,employee); } deleteEmployee (empId){ return this.http.delete(this.SERVER_URL + `/${empId}`); } calculateEmployeeSalary (empId, workingHours){ var employee = this.http.get(this.SERVER_URL + `/${empId}`); return employee.rate * workingHours; } }
The previous code seems completely fine at a glance, and many developers will follow the same approach without any issues. However, since it is responsible for two actors, this class violates the single responsibility principle. The getEmployee(), updateEmployee(), and deleteEmployee() functions are directly associated with HR management, while the calculateEmployeeSalary() function is associated with finance management.
In the future, if you need to update a function for the HR or finance department, you will have to change the ManageEmployee class, affecting both actors. Therefore, the ManageEmployee class violates the single responsibility principle. You would need to separate the functionalities related to the HR and finance departments to make the code compatible with the single responsibility principle. The following code example demonstrates this.
class ManageEmployee { constructor(private http: HttpClient) SERVER_URL = 'http://localhost:5000/employee'; getEmployee (empId){ return this.http.get(this.SERVER_URL + `/${empId}`); } updateEmployee (employee){ return this.http.put(this.SERVER_URL + `/${employee.id}`,employee); } deleteEmployee (empId){ return this.http.delete(this.SERVER_URL + `/${empId}`); } } class ManageSalaries { constructor(private http: HttpClient) SERVER_URL = 'http://localhost:5000/employee'; calculateEmployeeSalary (empId, workingHours){ var employee = this.http.get(this.SERVER_URL + `/${empId}`); return employee.rate * workingHours; } }
Functions, modules, and classes should be extensible but not modifiable.
This is an important principle to follow when implementing large-scale applications. According to this principle, we should be able to add new features to applications easily, but we should not introduce breaking changes to the existing code.
For example, assume that we have implemented a function named calculateSalaries() that uses an array with defined job roles and hourly rates to calculate salaries.
class ManageSalaries { constructor() { this.salaryRates = [ { id: 1, role: 'developer', rate: 100 }, { id: 2, role: 'architect', rate: 200 }, { id: 3, role: 'manager', rate: 300 }, ]; } calculateSalaries(empId, hoursWorked) { let salaryObject = this.salaryRates.find((o) => o.id === empId); return hoursWorked * salaryObject.rate; } } const mgtSalary = new ManageSalaries(); console.log("Salary : ", mgtSalary.calculateSalaries(1, 100));
Directly modifying the salaryRates array will violate the open-closed principle. For example, suppose you need to extend the salary calculations for a new role. In that case, you need to create a separate method to add salary rates to the salaryRates array without making to the original code.
class ManageSalaries { constructor() { this.salaryRates = [ { id: 1, role: 'developer', rate: 100 }, { id: 2, role: 'architect', rate: 200 }, { id: 3, role: 'manager', rate: 300 }, ]; } calculateSalaries(empId, hoursWorked) { let salaryObject = this.salaryRates.find((o) => o.id === empId); return hoursWorked * salaryObject.rate; } addSalaryRate(id, role, rate) { this.salaryRates.push({ id: id, role: role, rate: rate }); } } const mgtSalary = new ManageSalaries(); mgtSalary.addSalaryRate(4, 'developer', 250); console.log('Salary : ', mgtSalary.calculateSalaries(4, 100));
Let P(y) be a property provable about objects y of type A. Then P(x) should be true for objects x of type B where B is a subtype of A.
You will find different definitions of the Liskov substitution principle across the internet, but they all imply the same meaning. In simple terms, the Liskov principle states that we should not replace a parent class with its subclasses if they create unexpected behaviors in the application.
For example, consider a class named Animal, which includes a function named eat().
class Animal{ eat() { console.log("Animal Eats") } }
Now I will extend the Animal class to a new class named Bird with a function named fly().
class Bird extends Animal{ fly() { console.log("Bird Flies") } } var parrot = new Bird(); parrot.eat(); parrot.fly();
In the previous example, I have created an object named parrot from the Bird class and called both the eat() and fly() methods. Since the parrot is capable of both those actions, extending the Animal class to the Bird class does not violate the Liskov principle.
Now let’s extend the Bird class further and create a new class named Ostrich.
class Ostrich extends Bird{ console.log("Ostriches Do Not Fly") } var ostrich = new Ostrich(); ostrich.eat(); ostrich.fly();
This extension of the Bird class violates the Liskov principle since Ostriches cannot fly—this could create unexpected behavior in the application. The best way to address this case is to extend the Ostrich class from the Animal class.
class Ostrich extends Animal{ walk() { console.log("Ostrich Walks") } }
Clients should not be pushed to depend on interfaces they will never use.
This principle is related to interfaces and focuses on breaking large interfaces into smaller ones. For example, suppose you are going to driving school to learn how to drive a car, and they give you a large set of instructions on driving cars, trucks, and trains. Since you only need to learn to drive a car, you do not need all the other information. The driving school should divide the instructions and just give you the instructions specific to cars.
Since JavaScript does not support interfaces, it is difficult to adopt this principle in JavaScript-based applications. However, we can use JavaScript compositions to implement this. Compositions allow developers to add functionalities to a class without inheriting the entire class. For example, assume that there is a class named DrivingTest with two functions named startCarTest and startTruckTest. If we extend the DrivingTest class for CarDrivingTest and TruckDrivingTest, we have to force both classes to implement the startCarTest and startTruckTest functions.
Class DrivingTest { constructor(userType) { this.userType = userType; } startCarTest() { console.log(“This is for Car Drivers”’); } startTruckTest() { console.log(“This is for Truck Drivers”); } } class CarDrivingTest extends DrivingTest { constructor(userType) { super(userType); } startCarTest() { return “Car Test Started”; } startTruckTest() { return null; } } class TruckDrivingTest extends DrivingTest { constructor(userType) { super(userType); } startCarTest() { return null; } startTruckTest() { return “Truck Test Started”; } } const carTest = new CarDrivingTest(carDriver ); console.log(carTest.startCarTest()); console.log(carTest.startTruckTest()); const truckTest = new TruckDrivingTest( ruckdriver ); console.log(truckTest.startCarTest()); console.log(truckTest.startTruckTest());
However, this implementation violates the interface segregation principle since we are forcing both extended classes to implement both functionalities. We can resolve this by using compositions to attach functionalities for required classes as shown in the following sample.
Class DrivingTest { constructor(userType) { this.userType = userType; } } class CarDrivingTest extends DrivingTest { constructor(userType) { super(userType); } } class TruckDrivingTest extends DrivingTest { constructor(userType) { super(userType); } } const carUserTests = { startCarTest() { return ‘Car Test Started’; }, }; const truckUserTests = { startTruckTest() { return ‘Truck Test Started’; }, }; Object.assign(CarDrivingTest.prototype, carUserTests); Object.assign(TruckDrivingTest.prototype, truckUserTests); const carTest = new CarDrivingTest(carDriver ); console.log(carTest.startCarTest()); console.log(carTest.startTruckTest()); // Will throw an exception const truckTest = new TruckDrivingTest( ruckdriver ); console.log(truckTest.startTruckTest()); console.log(truckTest.startCarTest()); // Will throw an exception
Now, carTest.startTruckTest(); will throw an exception since the startTruckTest() function is not assigned to the CarDrivingTest class.
Higher-level modules should use abstractions. However, they should not depend on low-level modules.
Dependency inversion is all about decupling your code. Following this principle will give you the flexibility to scale and change your application at the highest levels without any issues.
Regarding JavaScript, we do not need to think about abstractions since JavaScript is a dynamic language. However, we need to make sure higher-level modules do not depend on lower-level modules.
Let’s consider a simple example to explain how dependency inversion works. Suppose you used the Yahoo email API in your application, and now you need to change it to the Gmail API. If you implemented the controller without dependency inversion like the following sample, you need to make some changes to the controller. This is because multiple controllers use the Yahoo API and you need to find each instance and update it.
class EmailController { sendEmail(emailDetails) { // Need to change this line in every controller that uses YahooAPI.const response = YahooAPI.sendEmail(emailDetails); if (response.status == 200) { return true; } else { return false; } } }
The dependency inversion principle helps developers avoid such costly mistakes by moving the email API handling part to a separate controller in this case. Then you only need to change that controller whenever there is a change in the email API.
class EmailController { sendEmail(emailDetails) { const response = EmailApiController.sendEmail(emailDetails); if (response.status == 200) { return true; } else { return false; } } } class EmailApiController { sendEmail(emailDetails) { // Only need to change this controller. return YahooAPI.sendEmail(emailDetails); } }
In this article, we discussed the importance of SOLID principles in software design and how we can adopt these concepts in JavaScript applications. As developers, it is essential to understand and use these core concepts in our applications. Sometimes the benefits of these principles may not be obvious when working with small applications, but you will surely know the difference they make once you start to work on a large-scale project.
I hope this article helped you to understand SOLID principles. Thank you for reading.
The Syncfusion JavaScript suite is the only suite you will ever need to build an application. It contains over 80 high-performance, lightweight, modular, and responsive UI components in a single package. Download the free trial and evaluate the controls today.
If you have any questions or comments, you can contact us through our support forums, support portal, or feedback portal. We are always happy to assist you!