Skip to content

SOLID

These are software design principles introduced by Robert C. Martin (Uncle Bob) meant to make software easier to maintain, extensible, testable, and reusable while adding the ability for collaboration in development. In this article we are going to dive into the FIVE principles Single Responsibility Principle, Open/Closed Principle, Liskov Substitution Principle, Interface Segregation Principle, and Depedency inversion Principle. We will be using Javascript in the examples.

Caveat

  • We will be using duck typing for interfaces as javascript does not have a native interface keyword

Single Responsibility Principle

This principle states that a class should have a single responsibility to change. If this principle is enforced it becomes easier to test classes in isolation and easily replace class implementation.

Example without SRP and with SRP

js
// WRONG APPROACH

// CLASS HAS 3 RESPONSIBILITIES 3 REASONS TO CHANGE VALIDATION
// SAVING TO DATABASE AND SENDING EMAIL
class UserService {
  register(user) {
    // 1. Validate
    if (!user.email.includes('@')) throw new Error('Invalid email');

    // 2. Save to database
    database.save(user);

    // 3. Send email
    emailService.sendWelcomeEmail(user.email);
  }
}

//=========================================================================

// FIX SRP – SINGLE RESPONSIBILITY PRINCIPLE

// EACH CLASS HAS ONLY ONE REASON TO CHANGE, ITS EASY TO TEST IN ISOLATION,
// AND ITS EASY TO REPLACE IMPLEMENTATIONS.
class UserValidator {
  static validate(user) {
    if (!user.email.includes('@')) throw new Error('Invalid email');
  }
}

class UserRepository {
  static save(user) {
    database.save(user);
  }
}

class EmailSender {
  static sendWelcomeEmail(email) {
    emailService.sendWelcomeEmail(email);
  }
}

class UserRegistrationService {
  static register(user) {
    UserValidator.validate(user);               // Only validation
    UserRepository.save(user);                  // Only persistence
    EmailSender.sendWelcomeEmail(user.email);   // Only email sending
  }
}

Open closed Principle

This principle states a class, module or function should be open for extension but closed for modification, thus we should extend the code instead of modifying it when requirements change.

Example with and without open/closed principle

js

// APPROACH WITHOUT OPEN CLOSED PRINCIPLE

// IF WE WANT TO ADD RECTANGLE OR CIRCLE WE MODIFY
// THE CLASS BUT WE SHOULD ONLY EXTEND IT. WE SHOULD
// ADD BEHAVIOUR WITHOUT CHANGING EXISTING CODE.

class AreaCalculator {
  calculate(shape) {
    if (shape.type === 'circle') {
      return Math.PI * shape.radius ** 2;
    }
  }
}

const circle = {
  type: 'circle',
  radius: 5
};
const calculator = new AreaCalculator();
console.log(calculator.calculate(circle)); // 78.53..

// --------------------------------------------------

// FIX - APPLY OPEN/CLOSED PRINCIPLE.

// The area() method in Shape defines a contract that all subclasses
// must implement their own area() method, thus consistency which
// enforces the Open/Closed Principle by letting subclasses extend behavior
// without modifying Shape, and preventing misuse by throwing an error if Shape is
// instantiated directly

class Shape {
  area() {
    throw new Error("Method 'area()' must be implemented.");
  }
}

class Circle extends Shape {
  constructor(radius) {
    super();
    this.radius = radius;
  }
  area() {
    return Math.PI * this.radius ** 2;
  }
}

class Triangle extends Shape {
  constructor(base, height) {
    super();
    this.base = base;
    this.height = height;
  }
  area() {
    return 0.5 * this.base * this.height;
  }
}

const shapes = [new Circle(5), new Triangle(3, 8)];
shapes.forEach(shape => console.log(shape.area()));

Liskov Substitution Principle

Liskov substitution principle states that objects for the super class can be interchanged with objects of the child class without breaking program behaviour.

Example with and without LSP

js

// Liskov substitution principle 

// --------- LSP Violation -----------

class ShapeBroken {
  render() {
    console.log(`Rendering shape with area: ${this.getArea()}`);
  }
}

class RectangleBroken extends ShapeBroken {
  constructor(width, height) {
    super();
    this.width = width;
    this.height = height;
  }
  getArea() {
    return this.width * this.height;
  }
}

// Broken subclass
class SquareBroken extends ShapeBroken {
  constructor(length) {
    super();
    this.length = length;
  }
  // Forgot to implement getArea()
}

function renderShapesBroken(shapes) {
  shapes.forEach(shape => shape.render());
}

const shapesBroken = [new RectangleBroken(4, 5), new SquareBroken(5)];
renderShapesBroken(shapesBroken);
// Error: getArea is not defined

// ---------------------------------------------------------------------

// // FIX - LISKOV SUBSTITUTION PRINCIPLE (LSP) - A PARENT CLASS AND A CHILD
// // CLASS, CAN BE USED INTERCHANGEABLY GIVING CORRECT RESULTS.
// // The forEach loop only knows about the base class Shape. It
// // doesn't care whether shape is a Rectangle or a Square. Each
// // subclass correctly implements getArea(), so render() works for all shapes.

