Why SOLID Design Matters: Avoid Code Smells and Write Maintainable Code

Photo by Markus Spiske / Unsplash

Why SOLID Design Matters: Avoid Code Smells and Write Maintainable Code

Improve your code quality by applying the SOLID principles, a set of best practices for designing readable, scalable, and maintainable software

Paul Knulst  in  Programming Dec 9, 2022 10 min read

Introduction

SOLID is an acronym for five principles of object-oriented design, which were first outlined by Robert C. Martin in the early 2000s. These principles can help developers to create more maintainable and scalable software systems. The five principles are:

Why SOLID Design Matters: Avoid Code Smells and Write Maintainable Code
All five SOLID principles

In this article, I want to showcase the power of the SOLID principles for building readable and maintainable software applications. Furthermore, I will provide practical examples of how to apply the SOLID principles with TypeScript.

These examples will demonstrate how the SOLID principles can be used to design and build better, more scalable applications.

Whether you are a developer, a data scientist, or a user of decentralized applications, these examples will provide a valuable starting point for applying the SOLID principles to your code.

Single Responsibility Principle

Each module should have one and only one reason to change.
- Robert C. Martin

The Single Responsibility Principle is a principle of object-oriented design that states that a class or module should have only one responsibility or reason to change. This means that the class should have a narrowly defined scope, and should not take on too many responsibilities.

In TypeScript, the Single Responsibility Principle can be applied by dividing a large and complex class into smaller and more focused classes, each with its own well-defined responsibility. For example, consider a class that is responsible for managing a user's account information, including their profile details, preferences, and payment information. This class may have many different methods for handling different aspects of the user's account, such as updating their profile, managing their preferences, and processing payments.

To apply the Single Responsibility Principle, we can break this large class into several smaller classes, each with a specific responsibility. For example, we might have a UserProfile class that is responsible for managing the user's profile information, a UserPreferences class that is responsible for managing their preferences, and a UserPayments class that is responsible for processing their payments. Each of these classes would have a narrowly defined scope, and they would be easier to maintain and extend than the original, monolithic class.

Here is an example of how these classes might be implemented in TypeScript:

class UserProfile {
  constructor(userId) {
    this.userId = userId;
  }

  loadProfile() {
    // load the user's profile details from the database
  }

  updateProfile(data) {
    // update the user's profile details in the database
  }
}

class UserPreferences {
  constructor(userId) {
    this.userId = userId;
  }

  loadPreferences() {
    // load the user's preferences from the database
  }

  updatePreferences(data) {
    // update the user's preferences in the database
  }
}

class UserPayments {
  constructor(userId) {
    this.userId = userId;
  }

  loadPaymentMethods() {
    // load the user's payment methods from the database
  }

  processPayment(data) {
    // process a payment for the user using the specified payment method
  }
}

In this example, each class has a narrowly defined responsibility and a well-defined interface. The UserProfile class is responsible for managing the user's profile information, the UserPreferences class is responsible for managing their preferences, and the UserPayments class is responsible for processing their payments. This makes it easier to understand the purpose and behavior of each class, and it also makes it easier to extend and maintain the code.

Open/Closed Principle

Modules should be open for extension but closed for modification.
- Robert C. Martin

The Open/Closed Principle is a principle of object-oriented design that states that a class should be open for extension but closed for modification. This means that the class should be designed in such a way that new behavior can be added through inheritance or composition, without changing the existing code.

In TypeScript, the Open/Closed Principle can be applied by using abstract classes, interfaces, and polymorphism to create a flexible and extensible design. For example, consider a simple Shape class that represents different types of geometric shapes, such as circles, squares, and triangles. This class might have a draw() method that can be used to render the shape on the screen.

To apply the Open/Closed Principle, we can define an abstract Shape class that contains the common behavior of all shapes, such as the draw() method. We can then define subclasses for each specific type of shape, such as Circle, Square, and Triangle, which inherit from the Shape class and override the draw() method to provide their own specific behavior.

Here is an example of how this might be implemented in TypeScript:

// define the abstract Shape class
class Shape {
  constructor() {}

  // the draw() method is abstract and must be implemented by subclasses
  draw() {
    throw new Error('The draw() method must be implemented by a subclass.');
  }
}

// define the Circle class, which extends the Shape class
class Circle extends Shape {
  constructor(radius) {
    super();
    this.radius = radius;
  }

  draw() {
    // draw a circle with the specified radius
  }
}

// define the Square class, which extends the Shape class
class Square extends Shape {
  constructor(sideLength) {
    super();
    this.sideLength = sideLength;
  }

  draw() {
    // draw a square with the specified side length
  }
}

