Designing better softwares with SOLID Principles: Drawing parallels with building bridges
Shubham Bharti
Posted on March 3, 2024
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.
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();
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();
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
andBridgeBuilder
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();
SOLID compliance:
-
Single Responsibility Principle (SRP): Each class has a single responsibility. For example,
Road
,Footpath
,OverBridge
,BridgeBuilder
, andBridgeLogger
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
andFootpath
can be substituted for their base typeBridgeComponent
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, andBridgeComponent
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 onBridge
interface, not on specific implementations likeOverBridge
. Also,BridgeLogger
depends onBridge
andBridgeComponent
interfaces rather than the specific implementations likeOverBridge
,Road
, andFootpath
.
NOTE: The logging part, which was part of the
Bridge
andBridgeBuilder
class in Code #2, has been moved toBridgeLogger
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.
Posted on March 3, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
March 3, 2024