Skip to main content

Command Palette

Search for a command to run...

SOLID principles with JavaScript

Explain solid principles using JavaScript and Typescript examples.

Updated
8 min read
SOLID principles with JavaScript
S

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() and setHeight()

  • 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 invariants

  • Ensured 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,

  1. The CourseController now does not depend on the CourseService directly

  2. Both the CourseService class (By implementing interface) and CourseController class (by setting interface as type of CourseService) depends on the Service interface

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();

Resources