Mastering SOLID Principles for Better Software Development

Sean Marcus
7 min readMay 2, 2023

--

SOLID is a set of five principles for writing maintainable and scalable software applications. These principles were introduced by Robert C. Martin in the early 2000s and have since become a cornerstone of modern software development practices.

SOLID stands for:

  1. Single Responsibility Principle (SRP)
  2. Open-Closed Principle (OCP)
  3. Liskov Substitution Principle (LSP)
  4. Interface Segregation Principle (ISP)
  5. Dependency Inversion Principle (DIP)

Let’s discuss each of these principles in detail.

Single Responsibility Principle (SRP)

At a high level, the SRP states that a class should have only one reason to change. In other words, a class should have only one responsibility, and if that responsibility changes, the class should be the only thing that needs to change.

Here’s an example of how to apply the SRP in TypeScript:

class UserRepository {
private db: Database;
constructor(db: Database) {
this.db = db;
}
getUserById(id: string): User {
// Retrieve user from the database
// and return it
}
saveUser(user: User) {
// Save user to the database
}
deleteUser(id: string) {
// Delete user from the database
}
}
}

In this example, UserRepository is a class that interacts with a database to retrieve, save, and delete user information. At first glance, this class seems to have only one responsibility: managing user data in the database.

However, let’s consider a scenario where we need to add a new feature to our application that requires sending emails to users. In this case, we might be tempted to modify our existing UserRepository class to add email functionality. However, this violates the SRP because now the UserRepository class has two responsibilities: managing user data in the database and sending emails.

Instead, we should create a new class that is responsible for sending emails, like this:

class EmailService {
sendWelcomeEmail(user: User) {
// Send a welcome email to the user
}
}

Now, our UserRepository class has only one responsibility: managing user data in the database. If we need to send emails to users, we can use the EmailService class to handle that responsibility.

By following the SRP, we can create classes that are more modular and easier to maintain over time. Each class has a clear responsibility, making it easier to understand and modify as needed.

Open-Closed Principle (OCP)

The OCP states that a class should be open for extension but closed for modification. In other words, we should be able to extend the behavior of a class without modifying its source code.

Here’s an example of how to apply the OCP in TypeScript:

abstract class Shape {
abstract area(): number;
}
class Rectangle extends Shape {
private width: number;
private height: number;
constructor(width: number, height: number) {
super();
this.width = width;
this.height = height;
}
area(): number {
return this.width * this.height;
}
}
class Circle extends Shape {
private radius: number;
constructor(radius: number) {
super();
this.radius = radius;
}
area(): number {
return Math.PI * this.radius ** 2;
}
}
class AreaCalculator {
static calculate(shapes: Shape[]): number {
let area = 0;
for (const shape of shapes) {
area += shape.area();
}
return area;
}
}

In this example, we have an abstract Shape class with an area() method, and two concrete classes that extend it: Rectangle and Circle. We also have an AreaCalculator class that takes an array of Shape objects and calculates the total area of all the shapes.

Now, let’s say we want to add a new shape to our application, like a triangle. We can extend the Shape class and create a new Triangle class, like this:

class Triangle extends Shape {
private base: number;
private height: number;
constructor(base: number, height: number) {
super();
this.base = base;
this.height = height;
}
area(): number {
return (this.base * this.height) / 2;
}
}

We can now pass an array of Triangle objects to our AreaCalculator class and it will calculate the total area of all shapes, including triangles.

By following the OCP, we can extend the behavior of our classes without modifying their source code. This makes our code more maintainable and reduces the risk of introducing bugs or breaking existing functionality.

Liskov Substitution Principle (LSP)

The LSP states that objects of a superclass should be able to be replaced with objects of a subclass without affecting the correctness of the program. In other words, a subclass should be able to be used wherever its superclass is used.

Here’s an example of how to apply the LSP in TypeScript:

class Animal {
move() {
console.log("Moving...");
}
}
class Dog extends Animal {
bark() {
console.log("Woof!");
}
}
class Cat extends Animal {
meow() {
console.log("Meow!");
}
}
function makeAnimalMove(animal: Animal) {
animal.move();
}
const dog = new Dog();
const cat = new Cat();
makeAnimalMove(dog); // Outputs "Moving..."
makeAnimalMove(cat); // Outputs "Moving..."

