SOLID principles with JavaScript
Explain solid principles using JavaScript and Typescript examples.

A lifelong learner. Love to travel. Listen to music.
SOLID
SOLID stands for 5 of the following principles,
Single Responsibility Principle
Open-Closed Principle
Liskov Substitution Principle
Interface Segregation Principle
Dependency Inversion Principle
Single Responsibility Principle
The Single Responsibility Principle implies,
- There should be one single reason to change a module
When violating the single responsibility principle,
// Breaking SRP by combining data and display responsibilities
class BandManager {
constructor() {
this.bands = [];
}
// Data management responsibilities
addBand(bandName) {
this.bands.push(bandName);
}
removeBand(bandName) {
this.bands = this.bands.filter(band => band !== bandName);
}
// Display responsibilities
displayBands() {
console.log("Current lineup:");
this.bands.forEach(band => {
console.log(`- ${band}`);
});
}
displayBandsAsTable() {
console.log("Band Lineup Table:");
console.log("------------------");
this.bands.forEach((band, index) => {
console.log(`${index + 1}. ${band}`);
});
console.log("------------------");
}
printBandsToFile(filename) {
console.log(`Saving band list to ${filename}...`);
// Code to write to file would go here
}
}
// Usage
const concertLineup = new BandManager();
concertLineup.addBand("Metallica");
concertLineup.addBand("Iron Maiden");
concertLineup.displayBands();
concertLineup.displayBandsAsTable();
Here the BandManager has two reasons to change,
Data management
Display management
A better approach using single responsibility principles is,
// Only responsible for band data management
class BandCollection {
constructor() {
this.bands = [];
}
addBand(bandName) {
this.bands.push(bandName);
}
removeBand(bandName) {
this.bands = this.bands.filter(band => band !== bandName);
}
getAllBands() {
return [...this.bands]; // Return a copy to prevent external modification
}
}
// Only responsible for displaying band information
class BandDisplay {
displayAsList(bands) {
console.log("Current lineup:");
bands.forEach(band => {
console.log(`- ${band}`);
});
}
displayAsTable(bands) {
console.log("Band Lineup Table:");
console.log("------------------");
bands.forEach((band, index) => {
console.log(`${index + 1}. ${band}`);
});
console.log("------------------");
}
}
// Only responsible for file operations
class FileService {
saveBandsToFile(bands, filename) {
console.log(`Saving band list to ${filename}...`);
// Code to write to file would go here
}
}
// Usage
const bandCollection = new BandCollection();
const display = new BandDisplay();
const fileService = new FileService();
bandCollection.addBand("Metallica");
bandCollection.addBand("Iron Maiden");
// Get the data from one class and display it using another
const bands = bandCollection.getAllBands();
display.displayAsList(bands);
display.displayAsTable(bands);
fileService.saveBandsToFile(bands, "concert_lineup.txt");
Here we isolate the logging in a separate module. Any time our logging mechanism is being changed, we can simply update the Logger class.
Open Closed Principle
Open closed principle implies that,
- A module should be open for extension but closed for modification
Consider the following code, if we update another payment, we need to add another switch case. This modifies the existing class PaymentProcessor.
class PaymentAdapter {
makePayment() {
throw new Error('makePayment() must be implemented');
}
}
class StripePayment extends PaymentAdapter {
makePayment() {
console.log('Payment made using Stripe');
}
}
class PaypalPayment extends PaymentAdapter {
makePayment() {
console.log('Payment made using PayPal');
}
}
class PaymentProcessor {
constructor(paymentAdapter) {
this.paymentAdapter = paymentAdapter;
}
pay(type) {
switch (type) {
case 'stripe':
new StripePayment().makePayment();
break;
case 'paypal':
new PaypalPayment().makePayment();
break;
default:
throw new Error('Payment type not supported');
}
}
}
new PaymentProcessor().pay('stripe');
new PaymentProcessor().pay('paypal');
Instead, we can use the following way, where any time, we need a new payment adapter, we can pass it to the PaymentProcessor, with no need to update the class.
class PaymentAdapter {
makePayment() {
throw new Error('makePayment() must be implemented');
}
}
class StripePayment extends PaymentAdapter {
makePayment() {
console.log('Payment made using Stripe');
}
}
class PaypalPayment extends PaymentAdapter {
makePayment() {
console.log('Payment made using PayPal');
}
}
class PaymentProcessor {
constructor(paymentAdapter) {
this.paymentAdapter = paymentAdapter;
}
pay() {
this.paymentAdapter.makePayment();
}
}
const stripePayment = new StripePayment();
const paypalPayment = new PaypalPayment();
new PaymentProcessor(stripePayment).pay();
new PaymentProcessor(paypalPayment).pay();
Liskov Substitution Principle
Liskov Substitution Principle implies that,
- Objects of the superclass should behave like objects of the subclass, they should be replaceable
Consider the following code, which violates the Liskov Substitution Principle.
// Base class
class Rectangle {
constructor(width, height) {
this.width = width;
this.height = height;
}
setWidth(width) {
this.width = width;
}
setHeight(height) {
this.height = height;
}
getArea() {
return this.width * this.height;
}
}
// Subclass that violates LSP
class Square extends Rectangle {
constructor(size) {
super(size, size);
}
// LSP VIOLATION: Changes behavior of parent method
setWidth(width) {
this.width = width;
this.height = width; // Also changes height!
}
// LSP VIOLATION: Changes behavior of parent method
setHeight(height) {
this.width = height; // Also changes width!
this.height = height;
}
}
// Client code - will break with Square
function testRectangle(rectangle) {
rectangle.setWidth(5);
rectangle.setHeight(4);
// Expects area to be 20, but with Square it will be 16!
if (rectangle.getArea() !== 20) {
console.log("LSP violation detected!");
}
}
const rect = new Rectangle(3, 3);
testRectangle(rect); // Works as expected
const square = new Square(3);
testRectangle(square); // Fails - LSP violation!
The code violates the principles,
A square is forced to inherit from a Rectangle despite having different invariants
The Square subclass changes the behavior of the parent methods
setWidth()andsetHeight()When setting width or height, Square modifies both dimensions to maintain its "squareness"
Client code expecting independent width and height modification will break with a Square
The postcondition of the Rectangle methods (that only one dimension changes) is violated by the Square
We can fix the following to align with the principles,
Created a common base class (Shape) with only truly shared behavior
Removed the inheritance relationship between Rectangle and Square
Made both Rectangle and Square implement the Shape interface independently
Gave Square its appropriate method (
setSize()) that aligns with its invariantsEnsured each class has methods that match its constraints and behavior
Client code now works with each shape according to its actual capabilities
The rewritten version aligns with the Liskov Substitution Principle:
// Base shape interface (could be a class)
class Shape {
getArea() {}
}
// Specific implementations
class Rectangle extends Shape {
constructor(width, height) {
super();
this.width = width;
this.height = height;
}
setWidth(width) {
this.width = width;
}
setHeight(height) {
this.height = height;
}
getArea() {
return this.width * this.height;
}
}
class Square extends Shape {
constructor(size) {
super();
this.size = size;
}
setSize(size) {
this.size = size;
}
getArea() {
return this.size * this.size;
}
}
// Client code is now shape-specific
function testRectangle(rectangle) {
if (!(rectangle instanceof Rectangle)) {
throw new Error("Rectangle expected");
}
rectangle.setWidth(5);
rectangle.setHeight(4);
console.log("Area:", rectangle.getArea()); // Always 20
}
function testSquare(square) {
if (!(square instanceof Square)) {
throw new Error("Square expected");
}
square.setSize(4);
console.log("Area:", square.getArea()); // Always 16
}
const rect = new Rectangle(3, 3);
testRectangle(rect);
const square = new Square(3);
testSquare(square);
Interface Segregation Principle
The Interface Segregation Principle implies that an interface should not have any properties that are not used or required by the class.
In the following example, we have a Shape interface that is implemented in the Square class and Cube class. We can calculate the volume for a cube, not for a square. So when we implement Shape for the Square class, it throws an error for the volume method.
interface Shape {
area: () => void;
volume: () => void;
}
class Square implements Shape {
height: number;
width: number;
constructor(height: number) {
this.height = height;
this.width = height;
}
area () {
console.log(this.height * this.width);
}
volume() {
throw new Error('Volume can not valculated on 2d shape');
}
}
class Cube implements Shape {
height: number;
width: number;
length: number;
constructor(height: number, width: number, length: number) {
this.height = height;
this.width = height;
this.length = length;
}
area () {
console.log(this.height * this.width);
}
volume () {
console.log(this.height * this.width * this.length);
}
}
const square = new Square(5);
square.area();
square.volume();
const cube = new Cube(5, 6, 7);
cube.area();
cube.volume();
To resolve the issue, we can introduce an extended interface Shape3D. This new interface will have the volume method and Will be used when creating the Cube class.
interface Shape {
area: () => void;
}
interface Shape3D extends Shape {
volume: () => void;
}
class Square implements Shape {
height: number;
width: number;
constructor(height: number) {
this.height = height;
this.width = height;
}
area () {
console.log(this.height * this.width);
}
}
class Cube implements Shape3D {
height: number;
width: number;
length: number;
constructor(height: number, width: number, length: number) {
this.height = height;
this.width = height;
this.length = length;
}
area () {
console.log(this.height * this.width);
}
volume () {
console.log(this.height * this.width * this.length);
}
}
const square = new Square(5);
square.area();
const cube = new Cube(5, 6, 7);
cube.area();
cube.volume();
Dependency Inversion Principle
The dependency Inversion Principle implies,
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
In the following code, we are making CourseService class according to the CourseController. Here the Courservice should not depend on CourseController, instead, both should depend on the interface.
class CourseService {
get() {
console.log('All the courses');
}
}
class CourseController {
constructor() {
this.courseService = new courseService(); // direct dependency
}
getAllCourse() {
this.courseService.get();
}
}
const courseController = new CourseController();
courseController.getAllCourse();
To resolve this, we need a service to which the CourseService can depend on an abstract.
So,
The
CourseControllernow does not depend on theCourseServicedirectlyBoth the
CourseServiceclass (By implementing interface) andCourseControllerclass (by settinginterfaceas type ofCourseService) depends on theServiceinterface
interface Service {
get: () => void;
}
// Low level module depends on the abstraction
class CourseService implements Service {
get() {
console.log('All the courses');
}
}
class CourseController {
courseService: Service; // High level module depends on the abstraction
constructor(courseService: Service) {
this.courseService = courseService; // No direct dependency
}
getAllCourse() {
this.courseService.get();
}
}
const courseService = new CourseService();
const courseController = new CourseController(courseService);
courseController.getAllCourse();




