Designing better softwares with SOLID Principles: Drawing parallels with building bridges
Tags: solidprinciples, designpatterns, softwaredevelopment, typescript
Published: 2024-03-03 08:07:24

Imagine you're tasked with building a bridge that connects two bustling cities separated by a wide river. The success of this project hinges not only on the initial construction but also on its ability to withstand the test of time, adapt to changing traffic patterns, and accommodate advancements in transportation technology.
![Building Softwares like building bridges with SOLID Principles](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6c6saz74x9gkb0a9qh5b.jpg)
In many ways, software development shares parallels with this idea. Here, We construct digital bridges to connect users with information, services, and each other. Just as a poorly designed bridge can lead to traffic jams and structural failures, poorly designed software can result in bugs, maintenance nightmares, and frustrated users.

This is where **SOLID principles** come into play.

## So what are SOLID principles?
The SOLID Principles are **software design principles** that help us **structure** and **organise** our functions, classes, and modules, so they are **robust**, easy to understand, **maintainable**, and **flexible to change**.

**SOLID** is an acronym for **five key design principles**:
- **S: Single Responsibility Principle (SRP)**
- **O: Open-Closed Principle (OCP)**
- **L: Liskov-Substitution Principle (LSP)**
- **I: Interface Segregation Principle (ISP)**
- **D: Dependency Inversion Principle (DIP)**

I will attempt to describe them one by one and try to draw a parallel with something more relatable and a real life example, like building bridges.

**_I will also evolve our initial code to apply those SOLID principles along the way._**

## S: Single Responsibility Principle (SRP)
By definition, this principle says:

> _Each piece of code should have only one job or responsibility. This keeps the code simple and focused, making it easier to understand and maintain._

While building a bridge, we preferably should not give the responsibility of building the bridge, adding the road and adding the footpath to the same Bridge Builder. Otherwise, there won't be a clear separation of concern.
Here is a non-compliant code in Typescript:

```typescript
// Code #1
// SRP non-compliant code
class BridgeBuilder {
  constructor(private roadWidth: number, private footpathWidth: number) { }

  buildBridge() {
    console.log(`Building bridge with road width ${this.roadWidth}`);
    // Logic to construct the entire bridge
  }

  addRoad() {
    console.log(`Adding road with width ${this.roadWidth}`);
    // Code to add road to the bridge
  }

  addFootpath() {
    console.log(`Adding footpath with width ${this.footpathWidth}`);
    // Code to add footpath to the bridge
  }
}

const mainModule = () => {
  const bridgeBuilder = new BridgeBuilder(10, 3);
  bridgeBuilder.buildBridge();
  bridgeBuilder.addFootpath();
}
mainModule();

```
In this case, the `BridgeBuilder` class is handling multiple responsibilities: building the bridge, adding the road, and adding the footpath. So, this **_violates the SRP_**.

Now, to make this SRP compliant, we must separate those responsibilities.

```typescript
// Code #2
// OCP and DIP non-compliant code
interface BridgeComponent {
  name: string;
  width: number;
}

class Road implements BridgeComponent {
  name: string = 'road';
  width: number;
  constructor(width: number) {
    this.width = width;
  }
}

class Footpath implements BridgeComponent {
  name: string = 'footpath';
  width: number;
  constructor(width: number) {
    this.width = width;
  }
}

class Bridge {
  components: BridgeComponent[] = [];
  constructor(components: BridgeComponent[]) {
    components.forEach(component => {
      this.components.push(component);
    });
  }

  addComponent(component: BridgeComponent): void {
      this.components.push(component);
      if(component.name == 'road') {
        console.log(`Adding road with width ${component.width}`);
      }
      else if(component.name == 'footpath') {
        console.log(`Adding footpath with width ${component.width}`);
      }
  }

  getComponent() {
    return this.components;
  }
}

class BridgeBuilder {
  constructor(private bridge: Bridge) { }

  buildBridge(): Bridge {
    const components = this.bridge.getComponent();
    components.forEach(component => {
      if(component.name == 'road') {
        console.log(`Building bridge with road width ${component.width}`);
      }
      else if(component.name == 'footpath') {
        console.log(`Building bridge with footpath width ${component.width}`);
      }
    });
    // Logic to construct the entire bridge
    return this.bridge;
  }
}

const mainModule = () => {
  const road = new Road(10);
  const bridge = new Bridge([road]);
  const bridgeBuilder = new BridgeBuilder(bridge);
  bridgeBuilder.buildBridge();

  const footpath = new Footpath(3);
  bridge.addComponent(footpath);
}
mainModule();

```
Here, Each class has a clear and distinct responsibility, which adheres to the SRP. The `BridgeComponent` interface defines the structure, the `Road` and `Footpath` classes represent specific components, the `Bridge` class manages its components, and the `BridgeBuilder` class builds the bridge using those components.

