SOLID principles in JavaScript
In this blog post, we will explore each of the SOLID principles with JavaScript examples using function () constructor and prototype methods. You can consider these as class and class methods respectively.
What is the SOLID principle? #
- SOLID is an acronym for five principles of object-oriented programming (OOP) and design.
- Guiding principles to write maintainable, scalable, reliable, efficient, stable, testable and clean code.
- Even though these principles are related to OOP, we can apply these principles to other programming paradigms as well.
Let's explore each of these principles with JavaScript examples. Imagine we are building a patient management system for a hospital. To begin with, we can create the following classes to model our system.
function Hospital() {}
function Patient() {}
1. Single Responsibility Principle (SRP) #
"A class should have only one reason to change."
Before applying SRP #
function Hospital() {
this.patients = [];
}
// add patient to the hospital
Hospital.prototype.addPatient = function (patient) {};
// remove patient from the hospital
Hospital.prototype.removePatient = function (patient) {};
// get all patients from the hospital
Hospital.prototype.getPatients = function () {};
// export patient data in default PDF format
Hospital.prototype.exportPatientData = function (patient) {};
// notify patient via default EMAIL channel
Hospital.prototype.sendPatientNotification = function (patient) {};
One can argue that all methods defined in the above class are related to the functionality of Hospital
. But these functions are not cohesive and focused, so they have many reasons to change.
- What will happen if the hospital decides to change the format of the exported patient names? (Think about changing the format from PDF to CSV)
- What will happen if the hospital decides to change the way it notifies the patient? (Think about changing the notification channel from EMAIL to SMS)
Wouldn't it be nice if we could separate these responsibilities into different classes?
After applying SRP #
function Hospital() {}
Hospital.prototype.addPatient = function (patient) {};
Hospital.prototype.removePatient = function (patient) {};
Hospital.prototype.getPatients = function () {};
function DataExporter() {}
DataExporter.prototype.exportPatientData = function (patient) {};
function Notifier() {}
Notifier.prototype.sendPatientNotification = function (patient) {};
I hope we have achieved a better separation of concerns by separating the responsibilities into different classes: Hospital
, DataExporter
and Notifier
. Now, each of these classes have a single reason to change and minimize the effects of change.
With the above changes,
Hospital
class has only one reason to change, which is to add or remove patients.DataExporter
class has only one reason to change, which is to export patient data.Notifier
class has only one reason to change, which is to notify patients.
2. Open-Closed Principle (OCP) #
"Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification."
Before we dive into examples, let's first understand some patterns that help to extend our application.
What are the ways of extension? #
- Inheritance: It is one of the ways to achieve extension. But since subclasses are tightly coupled to its parent class, users need to know the internal implementation details of the parent class. So, this is not considered ideal for extension.
function CsvDataExporter() {}
Object.setPrototypeOf(CsvDataExporter.prototype, DataExporter.prototype);
// override parent exportPatientData method to export patient data in CSV format
CsvDataExporter.prototype.exportPatientData = function (patient) {};
function XmlDataExporter() {}
Object.setPrototypeOf(XmlDataExporter.prototype, DataExporter.prototype);
// override parent exportPatientData method to export patient data in XML format
XmlDataExporter.prototype.exportPatientData = function (patient) {};
- Configuration: With this approach, users should not need to know the internal implementation details of the parent class. Instead, they can configure the class with the configuration object to achieve extension.
function DataExporter(config) {
// let's assume config object has a exportPatientData method
this.config = config;
}
DataExporter.prototype.exportPatientData = function (patient) {
return this.config.exportPatientData
? this.config.exportPatientData(patient)
: this.defaultExportPatientData(patient);
};
// export patient data in some default format
DataExporter.prototype.defaultExportPatientData = function (patient) {};
// create instances
const csvDataExporter = new DataExporter({
exportPatientData(patient) {
// export patient data in CSV format
// CSV specific logic goes here
},
});
const xmlDataExporter = new DataExporter({
exportPatientData(patient) {
// export patient data in XML format
// XML specific logic goes here
},
});
Now, imagine a situation where we need to add a new feature to support CSV and XML data download for patients.
Before applying OCP #
function Hospital() {}
Hospital.prototype.downloadPatientDataAllFormats = function (patient) {
// existing data format logic goes here ....
// new code to support CSV format
const csvDataExporter = new CsvDataExporter();
csvDataExporter.exportPatientData(patient);
// new code to support XML format
const xmlDataExporter = new XmlDataExporter();
xmlDataExporter.exportPatientData(patient);
};
const hospital = new Hospital();
const patient = new Patient();
hospital.downloadPatientDataAllFormats(patient);
What will happen if we need to export data in different formats in the future again? #
- We need to modify
downloadPatientDataAllFormats
method to support the new data format. Correct? - This violates OCP. We are modifying the existing code to support the new feature.
After applying OCP #
function Hospital() {
this.patientDataExporters = [];
}
Hospital.prototype.registerPatientDataExporter = function (exporter) {
this.patientDataExporters.push(exporter);
};
Hospital.prototype.downloadPatientDataAllFormats = function (patient) {
for (const exporter of this.patientDataExporters) {
exporter.exportPatientData(patient);
}
};
const hospital = new Hospital();
// csvDataExporter and xmlDataExporter are instances of DataExporter class
hospital.registerPatientDataExporter(csvDataExporter);
hospital.registerPatientDataExporter(xmlDataExporter);
const patient = new Patient();
hospital.downloadPatientDataAllFormats(patient);
- With the above changes, we have achieved the extension without modifying the existing code.
- If there is a new requirement to support a new data format, we can just create an instance of the new data exporter type and register it with the
Hospital
class from outside of theHospital
class.
Can we predict everything about the future and design the class/abstraction? #
- Answer is no.
- It is not possible to foresee all future use cases. We might think about a large configuration object to handle all the future use cases. But it is not a good idea because it involves both cost and complexity.
- There should be a balance between possible future use cases and a focused abstraction that has some boundary or constraints.
- We can think about involving customers from the beginning to get feedback on the future use cases of the system that is being built so that we can design the abstraction accordingly. But again, it is not possible to predict everything about the future.
3. Liskov Substitution Principle (LSP) / Behavioral Subtyping #
"Subtype should behave like Supertype"
Let's introduce a DataExporter
class based on the patient's age and some config. We are only exporting patient data if the patient age is within the defined range.
// patient class
function Patient(age, nationality) {
this.age = age;
this.nationality = nationality;
}
// config class
function Config(minAge, maxAge, nationality) {
this.minAge = minAge;
this.maxAge = maxAge;
this.nationality = nationality;
}
function DataExporter(config) {
this.config = config;
}
DataExporter.prototype.isAllowed = function (patient) {
// only export patient data if the patient age is within the defined range
return patient.age > this.config.minAge && patient.age < this.config.maxAge;
};
DataExporter.prototype.exportPatientData = function (patient) {
if (this.isAllowed(patient)) {
// export logic goes here
}
};
const config = new Config(18, 60, "CANADA");
const patient = new Patient(20, "CANADA");
const dataExporter = new DataExporter(config);
dataExporter.exportPatientData(patient);
Now, let's imagine we want to export patient data based on the patient nationality. We may extend the DataExporter
class to support this new feature by simply changing the isAllowed
method shown as below:
function NationalityDataExporter(config) {
DataExporter.call(this, config);
}
Object.setPrototypeOf(
NationalityDataExporter.prototype,
DataExporter.prototype
);
NationalityDataExporter.prototype.isAllowed = function (patient) {
// only export patient data if the patient nationality matches
if (patient.nationality && config.nationality) {
return patient.nationality === this.config.nationality;
}
return false;
};
NationalityDataExporter.prototype.exportPatientData = function (patient) {
if (this.isAllowed(patient)) {
// export logic goes here
}
};
const canadaDataExporter = new NationalityDataExporter(config);
canadaDataExporter.exportPatientData(patient);
But with this change, the derived class, NationalityDataExporter
, is not semantically equivalent to its base class DataExporter
.
This completely changes the semantics of the base class.
"Liskov's notion of a behavioural subtype defines a notion of substitutability for objects; that is, if S is a subtype of T, then objects of type T in a program may be replaced with objects of type S without altering any of the desirable properties of that program (e.g. correctness)." - Wikipedia
In our example, let's assume T is DataExporter
class and S is NationalityDataExporter
class.
- S is a subtype of T.
- We cannot replace objects of type T with objects of type S. If we do that, the results would be unpredictable, since the consuming client assumes that a
DataExporter
class works on patient age, not on patient nationality. - Objects of T are not substitutable with objects of S.
NationalityDataExporter
is not a behaviour subtype ofDataExporter
. This violates behavioral subtyping.
Lastly, this principle applies only if you are dealing with inheritance.
4. Interface Segregation Principle (ISP) #
"No client should be forced to depend on methods that it does not use."
- Imagine the hospital administration is considering exporting doctor's data.
- But they want to export doctor's data only in CSV format and not in XML format.
- So, let's add a new method on the
DataExporter
which will be used to export doctor's data. Let's call this method asexportDoctorData
.
Before applying ISP #
function DataExporter() {}
DataExporter.prototype.exportPatientData = function (patient) {};
// newly added method
DataExporter.prototype.exportDoctorData = function (doctor) {};
What are the implications of the above changes? #
Both CsvDataExporter
and XmlDataExporter
classes will inherit exportDoctorData
method by default. But XmlDataExporter
class should not be exposed to doctor data as per the business requirement. This results in forcing unnecessary dependencies and creates confusion and maintenance issues.
How to solve this problem? #
We can use mixins to solve this problem. Mixin is a great way to add functionality to a class without using inheritance.
In the following example, we have created two mixins called PatientInterface
and DoctorInterface
.
After applying ISP #
// mixins
function PatientInterface() {
return {
exportPatientData(patient) {},
};
}
function DoctorInterface() {
return {
exportDoctorData(doctor) {},
};
}
// XML data exporter
function XmlDataExporter() {}
Object.assign(XmlDataExporter.prototype, PatientInterface());
XmlDataExporter.prototype.exportPatientData = function (patient) {};
// CSV data exporter
function CsvDataExporter() {}
Object.assign(CsvDataExporter.prototype, PatientInterface(), DoctorInterface());
CsvDataExporter.prototype.exportPatientData = function (patient) {};
CsvDataExporter.prototype.exportDoctorData = function (doctor) {};
const xmlDataExporter = new XmlDataExporter();
const csvDataExporter = new CsvDataExporter();
With the above changes, XmlDataExporter
class is not inheriting exportDoctorData
method by default.
5. Dependency Inversion Principle (DIP) #
"High-level modules should not depend on low-level modules. Both should depend on abstractions (that is, interfaces)."
Before applying DIP #
function Hospital() {
this.patients = [];
}
Hospital.prototype.getPatients = function () {
return this.patients;
};
Hospital.prototype.sendNotificationToAllPatients = function () {
const patients = this.getPatients();
patients.forEach((patient) => {
const notifier = new Notifier();
notifier.sendPatientNotification(patient);
});
};
- Let's add a new method called
sendNotificationToAllPatients
to theHospital
class. - In the above example, the
Hospital
class is tightly coupled withNotifier
class. - When I say tightly coupled, I mean we are instantiating the
Notifier
class inside theHospital
class inside the body ofsendNotificationToAllPatients
usingnew Notifier ()
. - Let's imagine
Notifier
class added some dependencies in itsconstructor
to add new features. In that case, we also need to change theHospital
class since it needs to provide those dependencies to theNotifier
class. - Internal implementation details of the
Notifier
class are exposed to theHospital
class. This also means theHospital
class is dependent on the implementation details of theNotifier
class.
After applying DIP #
function Hospital(notifier) {
this.notifier = notifier;
}
Hospital.prototype.getPatients = function () {
return this.patients;
};
Hospital.prototype.sendNotificationToAllPatients = function () {
const patients = this.getPatients();
patients.forEach((patient) => {
this.notifier.sendPatientNotification(patient);
});
};
// moved out the Notifier dependency from the Hospital class
// inject the Notifier instance through the constructor (Dependency Injection)
const hospital = new Hospital(new Notifier());
- In the above example, we moved out
new Notifier ()
from theHospital
class and injected the instance ofNotifier
class as a dependency through theconstructor
of theHospital
class from outside of it. - This has made
Hospital
class loosely coupled withNotifier
class.Hospital
class is not dependent on the implementation details ofNotifier
class anymore. Hospital
class just needs to know that it can call thesendPatientNotification
method on theNotifier
instance. This is the very minimal information that theHospital
class needs to know about theNotifier
class.- So, both
Hospital
andNotifier
classes are dependent on an abstraction which has a single method calledsendPatientNotification
. - In javaScript, we don't have interfaces to represent abstractions. So, we can add if/else checks to only call the
sendPatientNotification
method if it is available on theNotifier
instance and assume this instance is the correct behavioral instance (Duck Typing).
Summary #
In this blog post, we learned about the SOLID principles in JavaScript. We explored each principle with relevant examples. If you have any questions or feedback, please let me know in the comment section below.
References
- Mastering JavaScript Object-Oriented Programming: Andrea Chiarelli