// define the Triangle class, which extends the Shape class
class Triangle extends Shape {
  constructor(sideLengths) {
    super();
    this.sideLengths = sideLengths;
  }

  draw() {
    // draw a triangle with the specified side lengths
  }
}

In this example, the Shape class is open for extension, because new subclasses can be defined to add new behavior. For example, we could define a Rectangle class that extends the Shape class and overrides the draw() method to provide its own specific behavior. However, the Shape class is closed for modification, because we cannot change the existing code in the Shape class without potentially breaking the subclasses that extend it. This makes the code more flexible and maintainable, because we can add new behavior without changing the existing code.

Liskov Substitution Principle

Use interfaces/protocols to separate interchangeable parts.
- Robert C. Martin

The Liskov Substitution Principle is a principle of object-oriented design that states that objects in a program should be replaceable with instances of their subtypes without altering the correctness of the program. This means that subclasses should be able to extend or override the behavior of their parent classes without breaking the parent class's contract.

In other words, the Liskov Substitution Principle is a way to ensure that subtypes are substitutable for their base types, meaning that any program written to use a base type should be able to run without modification if a subtype is used instead.

In TypeScript, the Liskov Substitution Principle can be applied by defining a class and ensuring that subclasses extend it. For example, consider a simple Vehicle class that represents different types of vehicles, such as cars, motorcycles, or boats. This Vehicle class might include the startEngine() method, as well as any other methods or properties that are common to all vehicles. We can then define subclasses for each specific type of vehicle, such as Car or Motocycle, which extends the Vehicle class and provide their own specific behavior for the startEngine() method.

Here is an example of how this might be implemented in TypeScript:

class Vehicle {
  startEngine() {
        throw new Error('The startEngine() method must be implemented by a subclass.');
  }

  stopEngine() {
      throw new Error('The stopEngine() method must be implemented by a subclass.');
  }
}

class Car extends Vehicle {
  startEngine() {
    console.log("Starting the engine of a car");
  }

  stopEngine() {
    console.log("Stopping the engine of a car");
  }
}

class Motorcycle extends Vehicle {
  startEngine() {
    console.log("Starting the engine of a motorcycle");
  }

  stopEngine() {
    console.log("Stopping the engine of a motorcycle");
  }
}

function useVehicle(vehicle) {
  vehicle.startEngine();
  // Do some other stuff with the vehicle...
  vehicle.stopEngine();
}

const vehicle = new Car();
useVehicle(vehicle);

const vehicle = new Motorcycle();
useVehicle(vehicle);

In this example, the Car and Motorcycle classes are subtypes of the Vehicle class. The useVehicle() function expects an object of type Vehicle, but because of the Liskov Substitution Principle, we can pass an object of type Car or Motorcycle to this function and it will work correctly.

This is because Car and Motorcycle are substitutable for Vehicle – that is, objects of type Car and Motorcycle can be used wherever objects of type Vehicle are expected, without altering the desired properties of the program.

Interface Segregation Principle

Clients should not be forced to depend upon interfaces that they do not use.” - Robert C. Martin

The Interface Segregation Principle is a principle of object-oriented design that states that clients of an object should not be forced to depend on methods that they do not use. This means that the interfaces of a class should be designed in such a way that each interface serves a specific purpose and is used by a specific client. This principle helps to reduce the amount of unused code in a program, making it more efficient and easier to maintain.

The motivation for this principle is to avoid the problem of monolithic interfaces, which define a large number of methods that may not be relevant to all clients. This can lead to client code that is bloated and hard to maintain, because it is forced to implement or mock unused methods to satisfy the interface contract.

To apply the Interface Segregation Principle, it is best to define multiple small and focused interfaces, rather than a single, monolithic interface. This allows each client to implement only the methods that it actually needs, without being forced to implement unused methods. This can lead to a more modular and maintainable design, because the interfaces are more closely aligned with the needs of their clients.

In TypeScript, the Interface Segregation Principle can be applied by creating small, specific interfaces for each type of object in a program.

For example, consider a program that has two types of objects: Cat and Fish. A fish object might have a eat() method, while a cat object might have eat() and move() methods. To follow the Interface Segregation Principle, we could create two separate interfaces for Cat and Fish, each with its own unique methods:

interface Eating {
  eat(): void;
}

interface Moving {
  move(): void;
}

class Cat implements Eating, Moving {
  eat() {
    console.log('cat eats');
  }

  move() {
    console.log('cat moves')
  }
}

class Fish implements Eating {
  eat() {
    console.log('fish eats')
  }
}

In this example, the Eating and Moving interfaces are defined. The Eating interface has a single method, eat(), while the Moving interface has a single method, move().

