SOLID Design Principles: A Simplified Explanation

SOLID Principles guide developers in creating well-structured software. Introduced by Robert C. Martin, they're essential for robust object-oriented design.

Solid Design Principles

Introduction

When it comes to building robust and maintainable software, the SOLID design principles are a beacon for developers. Conceived by Robert C. Martin, these principles offer a framework for object-oriented design that emphasizes readability, scalability, and maintainability. Let’s dive into a simple breakdown of these game-changing concepts.

S: Single Responsibility Principle (SRP)

Single Responsibility Principle

Picture a Swiss Army knife: it’s a single tool that can perform many tasks like cutting, opening bottles, and even screwing in screws. While it’s versatile, each part of the knife has only one main function. The blade is only for cutting, the corkscrew only for opening wine, and so on. This is similar to the Single Responsibility Principle ( SRP) in software development.

The SRP suggests that just like each tool on a Swiss Army knife is dedicated to a specific task, each class in your code should have only one reason to change, meaning it should have only one job or responsibility. For example, if you have a class that handles user information in an application, it should only deal with user information, not other things like sending emails or logging data.

Why is this useful? Think about if a tool on your Swiss Army knife broke. If each tool is separate, you only need to fix that one part. Similarly, in coding, if a class has only one responsibility, it’s much easier to maintain and update it without affecting other parts of your application. This leads to cleaner, more organized code that’s easier to understand and less prone to bugs.

In summary, the Single Responsibility Principle encourages writing classes that are focused and dedicated to doing one thing well, much like each tool on a Swiss Army knife.

Bad example: A class with multiple responsibilities

interface Device {
  display(data: string): void;
}

class WindowsConsole implements Device {
  display(text: string): void {
    console.log(text);
  }
}

class Book {
  private bookName: string;
  private authorName: string;
  private content: string;

  constructor(bookName: string, authorName: string, content: string) {
    this.bookName = bookName;
    this.authorName = authorName;
    this.content = content;
  }

  getBookName(): string {
    return this.bookName;
  }

  setBookName(bookName: string): void {
    this.bookName = bookName;
  }

  getAuthorName(): string {
    return this.authorName;
  }

  setAuthorName(authorName: string): void {
    this.authorName = authorName;
  }

  getContent(): string {
    return this.content;
  }

  setContent(content: string): void {
    this.content = content;
  }

  // Bad example: A class with multiple responsibilities
  show(device: Device): void {
    const text = `Name: ${this.bookName}, author: ${this.authorName}, content: ${this.content}`;
    device.display(text);
  }
}

// Usage
const book = new Book("Book Name", "Author Name", "text text text");
const device = new WindowsConsole();
book.show(device);

Good example: Separating responsibilities into different classes

interface Device {
  display(data: string): void;
}

class WindowsConsole implements Device {
  display(text: string): void {
    console.log(text);
  }
}

class BookDisplay {
  show(book: Book, device: Device): void {
    const text = `Name: ${book.getBookName()}, author: ${book.getAuthorName()}, content: ${book.getContent()}`;
    device.display(text);
  }
}

class Book {
  private bookName: string;
  private authorName: string;
  private content: string;

  constructor(bookName: string, authorName: string, content: string) {
    this.bookName = bookName;
    this.authorName = authorName;
    this.content = content;
  }

  getBookName(): string {
    return this.bookName;
  }

  setBookName(bookName: string): void {
    this.bookName = bookName;
  }

  getAuthorName(): string {
    return this.authorName;
  }

  setAuthorName(authorName: string): void {
    this.authorName = authorName;
  }

  getContent(): string {
    return this.content;
  }

  setContent(content: string): void {
    this.content = content;
  }
}

// Usage
const book = new Book("Book Name", "Author Name", "text text text");
const device: Device = new WindowsConsole();
const bookDisplay = new BookDisplay();
bookDisplay.show(book, device);

O: Open/Closed Principle

Open/Closed Principle

Think of a LEGO set. You can add new pieces to create something new, but you don’t alter the existing pieces. Similarly, the Open/Closed Principle suggests that classes should be open for extension but closed for modification. This approach allows you to introduce new functionalities without disrupting the existing code.

Bad example: A class that is not open for extension and closed for modification

enum ReportingType {
  CSV = "CSV",
  XML = "XML",
  EXCEL = "EXCEL"
}

class ReportingService {
  private typeReport: ReportingType;

  constructor(typeReport: ReportingType) {
    this.typeReport = typeReport;
  }

  getTypeReport(): ReportingType {
    return this.typeReport;
  }

  setTypeReport(typeReport: ReportingType): void {
    this.typeReport = typeReport;
  }

