SOLID Principles For Structuring Your Code

Ikramullah
7 min readMar 4, 2023

--

When developing a program, some programmers tend to just code as they go without giving it a second thought on how they wrote the program. There are various reasons for this such as limited time ( this is most likely the main cause ), lazy, the complexity of the program they already made or obliviousness, the list goes on. On short term this approach seems to be the most enjoyable way to write a code, but on the long run, it can be quite taxing to understand the code.

How to solve this problem?

The problem is basically how we will structure our program. Structuring is the most important and complex to come up with as we can’t tell what would be the best way to structure our code. This is because we came up with solutions and ideas incrementally, so the missing building blocks are not there yet when we first wrote it. So, we need something that can handle the dynamics and changes that happens when we wrote a program.

The easiest way would be to follow a certain propositions that can act as a system of belief for reasoning. Good news for us, some people ( Rober C. Martin ) already thought about these propositions and one of them is SOLID Principle.

SOLID?

SOLID is a nice acronym because it is easy to remember and align with our target, a solid structure code. Let’s us walkthrough one by one, starting from S, Single Responsibility Principle.

Single Responsibility

from the definition, we would have.

A class should have one and only one reason to change

First of all, we need to understand the change part, what does change mean? the changes in here refers to the functionality, behavior, or implementation of the class. The second part is reason, it is the cause that made the class to change. so we only need same type of cause that made the class change. If we had more than one, then we violates the Single Responsibility principle.

Let’s look at an example.

class User:
def __init__(self, name, email,database):
self.name = name
self.email = email
self.database = database

def send_email(self, message):
# code to send email
email.connect()
email.send(message)

def save_to_database(self):
# code to save user data to database
self.database.connect()
self.database.save(name)

At first glance, it might seem okay to let this user class to have a send_email and save_to_database methods. But, what if there are changes in the email class? then, the user class also need to change the send_email method, that is one reason. Next, the database class also change, the user also need to change save_to_database method, that is two reason. Imagine if we did this not only to user, but other classes, it became highly coupled and small changes causes the entire code to change.

That is the reason why we need the class to only have one reason to change, so that new changes can be done easily

class User:
def __init__(self, name, email):
self.name = name
self.email = email

class EmailManager:
def __init__(self):
# code to initialize email manager
pass
def send_email(self, message, recipient):
# code to send email
pass

class DatabaseManager:
def __init__(self):
# code to initialize database manager
pass
def save_to_database(self, data):
# code to save data to database
pass

With this, each class has only one responsibility. This is much easier to change compared to the previous one. The changes of database doesn’t impact the User or EmailManager and vice versa.

Open Closed

Objects or entities should be open for extension but closed for modification.

Extension from that statement means that we could add new features or implementations. But, to modify the base implementation needs to be minimal. The term closed might make you think that we are not allowed to change the base code at all, this in fact is not true ( imagine if there is a bug on that particular feature, we need to change it obviously ).

Here is an example of code that violates Open Closed Principle.

class Shape:
def __init__(self, x, y):
self.x = x
self.y = y

class Circle(Shape):
def __init__(self, x, y, radius):
super().__init__(x, y)
self.radius = radius

class Square(Shape):
def __init__(self, x, y, side_length):
super().__init__(x, y)
self.side_length = side_length

def calculate_area(shapes):
total_area = 0
for shape in shapes:
if isinstance(shape, Circle):
total_area += 3.14 * shape.radius ** 2
elif isinstance(shape, Square):
total_area += shape.side_length ** 2
return total_area

As you can see, each time you add new Shape ( Rectangle, Octagon, etc ), the calculate area needs to add new if else statement in order to handle the new shape. This is an extension but at the same time modifying the existing code. Instead, what you could do is add a method area on each Shape class and call that method on the calculate_area method.

class Shape:
def __init__(self, x, y):
self.x = x
self.y = y
def area(self):
pass

class Circle(Shape):
def __init__(self, x, y, radius):
super().__init__(x, y)
self.radius = radius
def area(self):
return 3.14 * self.radius ** 2

class Square(Shape):
def __init__(self, x, y, side_length):
super().__init__(x, y)
self.side_length = side_length
def area(self):
return self.side_length ** 2
def calculate_area(shapes):
total_area = 0
for shape in shapes:
total_area += shape.area()
return total_area

We actually modify the calculate_area method, but we don’t always do it when a new shape is added, we just modify it one time and the rest is pretty much just extending new features.

This approach reduces the amount of changes that required when adding new features and latent bugs that could show up ( imagine if we forgot to add if else statement on the calculate_area ).

