paint-brush
How to Keep Your Code SOLIDby@oxymoron_31
4,000 reads
4,000 reads

How to Keep Your Code SOLID

by Nagarakshitha RamuMarch 15th, 2023
Read on Terminal Reader
Read this story w/o Javascript

Too Long; Didn't Read

S.O.L.I.D principles are general guidelines for writing clean code in object-oriented programming. They include the Single Responsibility Principle, Open-Closed Principle, Interface Segregation, Dependency Inversion and Substitution Principle. These principles can be applied to a variety of programming languages.
featured image - How to Keep Your Code SOLID
Nagarakshitha Ramu HackerNoon profile picture

Broadly, all programming languages can be classified into two paradigms :

Imperative / Object-oriented programming- Follows sequence of instructions that are executed line-by-line.

Declarative / Functional programming- Not sequential but concerns itself more to the purpose of the program. The whole program is like a function that further has sub-functions, each performing a certain task.

As a junior developer, I am realizing it the hard way ( read : I was nervously staring at 1000s of lines of code ) that it’s not just about writing functional code, but semantically easy and flexible code as well. 

While there are multiple best practices to write clean code in both paradigms, I am gonna talk about SOLID design principles concerning the object-oriented paradigm of programming.

What is S.O.L.I.D?

S — Single Responsibility
O—Open-Closed Principle
L — Liskov Substitution Principle
I — Interface Segregation
D — Dependency Inversion 

The main reason for any difficulty in grasping these concepts is not because their technical depths are unfathomable, but because they are abstract guidelines that are generalized for writing clean code in object-oriented programming. Let us look at some high-level class diagrams to drive these concepts home.

These are not accurate class diagrams but are basic blueprints to help understand what methods are present in a class.

Let us consider an example of a cafe throughout the article.

Single Responsibility Principle

A class should have only one reason to change

Consider this class which handles online orders received by the cafe.

What's wrong with it?
This single class is responsible for multiple functions. What if you have to add other payment methods? What if you have multiple ways of sending confirmation? Changing the logic of payment in the class which is responsible for processing orders is not very good design. It leads to highly non-flexible code.

A better way would be to separate these specific functionalities into concrete classes and call an instance of them, as shown in the below illustration.

Open-Closed Principle

Entities should be open for extension but closed for modification.

At the cafe, you have to choose the condiments for your coffee from a list of options and there is a class that handles this.

The cafe decided to add a new condiment, butter. Notice how the price will change according to the condiment selected and the logic for price calculation is in the Coffee class. Not only do we have to add a new condiment class each time which creates possible code changes in main class , but also handle the logic differently each time.

A better way would be to create a condiments interface that can inturn have child classes that override its methods. And the main class can just use the condiments interface to pass the parameters and get the quantity and price for each order.

This has two advantages:

1. You can dynamically change your order to have different or even multiple condiments (coffee with mocha and chocolate sounds heavenly).

2. The Condiments class is going to have a has-a relationship with Coffee class, rather than is-a. So your Coffee can have-a mocha/butter/milk rather than your Coffee is-a mocha/butter/milk kind of coffee.

Liskov Substitution Principle

Every subclass or derived class should be substitutable for its base or parent class.

This means that the sub-class should be directly able to replace the parent class; it must have the same functionality. I found it hard to understand this one because it sounds like some complex math formula. But I will try to make it clear in this article.

Consider the staff in the cafe. There are baristas, managers, and servers. They all have similar functionality.

Hence we can create a base Staff class with name, position, getName, getPostion, takeOrder(), serve().

Each of the concrete classes , Waiter, Barista and Manager can derive from this and override the same methods to implement them as needed for the position.

In this example, Liskov Substitution Principle (LSP) is used by ensuring that any derived class of Staff can be used interchangeably with the base Staff class without affecting the correctness of the code.

For example, the Waiter class extends the Staff class and overrides the takeOrder and serveOrder methods to include additional functionality specific to a waiter's role. However, more importantly, despite the differences in functionality, any code that expects an object of the Staff class can also work correctly with an object of the Waiter class.

Let us see an example to understand this

public class Cafe {
    public void serveCustomer(Staff staff) {
        staff.takeOrder();
        staff.serveOrder();
    }
}

public class Main {
    public static void main(String[] args) {
        Cafe cafe = new Cafe();
        Staff staff1 = new Staff("John", "Staff");
        Waiter waiter1 = new Waiter("Jane");
        
        restaurant.serveCustomer(staff1); // Works correctly with Staff object
        restaurant.serveCustomer(waiter1); // Works correctly with Waiter object
    }
}

Here the method serveCustomer() in the class Cafe, akes a Staff object as a parameter. The serveCustomer() method calls the takeOrder() and serveOrder() methods of the Staff object to serve the customer.

In the Main class, we create a Staff object and a Waiter object. We then call the serveCustomer() method of the Cafe class twice - once with the Staff object and once with the Waiter object.

Because the Waiter class is derived from the Staff class, any code that expects an object of the Staff class can also work correctly with an object of the Waiter class. In this case, the serveCustomer() method of the Cafe class works correctly with both the Staff object and the Waiter object, even though the Waiter object has additional functionality specific to a waiter's role.

Interface Segregation

Classes shouldn't be forced to depend on methods they don't use.

So there is this very versatile vending machine at the cafe which can dispense coffee, tea, snacks, and soda.

What is wrong with it? Nothing technically. If you have to implement the interface for any of the functions like dispensing coffee, you need to implement other methods meant for tea, soda, and snacks too. This is unnecessary and these functions are not related to each other functionality. Each of those functions has very less cohesion between them.

What is cohesion? It is a factor that determines how strongly the methods in an interface related to each other.

And in the case of the vending machine, the methods are hardly interdependent. We can segregate the methods since they have very low cohesion.

Now, any interface which is meant to implement one thing ,must only implement takeMoney() which is common for all the functions. This separates the unrelated functions in an interface hence avoiding forcefully implementing unrelated functions in an interface.

Dependency Inversion

High level modules should not depend on low level modules. Details must depend on abstractions.

Consider the air conditioner(cooler) at the cafe. And if you are like me, it is always freezing in there. Let's look at the remote control and the AC classes.

Here, remoteControl is the high level module which depends on AC, the low level component. If I get a vote, I would want a heater too :P So in order to have a generic temperature regulation, rather than cooler, let us decouple remote and temperature control. But the remoteControl class is tightly coupled with AC, which is a concrete implementation. To decouple the dependency, we can make an interface which has functions of just increaseTemp() and decreaseTemp() within a range of ,say , 45-65 F.

Final Thoughts

As you can see, both the high-level and low-level modules depend on an interface that abstracts the functionality of increasing or decreasing temperature.

The concrete class, AC, implements the methods with the temperature range applicable.

Now I can probably get that heater I want implementing different temperature ranges in a different concrete class called heater.

The high-level module , remoteControl only has to worry about calling the right method during run time.