  generateReportBasedOnType(reportingType: ReportingType): void {
    if (reportingType === ReportingType.CSV) {
      this.generateCSVReport();
    } else if (reportingType === ReportingType.XML) {
      this.generateXMLReport();
    }
  }

  private generateCSVReport(): void {
    console.log("Generate CSV Report");
  }

  private generateXMLReport(): void {
    console.log("Generate XML Report");
  }
}

// Usage
const rs = new ReportingService(ReportingType.CSV);

// Generate CSV report
rs.generateReportBasedOnType(ReportingType.CSV);

// Generate XML report
rs.generateReportBasedOnType(ReportingType.XML);

Good example: A class that is open for extension and closed for modification

interface Reporting {
  generateReport(): void;
}

class XMLReporting implements Reporting {
  generateReport(): void {
    console.log("Generate XML Report");
  }
}

class CSVReporting implements Reporting {
  generateReport(): void {
    console.log("Generate CSV Report");
  }
}

class ReportingService {
  generateReportBasedOnStrategy(reportingStrategy: Reporting): void {
    reportingStrategy.generateReport();
    console.log();
  }
}

// Usage
const rs = new ReportingService();

// Generate CSV report
const csvReportingStrategy: Reporting = new CSVReporting();
rs.generateReportBasedOnStrategy(csvReportingStrategy);

// Generate XML report
const xmlReportingStrategy: Reporting = new XMLReporting();
rs.generateReportBasedOnStrategy(xmlReportingStrategy);

L: Liskov Substitution Principle

Liskov Substitution Principle

Think of the Liskov Substitution Principle (LSP) like a reliable coffee machine in your favorite café. No matter what model of machine they use (whether it’s the latest or an older one), you expect it to make your usual coffee just as well. In programming, LSP ensures something similar with classes and their subclasses.

In more technical terms, the LSP says that objects of a superclass should be replaceable with objects of its subclasses without altering how the program works. This means if you have a class (like the coffee machine) and a subclass (a specific model of the machine), you should be able to use the subclass in place of the superclass without any issues.

For example, let’s say you have a class called Bird that has a method fly(). If you have a subclass called Sparrow that extends Bird, you should be able to use Sparrow wherever Bird is expected, without any problem. If Sparrow cannot perform the fly() function properly, it violates the * LSP*. This is like having a specific model of coffee machine that doesn’t make coffee as expected.

Adhering to the LSP helps in creating flexible and maintainable code, where subclasses can be easily interchanged without causing errors or unexpected behavior in the application.

Bad example: External processes will either break, behave improperly, or need to know too much information.

abstract class Boiler {
  private currentTemperature: number;
  private desirableTemperature: number;

  constructor(currentTemperature: number, desirableTemperature: number) {
    this.currentTemperature = currentTemperature;
    this.desirableTemperature = desirableTemperature;
  }

  public setDesirableTemperature(temp: number): void {
    this.desirableTemperature = temp;
  }

  public getDesirableTemperature(): number {
    return this.desirableTemperature;
  }

  public getCurrentTemperature(): number {
    return this.currentTemperature;
  }

  public setCurrentTemperature(currentTemperature: number): void {
    this.currentTemperature = currentTemperature;
  }

  public abstract heat(): void;
}

class BoilerA extends Boiler {
  constructor(temperature: number, desirableTemperature: number) {
    super(temperature, desirableTemperature);
  }

  public heat(): void {
    this.setCurrentTemperature(this.getCurrentTemperature() + 1);
  }
}

class BoilerB extends Boiler {
  constructor(temperature: number, desirableTemperature: number) {
    super(temperature, desirableTemperature);
  }

  public heat(): void {
    this.setCurrentTemperature(this.getCurrentTemperature() + 2);
  }
}

class BoilerC extends Boiler {
  private desirableTemperature: number = 0;

  constructor(temperature: number, desirableTemperature: number) {
    super(temperature, desirableTemperature);
  }

  public setDesirableTemperature(temp: number): void {
    this.desirableTemperature = temp;
  }

  public getDesirableTemperature(): number {
    return this.desirableTemperature;
  }

  public heat(): void {
    this.setCurrentTemperature(this.getCurrentTemperature() + 3);
  }
}

interface TemperatureController {
  setDevice(boiler: Boiler): void;

  getWaterTemperature(): number;

  heatWater(): void;
}

class AutomaticTemperatureController implements TemperatureController {
  private boiler: Boiler;

  public setDevice(boiler: Boiler): void {
    this.boiler = boiler;
  }