> **NOTE**: _To make it truly SRP compliant, the logging part must be kept outside the scope of `Bridge` and `BridgeBuilder` class. Ignore it for now, as I have kept it for simplicity here. We will address it at the end._

## O: Open-Closed Principle (OCP)
By definition, this principle says:

> _Code should be open for extension but closed for modification. It fulfils two objectives: Extensibility and Stability, ie, we can add new features without making any changes to the existing code. This prevents us from introducing any bug to the existing code._

Referring to the Code #2 as mentioned above, if we require to add a `BicycleTrack` as a new `BridgeComponent`, we can simply do so implementing the `BridgeComponent` again like we did for `Road` and `Footpath` class. This suggests that it is open for extension.
But, if you notice the `Bridge` and `BridgeBuilder` classes, those if and else statements are dependent upon `Road` and `Footpath` components. So, if we require to add a `BicycleTrack`, we would need to modify the `Bridge` and `BridgeBuilder` classes. So, it is not closed for modification. Hence, this **_violates OCP_**.

## L: Liskov Substitution Principle (LCP)
By definition, this principle says:

> _Subclasses should be able to replace their base/parent classes without causing any issue. This ensures that different parts of the code can work together seamlessly._

Referring to the Code #2 again, the constructor of the `Bridge` class expects an argument of `BridgeComponent[]` and its `addComponent` method expects an argument of `BridgeComponent`. But, while calling those methods, I have passed road and footpath objects. Though I have defined `BridgeComponent` as an interface here, but the above inference would have hold true even if I would have defined it as a class. So, it is **_LCP compliant_**.

## I: Interface Segregation Principle (ISP)
By definition, this principle says:

> _A class using an interface should not be forced to depend on interfaces they don't use. This ensures that there is no unnecessary implementation by any class, forced by the interface._

This can be achieved by keeping the interfaces small and focused.

Let's say, we require to add a `streetlight` Component which suppose has `wattage` as an attribute. Can we add this attribute to the `BridgeComponent` along with the existing name and width attributes? No, we should not. Otherwise, the existing `Road` and `Footpath` classes will be forced to implement it, which would not make sense. Rather, we can create a new interface named as `LightComponent` with `wattage` as its attribute. So, the implement in Code #2 is **_ISP compliant_**.

## D: Dependency Inversion Principle (DIP)
By definition, this principle says:

> _High-level modules should not depend on low-level modules. Instead, both should depend on abstractions. Additionally, abstractions should not depend on details; instead, details should depend on abstractions._

This reduces the coupling between modules and makes the codebase more flexible and easier to maintain. In essence, it inverts the direction of dependencies.
Looking at the Code #2 again, we can see that `BridgeBuilder` class is a high-level module which depends on the `Bridge` class, which can be considered a low-level module implementing a `BridgeComponent` interface. So, the high-level module depends on the low-level module. Hence, this is **_not DIP compliant_**.
This can be fixed by creating sufficient abstractions using interfaces and using those interfaces to implement all the modules. This way, all the modules and its details will depend on abstractions.

So, here is the final version of the code, ie, Code #3:

```typescript
// Code #3
// SOLID compliant code
interface BridgeComponent {
  name: string;
  width: number;
}

interface Bridge {
  type: string;
  addComponent(component: BridgeComponent): void;
  getComponent(): BridgeComponent[];
}

class Road implements BridgeComponent {
  name: string = 'road';
  width: number;
  constructor(width: number) {
    this.width = width;
  }
}

class Footpath implements BridgeComponent {
  name: string = 'footpath';
  width: number;
  constructor(width: number) {
    this.width = width;
  }
}

class OverBridge implements Bridge {
  type: string = 'overbridge';
  components: BridgeComponent[] = [];

  constructor(components: BridgeComponent[]) {
    components.forEach(component => {
      this.addComponent(component);
    });
  }

  addComponent(component: BridgeComponent) {
    this.components.push(component);
  }

  getComponent() {
    return this.components;
  }
}

class BridgeBuilder {
  constructor(private bridge: Bridge) { }

  buildBridge(): Bridge {
    // Logic to build the bridge
    return this.bridge;
  }
}

class BridgeLogger {
  logAddingComponent(component: BridgeComponent): void {
    console.log(`Adding ${component.name} with width ${component.width}`);
  }

  logBuildBridge(bridge: Bridge): void {
    const components = bridge.getComponent();
    let str: string = 'Building bridge with ';
    for (let index = 0; index < components.length; index++) {
      const element = components[index];
      if (index > 0) {
        str += 'and ';
      }
      str += `${element.name} width ${element.width} `;
    }
    console.log(str);
  }
}

const mainModule = () => {
  const road = new Road(10);
  const bridge = new OverBridge([road]);
  const bridgeBuilder = new BridgeBuilder(bridge);
  bridgeBuilder.buildBridge();

  const bridgeLogger = new BridgeLogger();
  bridgeLogger.logBuildBridge(bridge);

  const footpath = new Footpath(3);
  bridge.addComponent(footpath);
  bridgeLogger.logAddingComponent(footpath);
}
mainModule();

```

## SOLID compliance:

- **Single Responsibility Principle (SRP)**: Each class has a single responsibility. For example, `Road`, `Footpath`, `OverBridge`, `BridgeBuilder`, and `BridgeLogger` each have distinct responsibilities.
- **Open/Closed Principle (OCP)**: The code is open for extension but closed for modification. You can extend the behavior of the system by creating new classes (e.g., new types of bridges or bridge components) without needing to modify existing code.
- **Liskov Substitution Principle (LSP)**: There are no apparent violations of LSP. Subtypes such as `Road` and `Footpath` can be substituted for their base type `BridgeComponent` without affecting the correctness of the program.
- **Interface Segregation Principle (ISP)**: The interfaces are segregated based on the requirements. `Bridge` interface provides methods specifically related to bridges, and `BridgeComponent` interface provides properties common to all bridge components.
- **Dependency Inversion Principle (DIP)**: Higher-level modules depend on abstractions (interfaces) rather than concrete implementations. For example, `BridgeBuilder` depends on `Bridge` interface, not on specific implementations like `OverBridge`. Also, `BridgeLogger` depends on `Bridge` and `BridgeComponent` interfaces rather than the specific implementations like `OverBridge`, `Road`, and `Footpath`.

> **NOTE**: _The logging part, which was part of the `Bridge` and `BridgeBuilder` class in Code #2, has been moved to `BridgeLogger` class in Code #3 (the final version)._

## Conclusion
The SOLID principles offer a robust framework for developing software that is maintainable, scalable, and resilient. By adhering to these principles, developers can create code that is easier to understand, modify, and extend, fostering an agile and efficient development process.
However, it's essential to note that while SOLID principles provide valuable guidelines, they are not silver bullets. Context matters, and there may be situations where strict adherence to these principles might not be the most practical approach, specially during the initial phase. Rather, we can evolve over time. As with any methodology or best practice, it's crucial to assess and adapt SOLID principles to fit the specific needs and constraints of your project.
That said, integrating SOLID principles into your development workflow can undoubtedly improve code quality and contribute to the long-term success of your software projects. So, whether you're a novice or a seasoned developer, consider incorporating SOLID principles into your toolkit and enjoy the benefits they bring to your software development journey.