The Cat class is defined as a subtype of Eating and Moving – that is, it implements both interfaces. This means that the Cat class is required to implement the eat() and move() methods with a specific implementation.

The Fish class is defined as a subtype of the Eating interface. This means that the Fish class is only required to implement the eat() method. The Fish class provides an implementation for this method, but it does not need to implement the move() method because fish do not move in the same way as other animals.


The Interface Segregation Principle helps to improve the design of a program by breaking down large, general interfaces into smaller, more specific ones. This can make a program more efficient and easier to maintain.

Dependency Inversion Principle

Entities must depend on abstractions, not on concretions. It states that the high-level module must not depend on the low-level module, but they should depend on abstractions.
- Robert C. Martin

The Dependency Inversion Principle is a software design principle that states that high-level modules should not depend on low-level modules, but rather both should depend on abstractions. This principle helps to improve the flexibility and maintainability of a program, by allowing different parts of the program to evolve independently.

In TypeScript, the Dependency Inversion Principle can be applied by defining abstractions (such as interfaces or abstract classes) that define the contract for different parts of the program. For example, consider a program that has a Database class and a UserService class. The UserService class might depend on the Database class to store and retrieve user data. To follow the Dependency Inversion Principle, we could define an abstract DataStore interface that the Database class implements, and have the UserService class depends on the DataStore interface instead of the Database class directly. This way, the UserService class is not tied to a specific implementation of a data store, and can be used with any class that implements the DataStore interface:

// DataStore interface
interface DataStore {
  get(id: string): any;
  set(id: string, data: any): void;
}

// Database class
class Database implements DataStore {
  // Implementation of DataStore methods
}

// UserService class
class UserService {
  constructor(private dataStore: DataStore) { }

  getUser(id: string) {
    return this.dataStore.get(id);
  }

  saveUser(user: any) {
    this.dataStore.set(user.id, user);
  }
}

In this example, the UserService class depends on the DataStore interface, and can work with any class that implements that interface. This allows us to change the implementation of the Database class without affecting the UserService class, or to use a different class altogether as long as it implements the DataStore interface.


The Dependency Inversion Principle helps to improve the design of a program by decoupling high-level modules from low-level ones, and making both depend on abstractions. This can make a program more flexible and easier to maintain.

Beware! Avoid Common Misconceptions

One common misconception about the SOLID principles is that they should be followed blindly, without considering the specific needs and constraints of the project at hand. In reality, these principles are guidelines that can help developers to create more maintainable and scalable software, but they should be applied with care and judgment.

For example, the Single Responsibility Principle can sometimes be violated in order to improve the performance or simplicity of a class.

Another common misconception is that the SOLID principles apply only to object-oriented languages, such as Kotlin, TypeScript,  Java, or C++. In reality, these principles are language-agnostic, and they can be applied to any type of software system, regardless of the programming paradigm or language used.

The SOLID principles are no silver bullet that can solve all problems of software design. In reality, these principles are just one tool among many that can help developers to create better software. They should be used in combination with other design principles and practices, such as refactoring, testing, and continuous integration.

Closing Notes

In conclusion, the SOLID principles are a set of guidelines that can help developers create more maintainable, extendable, and understandable software by focusing on creating classes and modules that are single-purpose, well-encapsulated, and easy to modify.

By adhering to the SOLID principles, developers can save time and effort in the long run by writing code that is easier to maintain and adapt over time.

It is paramount to understand that these principles should not be strictly followed in all situations. In reality, they should be treated more like guidelines that can be applied in different ways depending on the specific needs of a project.

As a developer, it's important to approach the SOLID principles with a flexible and open mind. By doing so, you can make the most out of these guidelines and use them to create better software.

Furthermore, SOLID principles are not a new or revolutionary idea in software design. In reality, these principles are based on decades of experience and research in the field of object-oriented design, and they build on the work of many other software developers and researchers who came before.

If you want to dive deeper into the topic of how to develop readable, scalable, and maintainable code, check out this video (and the following 5). It's the first lesson of "Coding Better World Together" by Robert C. Martin.

Finally, what do you think about the SOLID principles? Also, do you have any questions regarding any of the five principles? I would love to hear your thoughts and answer your questions. Please share everything in the comments.

Feel free to connect with me on Medium, LinkedIn, Twitter, and GitHub.


🙌 Support this content

If you like this content, please consider supporting me. You can share it on social media, buy me a coffee, or become a paid member. Any support helps.

See the contribute page for all (free or paid) ways to say thank you!

Thanks! 🥰

By Paul Knulst

I'm a husband, dad, lifelong learner, tech lover, and Senior Engineer working as a Tech Lead. I write about projects and challenges in IT.