In this example, we have a Animal class with a move() method, and two subclasses that extend it: Dog and Cat. We also have a function called makeAnimalMove() that takes an Animal object and calls its move() method.

The important thing to note here is that we can pass both a Dog object and a Cat object to makeAnimalMove(), even though they are different subclasses of Animal. This is possible because both Dog and Cat have an Animal as their superclass, and they both implement the move() method.

By following the LSP, we can ensure that our program behaves correctly even when different subclasses are used in place of their superclass. This helps reduce the risk of bugs and makes our code more flexible and extensible.

Interface Segregation Principle (ISP)

The ISP states that clients should not be forced to depend on interfaces they do not use. In other words, interfaces should be small and focused on a specific set of behaviors.

Here’s an example of how to apply the ISP in TypeScript:

interface IShape {
area(): number;
perimeter(): number;
}
class Rectangle implements IShape {
private width: number;
private height: number;
constructor(width: number, height: number) {
this.width = width;
this.height = height;
}
area(): number {
return this.width * this.height;
}
perimeter(): number {
return 2 * (this.width + this.height);
}
}
class Circle implements IShape {
private radius: number;
constructor(radius: number) {
this.radius = radius;
}
area(): number {
return Math.PI * this.radius ** 2;
}
perimeter(): number {
return 2 * Math.PI * this.radius;
}
}
class AreaCalculator {
static calculateArea(shapes: IShape[]): number {
let area = 0;
for (const shape of shapes) {
area += shape.area();
}
return area;
}
}

In this example, we have an IShape interface that defines two methods: area() and perimeter(). We have two classes that implement this interface: Rectangle and Circle. We also have an AreaCalculator class that takes an array of IShape objects and calculates the total area of all the shapes.

The important thing to note here is that the IShape interface only includes the methods that are relevant to calculating the area and perimeter of a shape. If we were to include other methods, such as draw() or resize(), these would not be relevant to our AreaCalculator class and would violate the ISP.

By following the ISP, we can create interfaces that are small and focused on a specific set of behaviors. This makes it easier to implement and test those behaviors, reduces the risk of introducing bugs, and makes our code more maintainable over time.

Dependency Inversion Principle (DIP)

The DIP states that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions. Abstractions should not depend on details, but details should depend on abstractions.

Here’s an example of how to apply the DIP in TypeScript:

interface ILogger {
log(message: string): void;
}
class ConsoleLogger implements ILogger {
log(message: string) {
console.log(message);
}
}
class User {
private logger: ILogger;
constructor(logger: ILogger) {
this.logger = logger;
}
save() {
// Save user to the database
this.logger.log("User saved");
}
}

In this example, we have an ILogger interface that defines a log() method. We have a ConsoleLogger class that implements this interface and logs messages to the console. We also have a User class that has a dependency on an ILogger object.

The important thing to note here is that the User class depends on the ILogger interface, not the ConsoleLogger class directly. This means that we can easily swap out the ConsoleLogger with another implementation of the ILogger interface, such as a file logger or a database logger, without changing the User class.

By following the DIP, we can reduce coupling between modules, making our code more modular and easier to modify over time. By depending on abstractions rather than concrete implementations, we can make our code more flexible and resilient to change.

Conclusion

In conclusion, the SOLID principles provide a set of guidelines for writing maintainable, scalable, and resilient code. By following these principles, developers can create software applications that are easier to modify, test, and maintain over time.

The Single Responsibility Principle (SRP) encourages us to create classes with a single responsibility, making them easier to understand and modify. The Open-Closed Principle (OCP) encourages us to create classes that are open for extension but closed for modification, reducing the risk of introducing bugs or breaking existing functionality. The Liskov Substitution Principle (LSP) encourages us to create classes that can be used interchangeably with their superclasses, making our code more flexible and extensible. The Interface Segregation Principle (ISP) encourages us to create interfaces that are small and focused on a specific set of behaviors, reducing the risk of introducing unnecessary complexity. Finally, the Dependency Inversion Principle (DIP) encourages us to reduce coupling between modules by depending on abstractions rather than concrete implementations.

By applying these principles in our development process, we can create code that is more modular, testable, and easier to maintain over time. This can help us build better software applications that are more resilient to change and better suited to meet the evolving needs of our users.

--

--

Sean Marcus
Sean Marcus

No responses yet