class Shape {
  getArea() {
    throw new Error("getArea must be implemented");
  }
  render() {
    console.log(`Rendering shape with area: ${this.getArea()}`);
  }
}

class Rectangle extends Shape {
  constructor(width, height) {
    super();
    this.width = width;
    this.height = height;
  }
  getArea() {
    return this.width * this.height;
  }
}

class Square extends Shape {
  constructor(length) {
    super();
    this.length = length;
  }
  getArea() {
    return this.length ** 2;
  }
}

const shapes = [new Rectangle(4, 5), new Square(5)];
shapes.forEach(shape => shape.render()); // Works for all shapes

Interface Segregation Principle

Interface Segregation Principle states that classes should not be forced to depend on methods they do not use. This results in lean interfaces which are easier to understand and at the same time avoids bloated code as classes doesn't have to implement unused methods.

Example with and withot Interface Segregation Principle

js

// Example that violates Interface Segregation Principle
// Ostrich is forced to implement fly which it does not do.
class Bird {
  fly() {}
  swim() {}
}

class DuckBroken extends Bird {
  fly() {
    console.log("Duck flying");
  }
  swim() {
    console.log("Duck swimming");
  }
}

class OstrichBroken extends Bird {
  fly() {
    console.log("Ostrich flying");
  } // Ostrich can't fly!
  swim() {
    console.log("Ostrich swimming");
  }
}

const ostrichBroken = new OstrichBroken();
ostrichBroken.fly(); // incorrect! Ostrich cannot fly

// ----------------------------------------------------

// FIX - Interface Segregation Principle
// Objects implement only interface they need
class IFlyable {
  fly() {}
}

class ISwimmable {
  swim() {}
}

// Duck implements only the interfaces it needs
class Duck extends IFlyable {
  constructor() {
    super();
    Object.assign(this, new ISwimmable());
  }

  fly() {
    console.log("Duck flying");
  }
  swim() {
    console.log("Duck swimming");
  }
}

// Ostrich implements only what it can do
class Ostrich extends ISwimmable {
  // Ostrich can only swim, no fly method needed
  swim() {
    console.log("Ostrich swimming");
  }
}

// Usage
const duck = new Duck();
duck.fly(); // Duck flying
duck.swim(); // Duck swimming

const ostrich = new Ostrich();
ostrich.swim(); // Ostrich swimming

Dependency Inversion Principle

Dependency Inversion Principle states that high level modules should not depend on low level modules as both should depend on abstractions. Also abstractions should depend on details and details should depend on abstractions. High level modules decides what to do Business logic an example would be OrderService.placeOrder(order) while low level modules handles how to do it technical detail an example is MySQLRepository.save(order);

Example with and without Dependency Inversion Principle

js

// THIS EXAMPLE VIOLATES DEPENDENCY INVERSION PRINCIPLE

// High-level module depends directly on low-level modules
// NotificationService depends on EmailSenderBroken
// and SMSSenderBroken. Adding a new sender (e.g., PushNotification)
// requires modifying NotificationServiceBroken. High-level module
// depends on low-level modules.

class NotificationServiceBroken {
  sendMessage(message) {
    // Directly creates low-level objects -> violation of DIP
    const email = new EmailSenderBroken();
    email.send(message);

    const sms = new SMSSenderBroken();
    sms.send(message);
  }
}

// Low-level modules
class EmailSenderBroken {
  send(msg) {
    console.log(`Sending Email: ${msg}`);
  }
}

class SMSSenderBroken {
  send(msg) {
    console.log(`Sending SMS: ${msg}`);
  }
}

// Usage
const brokenService = new NotificationServiceBroken();
brokenService.sendMessage("Hello World!");
// High-level depends on low-level details
------------------------------------------
// FIX - DEPENDENCY INVERSION PRINCIPLE

// (NotificationService) depends on the IMessageSender abstraction rather
// than concrete classes. While low-level modules (EmailSender, SMSSender)
// implement the same abstraction. As a result, both high-level and low-level
// modules depend on abstractions, concrete details depend on abstractions,
// and new senders can be added without modifying the high-level module, fully
// satisfying the Dependency Inversion Principle.

// Abstraction
class IMessageSender {
  send(msg) {
    throw new Error("Method 'send' must be implemented.");
  }
}

// Low-level modules depend on abstraction
class EmailSender extends IMessageSender {
  send(msg) {
    console.log(`Sending Email: ${msg}`);
  }
}

class SMSSender extends IMessageSender {
  send(msg) {
    console.log(`Sending SMS: ${msg}`);
  }
}

// High-level module depends on abstraction, not concrete implementations
class NotificationService {
  constructor(senders) {
    this.senders = senders; // array of IMessageSender
  }

  sendMessage(msg) {
    // Works with any object implementing IMessageSender
    this.senders.forEach(sender => sender.send(msg));
  }
}

// Usage
const email = new EmailSender();
const sms = new SMSSender();

const service = new NotificationService([email, sms]);

service.sendMessage("Hello DIP!");
// High-level depends on abstraction, low-level depends on abstraction

javascript book

If this interested you, check out my Javascript Book

Enjoy every byte.