The introduction of the OOP paradigm popularized key programming concepts such as Inheritance, Polymorphism, Abstraction, and Encapsulation. OOP quickly became a widely accepted programming paradigm with implementation in several languages such as Java, C++, C#, JavaScript, and more. The OOP system became more complex over time, but its software remained resistant to change. To improve software extensibility and reduce code rigidity, Robert C. Martin (a.k.a Uncle Bob) introduced the SOLID principles in the early 2000s.
SOLID is an acronym that consists of a set of principles — single responsibility principle, open-closed principle, Liskov substitution principle, interface segregation principle, and dependency inversion principle — that helps software engineers design and write maintainable, scalable, and flexible code. Its aim? To improve the quality of software developed following the Object Oriented Programming (OOP) paradigm.
In this article, we will delve into all of SOLID’s principles and illustrate how they are implemented using one of the most popular web programming languages, JavaScript.
Single Responsibility Principle (SRP)
The first letter in SOLID represents the single responsibility principle. This principle suggests that a class or module should perform just one role.
Simply put, a class should have a single responsibility or a single reason to change. If a class handles more than one functionality, updating one functionality without affecting the others becomes tricky. The subsequent complications could result in a fault in software performance. To avoid these kinds of problems, we should do our best to write modular software in which concerns are separated.
If a class has too many responsibilities or functionalities, it becomes a headache to modify. By using the single responsibility principle, we can write code that is modular, easier to maintain, and less error-prone. Take, for instance, a person model:
class Person {
constructor(name, age, height, country){
this.name = name
this.age = age
this.height = height
this.country = country
}
getPersonCountry(){
console.log(this.country)
}
greetPerson(){
console.log("Hi " + this.name)
}
static calculateAge(dob) {
const today = new Date();
const birthDate = new Date(dob);
let age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
age--;
}
return age;
}
}
The code above appears to be fine, right? Not quite. The sample code violates the single responsibility principle. Instead of being the only model from which other instances of a Person
can be created, the Person
class also has other responsibilities such as calculateAge, greetPerson and getPersonCountry
.
These extra responsibilities handled by the Person
class make it difficult to change just one aspect of the code. For example, if you attempted to refactor the calculateAge
, you might also be forced to refactor the Person
model. Depending on how compact and complex our code base is, it could be difficult to reconfigure the code without causing errors.
Let’s try and revise the mistake. We can separate the responsibilities into different classes, like so:
class Person {
constructor(name, dateOfBirth, height, country){
this.name = name
this.dateOfBirth = dateOfBirth
this.height = height
this.country = country
}
}
class PersonUtils {
static calculateAge(dob) {
const today = new Date();
const birthDate = new Date(dob);
let age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
if(monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
age--;
}
return age;
}
}
const person = new Person("John", new Date(1994, 11, 23), "6ft", "USA");
console.log("Age: " + PersonUtils.calculateAge(person.dateOfBirth));
class PersonService {
getPersonCountry(){
console.log(this.country)
}
greetPerson(){
console.log("Hi " + this.name)
}
}
As you can see from the sample code above, we’ve separated our responsibilities. The Person
class is now a model with which we can create a new person object. And the PersonUtils
class has just one responsibility — to calculate the age of a person. The PersonService
class handles greetings and shows us each person’s country.
If we want, we can still reduce this process more. Following the SRP, we want to decouple the responsibility of a class to the barest minimum so that when there is an issue, refactoring and debugging can be done without much hassle.
By dividing functionality into separate classes, we’re adhering to the single responsibility principle and ensuring each class is responsible for a specific aspect of the application.
Before we move on to the next principle, it should be noted that adhering to the SRP doesn’t mean that each class should strictly contain a single method or functionality.
However, adhering to the single responsibility principle means we should be intentional about assigning functionalities to classes. Everything a class carries out should be closely related in every sense. We must be careful not to have several classes scattered everywhere, and we should, by all means, avoid bloated classes in our code base.
Open-Closed Principle (OCP)
The open-closed principle states that software components (classes, functions, modules, etc.) should be open to extension and closed to modification. I know what you’re thinking — yes, this idea of might seem contradictory at first. But the OCP is simply asking that the software is designed in a way that allows for extension without necessarily modifying the source code.
The OCP is crucial for maintaining large code bases, as this guideline is what allows you to introduce new features with little to no risk of breaking the code. Instead of modifying the existing classes or modules when new requirements arise, you should extend the relevant classes by adding new components. As you do this, be sure to check that the new component doesn’t introduce any bugs to the system.
The OC principle can be achieved in JavaScript using the ES6+ class Inheritance
feature.
The following code snippets illustrate how to implement the Open-Closed principle in JavaScript, using the aforementioned ES6+ class keyword:
class Rectangle {
constructor(width, height) {
this.width = width;
this.height = height;
}
area() {
return this.width * this.height;
}
}
class ShapeProcessor {
calculateArea(shape) {
if (shape instanceof Rectangle) {
return shape.area();
}
}
}
const rectangle = new Rectangle(10, 20); const shapeProcessor = new ShapeProcessor(); console.log(shapeProcessor.calculateArea(rectangle));
The code above works fine, but it’s limited to calculating only the area of a rectangle. Now imagine that there is a new requirement to calculate. Let’s say, for instance, that we need to calculate the area of a circle. We would have to modify the shapeProcessor
class to cater to that. However, following the JavaScript ES6+ standard, we can extend this functionality to account for areas of new shapes without necessarily modifying the shapeProcessor
class.
We can do that like so:
class Shape {
area() {
console.log("Override method area in subclass");
}
}
class Rectangle extends Shape {
constructor(width, height) {
super();
this.width = width;
this.height = height;
}
area() {
return this.width * this.height;
}
}
class Circle extends Shape {
constructor(radius) {
super();
this.radius = radius;
}
area() {
return Math.PI * this.radius * this.radius;
}
}
class ShapeProcessor {
calculateArea(shape) {
return shape.area();
}
}
const rectangle = new Rectangle(20, 10);
const circle = new Circle(2);
const shapeProcessor = new ShapeProcessor();
console.log(shapeProcessor.calculateArea(rectangle));
console.log(shapeProcessor.calculateArea(circle));
In the above code snippet, we extended the functionality of the Shape
class by using the extends
keyword. In each subclass, we override the implementation of the area()
method. Following this principle, we can add more shapes and process areas without needing to modify the functionality of the ShapeProcessor
class.
Why Is the OCP Important?
- Reduce bugs: OCP helps to avoid bugs in a large code base by avoiding system modification.
- Encourages software adaptability: OCP also improves the ease with which new features can be added to software without breaking or altering the source code.
- Testing new features: OCP promotes code extension over modification, making it easier for new features to be tested as a unit without affecting the entire codebase.
The Liskov Substitution principle
The Liskov substitution principle states that an object of a subclass should be able to replace an object of a superclass without breaking the code. Let’s break down how that works with an example: if L is a subclass of P, then an object of L should replace an object of P without breaking the system. This just means that a subclass should be able to override a superclass method in a way that does not break the system.
In practice, the Liskov substitution principle ensures that the following conditions are adhered to:
- A subclass should override methods of the parent class without breaking the code
- A subclass should not deviate from the behavior of the parent class, meaning subclasses can only add functionality but cannot alter or remove the parent class functionality
- The code that works with the instances of the parent class should work with the instances of the subclasses without needing to know that the class has changed
It’s time to illustrate the Liskov substitution principle with JavaScript code samples. Take a look:
class Vehicle {
OnEngine(){
console.log("Engine is steaming!")
}
}
class Car extends Vehicle {
// you can invoke the super class OnEngine method and implement how Cars On engine
}
class Bicycle extends Vehicle {
OnEngine(){
throw new Error("Bicycles technically don't have an engine")
}
}
const myCar = new Car();
const myBicycle = new Bicycle();
myCar.OnEngine();
myBicycle.OnEngine();
In the code snippet above, we created two subclasses (Bicycle and Car) and one superclass (Vehicle). For the purposes of this article, we implemented a single method (OnEngine) for the superclass.
One of the core conditions for the LSP is that subclasses should override the parent classes' functionality without breaking the code. Keeping that in mind, let’s see how the code snippet we just saw violates the Liskov substitution principle. In reality, a Car
has an engine and can turn ON
an engine but a bicycle technically doesn’t have an engine and therefore cannot turn ON
an engine. So, a Bicycle
cannot override the OnEngine
method in the Vehicle
class without breaking the code.
We’ve now identified the section of the code that violates the Liskov substitution principle. The Car
class can override the OnEngine
functionality in the superclass and implement it in such a way that differentiates it from other vehicles (like an airplane, for example) and the code will not break. The Car
class satisfies the Liskov substitution principle.
In the code snippet below, we’ll illustrate how to structure the code to be in compliance with the Liskov substitution principle:
class Vehicle {
move() {
console.log("The vehicle is moving.");
}
}
Here is a basic example of a Vehicle
class with a general functionality, move
. It is a general belief that all vehicles move; they just move via different mechanisms. One way we’re going to illustrate LSP is to override the move()
method and implement it in a way that depicts how a particular vehicle, for instance, a Car
would move.
To do so, we’re going to create a Car
class that extends the Vehicle
class and overrides the move method to suit the movement of a car, like so:
class Car extends Vehicle {
move(){
console.log("Car is running on four wheels")
}
}
We can still implement the move method in another sub-vehicle class—for instance—an airplane.
Here’s how we would do that:
class Airplane extends Vehicle {
move(){
console.log("Airplane is flying...")
}
}
In these two examples above, we illustrated key concepts such as inheritance and method overriding.
N.B: A programming feature that allows subclasses to implement a method already defined in the parent class is called method overriding.
Let’s do some housekeeping and put everything together, like so:
class Vehicle {
move() {
console.log("The vehicle is moving.");
}
}
class Car extends Vehicle {
move(){
console.log("Car is running on four wheels")
}
getSeatCapacity(){
}
}
class Airplane extends Vehicle {
move(){
console.log("Airplane is flying...")
}
}
const car = new Car();
const airplane = new Airplane();
car.move() // output: Car is running on four wheels
Now, we have 2 subclasses inheriting and overriding a single functionality from the parent class and implementing it according to their requirements. This new implementation does not break the code.
Interface Segregation Principle (ISP)
The interface segregation principle states that no client should be forced to depend on an interface it doesn’t use. It wants us to create smaller, more specific interfaces that are relevant to the particular clients, rather than having a large, monolithic interface that forces clients to implement methods they don’t need.
Keeping our interfaces compact makes code bases easier to debug, maintain, test, and extend. Without the ISP, a change in one part of a large interface could force changes in unrelated parts of the codebase, causing us to carry out code refactoring which in most cases depending on the size of the code base can be a difficult task.
JavaScript, unlike C-based programming languages like Java, does not have built-in support for interfaces. However, there are techniques with which interfaces are implemented in JavaScript.
Interfaces are a set of method signatures that a class must implement.
In JavaScript, you define an interface as an object with names of method and function signatures, like so:
const InterfaceA = {
method: function (){}
}
To implement an interface in JavaScript, create a class and ensure that it contains methods with the same names and signatures that are specified in the interface:
class LogRocket {
method(){
console.log("This is a method call implementing an interface”)
}
}
Now we’ve figured out how to create and use interfaces in JavaScript. The next thing we need to do is illustrate how to segregate interfaces in JavaScript so that we can see how it all fits together and makes code easier to maintain.
In the following example, we’ll use a printer to illustrate the interface segregation principle.
Assuming we have a printer, scanner, and fax, let’s create an interface defining these objects’ functions:
const printerInterface = {
print: function(){
}
}
const scannerInterface = {
scan: function(){
}
}
const faxInterface = {
fax: function(){
}
}
In the code above, we created a list of separated or segregated interfaces against having one large interface that defines all of these functionalities. By breaking these functionalities into smaller bits and more specific interfaces, we’re allowing different clients to implement just the methods they need and keeping all the other bits out.
In the next step, we’ll create classes that implement these interfaces. Following the interface segregation principle, each class will only implement the methods that it needs.
If we want to implement a basic printer that can only print documents, we can just implement the print()
method through the printerInterface
, like so:
class Printer {
print(){
console.log(“printing document”)
}
}
This class only implements the PrinterInterface
. It doesn't implement scan
or fax
method. By following the interface segregation principle, the client — in this case, the Printer
class — has reduced its complexity and improved the performance of a software.
Dependency Inversion Principle (DIP)
Now for our last principle: the dependency inversion principle. This principle says that higher-level modules (business logic) should rely on abstraction rather than relying directly on lower-level modules (concretion). It helps us to reduce code dependencies and offers developers the flexibility to modify and expand applications at higher levels without encountering complications.
Why does the dependency inversion principle favor abstraction over direct dependencies? That’s because the introduction of abstractions reduces the potential impacts of changes, improves testability (mocking abstractions instead of concrete implementations), and achieves a higher degree of flexibility in your code. This rule makes it easier to extend software components through a modular approach and also helps us to modify low-level components without affecting high-level logic.
Adhering to the DIP makes code easier to maintain, extend, and scale, thereby stopping bugs that might occur because of changes in the code. It recommends that developers use loose coupling instead of tight coupling between classes. Generally, by embracing a mindset that prioritizes abstractions over direct dependencies, teams will gain the agility to adapt and add new functionalities or change old components without causing ripple disruptions. In JavaScript, we’re able to implement DIP using the dependency injection approach, like so:
class MySQLDatabase {
connect() {
console.log('Connecting to MySQL database...');
}
}
class MongoDBDatabase {
connect() {
console.log('Connecting to MongoDB database...');
}
}
class Application {
constructor(database) {
this.database = database;
}
start() {
this.database.connect();
}
}
const mySQLDatabase = new MySQLDatabase();
const mySQLApp = new Application(mySQLDatabase);
mySQLApp.start();
const mongoDatabase = new MongoDBDatabase();
const mongoApp = new Application(mongoDatabase);
mongoApp.start();
In the basic example above, the Application
class is the high-level module that depends on the database abstraction. We created two database classes: MySQLDatabase
, and MongoDBDatabase
. The databases are low-level modules, and their instances are injected into the Application
runtime without modifying the Application
itself.
Conclusion
The SOLID principle is a fundamental building block for scalable, maintainable, and robust software design. This set of principles helps developers write clean, modular, and adaptable code.
The SOLID principle promotes cohesive functionality, extensibility without modification, object substitution, interface separation, and abstraction over concrete dependencies. Be sure to integrate the SOLID principles into your code to prevent bugs to reap all their benefits.
LogRocket: Debug JavaScript errors more easily by understanding the context
Debugging code is always a tedious task. But the more you understand your errors, the easier it is to fix them.
LogRocket allows you to understand these errors in new and unique ways. Our frontend monitoring solution tracks user engagement with your JavaScript frontends to give you the ability to see exactly what the user did that led to an error.
LogRocket records console logs, page load times, stack traces, slow network requests/responses with headers + bodies, browser metadata, and custom logs. Understanding the impact of your JavaScript code will never be easier!
Top comments (1)
Gained great knowledge from this. ✨