DEV Community

Shubham Bharti
Shubham Bharti

Posted on

Designing better softwares with SOLID Principles: Drawing parallels with building bridges

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
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:

// 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();

Enter fullscreen mode Exit fullscreen mode

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.

// 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();

Enter fullscreen mode Exit fullscreen mode

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:

// 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();

Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)