What are the SOLID principles?

What are the SOLID principles?

Timo
Timo

If we want to have good software, the infrastructure should be good first. We should learn more techniques that help to have better quality. Firstly, we have to know SOLID principles.

What are the SOLID principles?

SOLID principles are object-oriented design concepts relevant to software development. SOLID is an acronym for five other class-design principles. SOLID helps developers to write code that is easy to read, easy to maintain, and easy to understand.

Those 5 principles include:

  • Single responsibility principle (SRP)
  • Open/Closed principle (OCP)
  • Liskov substitution principle (LSP)
  • Interface segregation principle (ISP)
  • Dependency inversion principle (DIP)

Single responsibility principle

Each class should be responsible for a single part or functionality of the system.

We have an example that violates this principle like the code below:

export class DbConnection {
  connect(): Promise<DataSource> {
    try {
      const con = new DataSource({});

      return Promise.resolve(con);
    } catch (error) {
      winston.createLogger({
        level: 'debug',
        format: ...,
        transports: []
      });

      return Promise.reject(error);
    }
  }
}

This is violated because DbConnection class both connects to DB and writes an error log.

To avoid violating this principle, we need to separate into 2 classes DbConnection and Logger.

export class DbConnection {
  private Logger _logger;

  constructor() {
    super();

    this._logger = new Logger();
  }

  connect(): Promise<DataSource> {
    try {
      const con = new DataSource({});

      return Promise.resolve(con);
    } catch (error) {
      this._logger.writeError(error.message);

      return Promise.reject(error);
    }
  }
}

export class Logger {
  void writeError(message: string) {
    winston.createLogger({
      level: 'debug',
      format: ...,
      transports: []
    });
  }
}

Open/Closed principle

Software components should be open for extension, but not for modification.

To make it easier to understand, let's go through an example:

export class Staff {
  calculateTask(staff: StaffEntity) {
    const baseTask = this.getBaseTask(staff);

    switch(staff.position) {
      case 'developer':
        return baseTask * 1.5;
      case 'product owner':
        return baseTask * 1.8;
      default:
        return baseTask;
    }
  }
}

Suppose we now want to add another subclass called 'solution architecture'. We would have to modify the above class by adding another switch case statement, which goes against the Open-Closed Principle.

A better approach would be for the subclasses Developer, ProductOwner, and SolutionArchitecture to override the calculateSalary method:

export class Staff {
  calculateTask(staff: StaffEntity) {
    return this.getBaseTask(staff);
    }
  }
}

export class Developer extends Staff {
  calculateTask(staff: StaffEntity) {
    const baseTask = this.getBaseTask(staff);

    return baseTask * 1.5;
    }
  }
}

export class ProductOwner extends Staff {
  calculateTask(staff: StaffEntity) {
    const baseTask = this.getBaseTask(staff);

    return baseTask * 1.8;
    }
  }
}

export class SolutionArchitecture extends Staff {
  calculateTask(staff: StaffEntity) {
    const baseTask = this.getBaseTask(staff);

    return baseTask * 1.95;
    }
  }
}

Adding another Staff Position type is as simple as making another subclass and extending from the Staff class.

Liskov substitution principle

Objects of its subclasses without breaking the system.

Consider a typical example of a Square derived class and Rectangle base class:

export class Rectangle {
    private double height;
    private double width;
    public void setHeight(double h) { height = h; }
    public void setWidht(double w) { width = w; }
}

export class Square extends Rectangle {}

By conventional logic, a square is a rectangle. So, logically, the Square class is born from the Rectangle class. But the square does not need both the length and the width because the length and width of the square are the same.

Obviously, this is a serious problem. However there is a way to solve it, we can override the setWidth and setHeight functions as follows.

export class Rectangle {
    private double height;
    private double width;
    public void setHeight(double h) { height = h; }
    public void setWidht(double w) { width = w; }
}

export class Square extends Rectangle {
    public void setHeight(double h) {
        super.setHeight(h);
        super.setWidth(h);
    }

    public void setWidth(double w) {
        super.setHeight(w);
        super.setWidth(w);
    }
}

Interface segregation principle

No client should be forced to depend on methods that it does not use.

Suppose there’s an interface for a vehicle and a Bike class:

export interface Vehicle {
    public void drive();
    public void stop();
    public void refuel();
    public void openDoors();
}

export class Bike implements Vehicle {
    // Can be implemented
    public void drive() {...}
    public void stop() {...}
    public void refuel() {...}
    
    // Can not be implemented
    public void openDoors() {...}
}

The openDoors() method does not make sense for a Bike class because the bike does not any doors. To fix this, follow the code below.

export interface IBike {
    public void drive();
    public void stop();
    public void refuel();
}

export interface IVehicle {
    public void drive();
    public void stop();
    public void refuel();
    public void openDoors();
}

public class Bike implements IBike {
    public void drive() {...}
    public void stop() {...}
    public void refuel() {...}
}

public class Vehicle implements IVehicle {
    public void drive() {...}
    public void stop() {...}
    public void refuel() {...}
    public void openDoors() {...}
}

Dependency inversion principle

High-level modules should not depend on low-level modules, both should depend on abstractions.
export class DbConnection {
  private ErrorLogger _errorLogger = new ErrorLogger();

  connect(): Promise<DataSource> {
    try {
      const con = new DataSource({});

      return Promise.resolve(con);
    } catch (error) {
      this._errorLogger.write(error.message);

      return Promise.reject(error);
    }
  }
}

The code will work, for now, but what if we wanted to add another log type, let’s say a info log? This will require refactoring the DbConnection class.

Fix it through dependency injection.

export class DbConnection {
  private Logger _logger;

  constructor(logger: Logger) {
    super();

    this._logger = logger;
  }

  connect(): Promise<DataSource> {
    try {
      const con = new DataSource({});

      return Promise.resolve(con);
    } catch (error) {
      this._logger.write(error.message);

      return Promise.reject(error);
    }
  }
}

Now that the logger is no longer dependent on the DbConnection class, we can change it as we please by passing it into this class at initialization.

Conclusion

These are 5 essential principles used by professional developers around the globe, you should start applying them today!

Thank you for reading, and happy coding!