Let's SOLIDify Your Code

Raouf Osman
Raouf Osman

When I hear the phrase "SOLID principles," it immediately conjures up an image of Uncle Bob, reminding me to be professional, just like in his influential book, Clean Coder. It's hard to believe that Uncle Bob first shared his thoughts on SOLID principles as far back as 2000. At that time, I was merely a nine-year-old, unaware of the impact these principles would have on the software development landscape. Time truly flies!

What's even more remarkable is that the SOLID principles have not lost their relevance over the years, despite the rapid emergence of new frameworks and technologies. In fact, modern frameworks like Spring, .NET Core, Django, Laravel, and even Angular actively promote and incorporate SOLID principles into their core features.

Join me on this journey as we uncover what the SOLID principles entail and discover practical real-world examples of their application.

What is SOLID?

SOLID is an acronym coined by Robert C. Martin that represents five basic principles of object-oriented programming.

S(RP) - Single Responsibility

There should never be more than one reason for a class to change.

O(CP) - Open/Closed

A module should be open for extension but closed for modification.

L(SP) - Liskov Substitution

Subclasses should be substitutable for their base classes.

I(SP) - Interface Segregation

Many client specific interfaces are better than one general purpose interface.

D(IP) - Dependency Inversion

Depend upon abstractions. Do not depend on concretions.

Let's explore each SOLID principle and examine how we can attain solid code design by analyzing and making modifications to the code below.

Single Responsibility Principle

A class should only have one reason to change. In simpler terms, the class should be responsible for doing one thing and doing it well. This doesn't mean one method in the class and that's it. Rather, having a clear and well-defined purpose that aligns with what the class is responsible for.

So what's wrong with the calculator class above? At first glance, you'd think it's fine. A calculator that can perform arithmetic operations such as, add, subtract, multiply, and divide, and calculate square root. We just listed two responsibilities this class has. Sounds to me like this class has more than one reason to change if requirements changed for one of the methods. Therefore, it violates this principle.

Let's modify it to adhere to SRP.

We modified the original Calculator class into two classes. One class that handles all arithmetic operations and one that calculates square root.

With this modification, we've satisfied the single responsibility principle. This code is now our baseline. Let's take a look at the next solid principle, Open-Closed Principle (OCP).

Open-Closed Principle

Of all the principles of object oriented design, this is the most important. - Uncle Bob

This principle simply means, we should write our code so modules can be easily extended, rather than modifying the module. Polymorphism and abstract classes, are well-known concepts in object-oriented programming that aids us in adhering to OCP.

Now, how can we modify the example above that conforms to SRP, but does not conform OCP.

We modified the Calculator class and made it abstract. This class now defines a calculate method. Why make it abstract? By making the calculator abstract, we allow for the calculate method to be extended. Each specific calculator operation (addition, subtraction, multiplication, and division) is implemented as a derived class that extends the calculator base class. This allows for adding new calculator operations without modifying the existing code.

By using abstraction and inheritance, the code adheres to the Open-Closed Principle (OCP). The calculator class is closed for modification but open for extension through the new calculator operations. Now, how can we modify the example above that conforms to SRP and OCP, but does not conform Liskov Substitution (LSP).

Liskov Substitution Principle

This principle is probably the one that causes the most bugs (speaking from experience😀).

I pulled this definition from Dot Net Tutorials - This principle states that, if S is a subtype of T, then objects of type T should be replaced with objects of type S. This made the most sense to me. In other words, Subtypes must be substitutable for their base types, i.e a superclass should be replaceable with objects of any of its subclasses without affecting the correctness of the program.

Let's look back at our example above.

This is the same code as our OCP refactor. Does this already support LSP?

Yes it does! Let's discuss how.

We stick with the abstract base class. Each derived calculator class overrides the calculate method to provide its specific calculation implementation. The derived classes can be used interchangeably with the base class. So any code that depends on the calculator class can use any derived calculator class without knowing the specific implementation details.

Great, now let's get into the next principle, Interface Segregation (ISP).

Interface Segregation Principle

This principle states that interfaces should be specific and tailored to the needs of the classes that use them. By creating smaller and more focused interfaces, this principle avoids the problem of classes being dependent on methods they don't need, which leads to cleaner code and easier maintenance.

Let's see how we can modify our calculator class to conform to this principle.

In this modified example, each calculator operation (addition, subtraction, multiplication, division, square root) is defined by its own interface (e.g., IAdditionCalculator, ISubtractionCalculator, IMultiplicationCalculator, IDivisionCalculator, ISquareRootCalculator). Each calculator class implements the specific interface(s) that are relevant to its functionality.

By segregating the interfaces based on specific calculator operations, clients can depend only on the specific interfaces they require, and classes are not forced to implement methods they don't need.

Now let's create a calculator class that does everything following ISP.

Nice! Now we have a class that conforms to ISP. Finally, let's modify our class to conform to the last principle, Dependency Inversion (DIP).

Dependency Inversion Principle

Bear with me on this one. I will explain it in a way that makes sense.

This principle states that High-level classes should not depend on low-level classes. Instead, we should depend on abstractions. This principle promotes loose coupling between modules by introducing abstractions or interfaces that define contracts. High-level class should depend on these abstractions rather than directly depending on low-level implementation details.

In other words, no dependency should target a concrete class. It should target an interface. We want to keep high-level and low-level modules loosely coupled to keep the class flexible, testable, and easier to maintain.

So we threw out some new words here. High-level and Low-level classes. Let's break those down.

What is a high-level class?

The class which is performing a task with the help of other class is a high-level class

What is a low-level class?

The class which receives a command or helps high-level class to perform a task is often regarded as a low-level class.

Think of high-level classes as Managers, Services, Repositories, or Orchestrates. Think of low-level classes as classes that perform the actions in the high-level class. For example, creating a report is high-level while getting data for that report and printing those data are low-level operations.

Note: Dependency Inversion Principle is NOT the same as Dependency Injection.

  • Dependency Injection is an implementation technique for populating instance variables of a class.

  • Dependency Inversion is a general design guideline which recommends that classes should only have direct relationships with high-level abstractions.

DIP is not about injecting dependencies, although the dependency injection pattern is one of many techniques which can help provide the level of indirection needed to avoid depending on low-level details and coupling with other concrete classes.

Review these great resources below to further understand the differences.

[Difference between Dependency Injection and Dependency Inversion]

[DIP in the Wild]

Now that we got that out of the way, let's take a look at our new modified Calculator class.

So we still follow ISP, but in this modified example, we are now creating a CalculatorService and injecting an ICalculator into it.

Now when we create a Calculator, we can inject any class that implements ICalculator and do the calculation.

var addCalculatorService = new CalculatorService(new AdditionCalculator());

var result = addCalculatorService.Calculate(1, 2);

// result = 3

By relying on abstractions (interfaces) rather than concrete implementations, the code adheres to the Dependency Inversion Principle (DIP), promoting loose coupling and facilitating easier maintenance and extensibility.

Closing Remarks

Gaining a deep understanding of the SOLID principles is essential to grasp the essence of object-oriented programming (OOP). These principles and OOP go hand-in-hand, forming the very fabric of effective software design.

While frameworks implicitly adhere to these principles, it remains crucial to actively comprehend their significance. As you progress on your journey as a software engineer, contemplate your software solutions, and apply these principles in your development process. By doing so, you will forge software solutions that are maintainable, and scalable, enhancing your prowess as a skilled developer.

Until next time, Happy Computing!!

RO out 🎤