  public getWaterTemperature(): number {
    return this.boiler.getCurrentTemperature();
  }

  public heatWater(): void {
    this.boiler.heat();
  }
}

class ManualTemperatureController implements TemperatureController {
  private boiler: Boiler;

  public setDevice(boiler: Boiler): void {
    this.boiler = boiler;
  }

  public getWaterTemperature(): number {
    return this.boiler.getCurrentTemperature();
  }

  public heatWater(): void {
    this.boiler.heat();
  }
}

// Usage
const controller1: TemperatureController = new ManualTemperatureController();
const boilerA: BoilerA = new BoilerA(0, 37);
controller1.setDevice(boilerA);
controller1.heatWater();

console.log("Desirable Temperature: " + boilerA.getDesirableTemperature());
console.log("Temperature: " + controller1.getWaterTemperature());

const controller2: TemperatureController = new ManualTemperatureController();
const boilerB: BoilerB = new BoilerB(5, 40);
controller2.setDevice(boilerB);
controller2.heatWater();

console.log("Desirable Temperature: " + boilerB.getDesirableTemperature());
console.log("Temperature: " + controller2.getWaterTemperature());

/**
 * Violating Liskov Substitution Principle (Desirable Temperature is 0-zero, boiler will not heat water).
 *
 * @description:
 * If you don't follow the LSP, external processes will either break, behave improperly, or need to know too much information.
 */
const controller3: TemperatureController = new AutomaticTemperatureController();
const boilerC: BoilerC = new BoilerC(3, 39);
controller3.setDevice(boilerC);
console.log("Desirable Temperature: " + boilerC.getDesirableTemperature());
console.log("Temperature: " + controller3.getWaterTemperature());

Good example

abstract class Boiler {
  private currentTemperature: number
  private desirableTemperature: number

  constructor(currentTemperature: number, desirableTemperature: number) {
    this.currentTemperature = currentTemperature
    this.desirableTemperature = desirableTemperature
  }

  public setDesirableTemperature(temp: number): void {
    this.desirableTemperature = temp
  }

  public getDesirableTemperature(): number {
    return this.desirableTemperature
  }

  public getCurrentTemperature(): number {
    return this.currentTemperature
  }

  public setCurrentTemperature(currentTemperature: number): void {
    this.currentTemperature = currentTemperature
  }

  public abstract heat(): void
}

class BoilerA extends Boiler {
  constructor(temperature: number, desirableTemperature: number) {
    super(temperature, desirableTemperature)
  }

  public heat(): void {
    this.setCurrentTemperature(this.getCurrentTemperature() + 1)
  }
}

class BoilerB extends Boiler {
  constructor(temperature: number, desirableTemperature: number) {
    super(temperature, desirableTemperature)
  }

  public heat(): void {
    this.setCurrentTemperature(this.getCurrentTemperature() + 2)
  }
}

class BoilerC extends Boiler {
  constructor(temperature: number, desirableTemperature: number) {
    super(temperature, desirableTemperature)
  }

  public heat(): void {
    this.setCurrentTemperature(this.getCurrentTemperature() + 3)
  }
}

interface TemperatureController {
  setDevice(boiler: Boiler): void

  getWaterTemperature(): number

  heatWater(): void
}

class ManualTemperatureController implements TemperatureController {
  private boiler: Boiler

  public setDevice(boiler: Boiler): void {
    this.boiler = boiler
  }

  public getWaterTemperature(): number {
    return this.boiler.getCurrentTemperature()
  }

  public heatWater(): void {
    this.boiler.heat()
  }
}

class AutomaticTemperatureController implements TemperatureController {
  private boiler: Boiler

  public setDevice(boiler: Boiler): void {
    this.boiler = boiler
  }

  public getWaterTemperature(): number {
    return this.boiler.getCurrentTemperature()
  }

  public heatWater(): void {
    this.boiler.heat()
  }
}

// Usage
const controller1: TemperatureController = new ManualTemperatureController()
const boilerA: BoilerA = new BoilerA(0, 37)
controller1.setDevice(boilerA)
controller1.heatWater()

console.log('Desirable Temperature: ' + boilerA.getDesirableTemperature())
console.log('Temperature: ' + controller1.getWaterTemperature())

const controller2: TemperatureController = new ManualTemperatureController()
const boilerB: BoilerB = new BoilerB(5, 40)
controller2.setDevice(boilerB)
controller2.heatWater()

console.log('Desirable Temperature: ' + boilerB.getDesirableTemperature())
console.log('Temperature: ' + controller2.getWaterTemperature())

const controller3: TemperatureController = new AutomaticTemperatureController()
const boilerC: BoilerC = new BoilerC(3, 39)
controller3.setDevice(boilerC)