Liskov Substitution

Let q(x) be a property provable about objects of x of type T. Then q(y) should be provable for objects y of type S where S is a subtype of T.

First of all, what is a property? property is basically a statement that can be expressed as true or false, depending on the x value or canbe called as a predicate. On programming context, q(x) would be a method. What is x? x is an object. type T will be the class.

second, what does property provable mean? property provable means that there is a mathematical proof that holds true for all objects of a given type or subtype. Which means, the statement is consistent for any x value.

For example, the statement “every odd number is prime” is true for 3,5,7 but not for 9. Therefore this statement is not provable.

Basically, what this statement is saying is that a method that applies for object x should also apply for all the objecty which is a subtype of T.

class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def get_area(self):
return self.width * self.height

class Square(Rectangle):
def __init__(self, side_length):
super().__init__(side_length, side_length)
def set_width(self, width):
self.width = width
self.height = width
def set_height(self, height):
self.width = height
self.height = height

The problem lies on the get_area property ( method ). Not every Rectangle area formula is width * height, so this property is unprovable. Since this is the case, this won’t apply for Square even though Square is a subtype of Rectangle as the area of square is either width * width or height * height.

The solution would be to create an interface Shape that has a property of area. then, Rectangle and Square implements the area property.

Interface Segregation

A client should never be forced to implement an interface that it doesn’t use, or clients shouldn’t be forced to depend on methods they do not use.

What does client refers to? in this context client means a class or interface that implements an interface. Since this is pretty straightforward, we will directly go to code example.

class Animal:
def speak(self):
pass
def fly(self):
pass

class Dog(Animal):
def speak(self):
return "Woof!"

class Bird(Animal):
def speak(self):
return "Chirp!"
def fly(self):
return "I'm flying!"

class Fish(Animal):
def speak(self):
return "Blub!"
d = Dog()
print(d.speak()) # Output: "Woof!"
b = Bird()
print(b.fly()) # Output: "I'm flying!"
f = Fish()
print(f.fly()) # Error: 'Fish' object has no attribute 'fly'

This code violates the interface segregation. The reason is because the class dog is forced to implement an interface that it has no use of.

One way to fix it is by separating the fly method onto another Class ( FlyingAnimal for example ).

Dependency Inversion

Entities must depend on abstractions, not on concretions.

What this statements basically says is that concrete class should only depend on abstract class, not the other way around. The reason being is that it most of the time we don’t really care about the implementations rather the functionality of a class.

These concepts is quite similar to math where you define x as a variable that holds value as number. Instead of thinking 1,2,3,… you think about it’s properties, that is you can add, subtract, divide, and so on.

Thus, it is much easier to change implementations since we only care about it’s properties. To make it easier, let’s look at an example.

import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart

class EmailSender:
def send_email(self, to_address, subject, body):
msg = MIMEMultipart()
msg['To'] = to_address
msg['Subject'] = subject
msg.attach(MIMEText(body, 'plain'))
smtp_server = smtplib.SMTP('smtp.gmail.com', 587)
smtp_server.starttls()
smtp_server.login('username', 'password')
smtp_server.sendmail('username', to_address, msg.as_string())
smtp_server.quit()

class UserService:
def __init__(self):
self.email_sender = EmailSender()
def create_user(self, username, email, password):
# Create the user...
# Send a welcome email
subject = "Welcome to MySite"
body = f"Dear {username}, welcome to MySite!"
self.email_sender.send_email(email, subject, body)

You can see that UserService directly uses the EmailSender concrete implementation, if we wanted to change using other email it can be quite hard to change it. To handle this, we first should create an interface of EmailSender and have UserService use that interface.

Conclusion…

SOLID principles can be used as a guide for us when we write down our code. We don’t have to think on how to structure the code correctly, we just need to make sure that our code doesn’t violate the principles. As we follow these principles, the structure will emerge and sometimes this structure is so common that it has a pattern. It is called as design pattern. Design pattern should be our next topic to learn after understanding the principles so that we could understand the reasoning of those patterns.

Each time you write down a code, try to ask yourself whether it violates the principles. For example.

  • Single Responsibility, does my code need more than one reason to change?
  • Open Closed, does my code requires new changes as i add new implementation?
  • Liskov Substitution, does the properties that i have defined holds true for all subtypes?
  • Interface Segregation, does my code needs to implement method it has no use for?
  • Dependency Inversion, does my code directly depends on concrete classes?

I hope you find this article useful.

Thanks for reading 🙏

--

--