console.log('Desirable Temperature: ' + boilerC.getDesirableTemperature())
console.log('Temperature: ' + controller3.getWaterTemperature())

I: Interface Segregation Principle

Interface Segregation Principle

Imagine you’re at a buffet with a huge variety of dishes. Now, you wouldn’t want to fill your plate with everything, right? You’d prefer to choose only what you like or need. The Interface Segregation Principle (ISP) is similar to this concept in programming.

In programming, an ‘interface’ is like a menu of functions that a class can perform. The Interface Segregation Principle says that instead of having one big interface (or menu) with many methods (or dishes), it’s better to create smaller, more specific interfaces. This way, a class (like you at the buffet) can pick and choose which specific functionalities (dishes) it needs, without being burdened by the ones it doesn’t use.

For example, if you have a multifunction printer, you might have one interface for printing, another for scanning, and yet another for faxing. A basic printer class would implement only the printing interface, while a multifunctional one might implement all three. This way, the basic printer isn’t forced to include code for scanning and faxing that it will never use.

By following the ISP, you make sure your classes aren’t overwhelmed with unnecessary functions, making your code cleaner, more understandable, and easier to manage.

Bad example: A large interface that includes unnecessary methods

interface Animal {
  eat(): void;

  sleep(): void;

  fly(): void;

  swim(): void;
}

class Bird implements Animal {
  public eat(): void {
    // Implementation...
  }

  public sleep(): void {
    // Implementation...
  }

  public fly(): void {
    // Implementation...
  }

  public swim(): void {
    // Implementation...
  }
}

Good example: Smaller interfaces that are more specific to each client's needs

interface IEatable {
  eat(): void;
}

interface ISleepable {
  sleep(): void;
}

interface IFlyable {
  fly(): void;
}

class Bird implements IEatable, ISleepable, IFlyable {
  public eat(): void {
    // Implementation...
  }

  public sleep(): void {
    // Implementation...
  }

  public fly(): void {
    // Implementation...
  }
}

D: Dependency Inversion Principle

Dependency Inversion Principle

Imagine using a music app on your phone. You can plug in different headphones and still listen to your music, whether they are simple earbuds or high-end noise-cancelling headphones. The Dependency Inversion Principle (DIP) in programming works similarly.

In DIP, your main program (like the music app) is designed to be compatible with various components (like different headphones). You achieve this not by directly binding your code to a specific component, but by using an interface (like the headphone jack), which allows for flexibility.

This way, you can easily change or upgrade parts of your code (switch headphones) without needing to alter your main program (the music app). DIP is about creating such adaptable connections in your code, leading to a system that’s easy to modify and update.

Bad example: High-level module depending on low-level module

enum MailerType {
  SMTP,
  SEND_GRID
}

class SendGridMailer {
  send(): void {
    console.log("Send: SendGridMailer");
  }
}

class SmtpMailer {
  send(): void {
    console.log("Send: SmtpMailer");
  }
}

class MailerService {
  private smtpMailer: SmtpMailer;
  private sendGridMailer: SendGridMailer;

  constructor() {
    this.smtpMailer = new SmtpMailer();
    this.sendGridMailer = new SendGridMailer();
  }

  send(mailerType: MailerType): void {
    if (mailerType === MailerType.SMTP) {
      this.smtpMailer.send();
    } else if (mailerType === MailerType.SEND_GRID) {
      this.sendGridMailer.send();
    }
  }
}

const mailerService = new MailerService();
mailerService.send(MailerType.SMTP);
mailerService.send(MailerType.SEND_GRID);

Good example: Both high-level module and low-level module depending on abstraction

interface Mailer {
  send(): void;
}

class SendGridMailer implements Mailer {
  send(): void {
    console.log("Send: SendGridMailer");
  }
}

class SmtpMailer implements Mailer {
  send(): void {
    console.log("Send: SmtpMailer");
  }
}

class MailerService {
  private mailer: Mailer;

  constructor(mailer: Mailer) {
    this.mailer = mailer;
  }

  send(): void {
    this.mailer.send();
  }
}

const smtpMailer: Mailer = new SmtpMailer();
const mailerService: MailerService = new MailerService(smtpMailer);
mailerService.send();

Conclusion

Embracing the SOLID principles can transform your approach to coding, leading to software that’s not just functional, but also clean and manageable. While these concepts might seem overwhelming at first, their practical application can greatly enhance the quality and maintainability of your code. As you continue your journey in software development, let these principles guide you towards becoming a more efficient and skilled programmer.

We would love to help your company grow!