“SOLID Principles Decoded: Unveiling the essential software designs.

Santhoshsivan Vallatharasu
8 min readNov 12, 2023

--

Picture from freeCodeCamp

SOLID is an acronym that represents the five essential and practical design principles used by senior software developers when writing object-oriented programming in software industry. Junior developers may not prioritize these principles during the initial stages of thier careers, But these design principles play a crucial role in ensuring the structure of their code.

The SOLID principles were introduced by Robert C. Martin, popularly known as Uncle Bob in the software design field. While there are many software design principles in programming, here you will learn about the five principles commonly known as SOLID in industry standards.

  1. Single Responsibility Principle
  2. Open Closed Principle
  3. Liskov Substitution Principle
  4. Interface Segregation Principle
  5. Dependency Inversion Principle

Single Responsibility Principle:

In simple terms, the Single Responsibility Principle (SRP) states that -

“A class or module should have only one reason to change.”

This principle encourages a class to have a focused and well-defined responsibility, addressing a specific concern. When designing a class, one needs to be focused on doing one thing and doing it well. Suppose the class performs more than one different functionality; then, it would be violating the SRP.

The primary motivation behind the SRP is to reduce the impact of changes and maintenance. By adhering to this principle, you can maintain a clean and organized codebase. Moreover, it becomes notably easier to write focused unit tests.

Let’s consider the examples below: Example 1, which violates the Single Responsibility Principle (SRP) and, Example 2, which adheres to it.

Example 1:

class Employee:
def __init__(self, name, id):
self.name = name
self.id = id
def calculate_salary(self, hours_worked):
# Calculate the salary based on hours worked, pay rate, and deductions pass def save_to_database(self):
# Save employee data to the company's database pass
pass

In the above example, there is a class called ‘Employee’ representing employees in a company.

The ‘Employee’ class violates SRP because it has two distinct responsibilities:

  1. Calculating an employee’s salary
  2. Saving employee data to a database.

If either of these responsibilities changes, then need to modify this class is necessary.

Example 2:

class Employee: 
def __init__(self, name, id):
self.name = name
self.id = id

class SalaryCalculator:
def calculate_salary(self, employee, hours_worked):
# Calculate the salary based on hours worked, pay rate and deductions
pass

class DatabaseSaver:
def save_to_database(self, employee):
# Save employee data to the company's database
pass

Refactor the ‘Employee’ class to adhere to SRP by splitting its responsibilities into separate classes.

By adhering to SRP, we’ve made the code more modular and maintainable. Each class has a single responsibility, and changes to one aspect of the system are less likely to impact the others.

Open-Closed Principle:

The Open-Closed Principle was introduced by Bertrand Meyer in 1988. it is an essential practice to follow while programming.

OCP conveys that,

Software entities ( classes, modules, functions, etc.) should be open for extension but closed for modification.”

In simple terms, the Open-Closed Principle means that when designing and implementing software modules (such as classes or functions), they should be extendable to change or add new features without requiring modifications to their existing source code or base code. This promotes flexible and maintainable software design.

This principle emphasizes that once you have completed and tested your modules, you should avoid making any changes to that source code. Altering thoroughly tested code can lead to unintended bugs or side effects.

Let’s see the example below , which consists of class components such us Order and ShippingCalculator.

class Order:
def __init__(self, total_amount):
self.total_amount = total_amount

class ShippingCalculator:
def calculate_shipping_cost(self, order):
# Assuming some logic to calculate shipping cost
return order.total_amount * 0.1

Now, let’s consider the scenario where we want to introduce a new shipping method, for example, express shipping. If we modify the existing class as shown below:

class ShippingCalculator:
def calculate_shipping_cost(self, order, shipping_method):
if shipping_method == "standard":
return order.total_amount * 0.1
elif shipping_method == "express":
# Logic to calculate express shipping cost

The above modification violates the Open-Closed Principle because it is not conductive to extension and is not closed. To adhere to the Open-Closed Principle, a new class should be created for express shipping.

class ExpressShippingCalculator:
def calculate_shipping_cost(self, order):
# Logic to calculate express shipping cost
return order.total_amount * 0.2 # Simplified example for illustration

class ShippingCalculator:
def calculate_shipping_cost(self, order, shipping_calculator):
return shipping_calculator.calculate_shipping_cost(order)

In this example, the Open-Closed Principle is followed by creating a new class ExpressShippingCalculator for the new shipping method without modifying the existing ShippingCalculator class. This allows us to extend the functionality for new shipping methods without altering the existing code.

3. Liskov Substitution Principle

In object-oriented programming and design, the Liskov Substitution Principle was introduced by Barbara Liskov in 1987. The Liskov substitution principle is defined as follows:

“The child class objects should be able to substitute in the place of base class objects without altering the behavior/characteristics of the program.”

The main idea behind this principle is to ensure the sub types contain the same contracts and behavioural expectations as their base types. By adhering to the LSP, that base class objects provides a more flexiable and adaptable nature to the child class objects. This principle promotes code reusability, maintainability, and robustness in software systems. It’s a critical concept for creating well-structured and predictable object-oriented designs.

# Base Class
class Shape:
def area(self):
pass

class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height

def area(self):
return self.width * self.height

class Square(Rectangle):
def __init__(self, side):
super().__init__(side, side)

# Function that calculates the area of a shape
def calculate_area(shape):
return shape.area()

# Using the function with instances of base and derived classes
rectangle = Rectangle(width=5, height=10)
square = Square(side=7)

print(calculate_area(rectangle)) # Output: 50
print(calculate_area(square)) # Output: 49

In this example,

There is a base class ‘Shape’ with a method ‘area’ and derived class ‘Rectangle’ that inherits from ‘Shape’ and implements its own ‘area’ method. we have another class ‘Square’ that inherits from ‘Rectangle’ and overrides the ‘__init__’ method to ensure both width and height are the same.

The calculate_area function takes an object of type Shape and calls its area method. We can use this function with both a Rectangle and a Square showcasing the Liskov Substitution Principle.

The key idea is that a Square is substitutable for a Shape or a Reactangle, and using a Square in place of a Reactangle does not break the expected behavior.

4. Interface Segregation Principle

The Interface Segregation Principle (ISP) is one of the SOLID principles of object-oriented programming. It was introduced by Robert C. Martin and focuses on the design of interfaces in a way that the client ( class ) is not forced to implement the unnecessary methods.

The principle expressed as follows:

“A class should not be forced to implement interfaces it does not use.”

In common programming scenarios, if an interface contains methods for all possible actions and it is implemented by a class, but that class doesn’t need to implement some of the methods from that interface, it can lead to Interface Pollution resulting in large interfaces with unrelated methods.

Its better to have several smaller, specific interfaces, each representing distinct funtionalities. Clients ( classes or modules that implement interfaces ) should not be obligated to implement methods they do not need.

The following signs represent interface pollution in a class:

  1. Classes have empty method implementations.
  2. Method implementations throw UnsupportedOperationException.
  3. Method implementations return null or default/dummy values.
  4. The adherence to the Interface Segregation Principle leads to highly cohesive interfaces.

Here’s an example to illustrate the Interface Segragation Principle:

# Incorrect design violating ISP

class Worker:
def work(self):
pass

def eat(self):
pass

# The class implementing this interface is forced to provide both work and eat methods,
# even if it doesn't need both.

class SuperWorker(Worker):
def work(self):
# Do some super work
pass

def eat(self):
# Eat a super meal
pass

In this example, a worker interface has methods for both working and eating. However, when you have a SuperWorker class that implements this interface, it's forced to provide implementations for both work and eat, even if it doesn't need the eating functionality.

Now, let’s correct this using the Interface Segregation Principle

# Correct design following ISP

class Workable:
def work(self):
pass

class Eatable:
def eat(self):
pass

# Now, a class can choose to implement only the interfaces it needs.

class Worker(Workable, Eatable):
def work(self):
# Do some work
pass

class SuperWorker(Workable, Eatable):
def work(self):
# Do some super work
pass

def eat(self):
# Eat a super meal
pass

In this corrected design, we have separate interfaces Workable and Eatable representing specific functionalities. Classes can implement only the interfaces they need, adhering to the Interface Segregation Principle.

5 . Dependency Inversion Principle

In SOLID principles, the Dependency Inversion Principle is the final principle and was introduced by Robert C. Martin. This principle mainly focuses on reducing the coupling between high-level modules and low-level modules in a software system.

The Dependency Inversion Principle is defined as follows:

  1. High-Level modules should not depend on low-level modules. Both should depend on abstractions (Interface or abstract classes).
  2. Abstraction should not depend on details. Details should depend on abstractions.

In simple way, the principle suggests that the direction of dependency should be inverted. Instead of high-level modules depends on low-level modules, both should depend on abstractions such us interfaces or abstract classes. This inversion allows for greater maintainability and ease of changes in the system.

Key Concepts:

  • High-level modules: Modules that contain the more abstract, policy-related, and business logic.
  • Low-level modules: Modules that deal with more detailed, implementation-specific aspects.
  • Abstractions: Interfaces or abstract classes that define the contract between high-level and low-level modules.

By adhering to the Dependency Inversion Principle, systems become more moduler and changes in low-level modules do not affect high-level modules

Let’s see the simple example involving a reporting system.

Without adhering to the Dependency Inversion Principle (DIP), and then with its application.

Without DIP:

# High-level module (Business Logic)
class ReportGenerator:
def generate_report(self,data):
return f"Report: {data}"

# Low-level module ( Data Source )
class Database:
def fetch_data(self):
return "Data from database"

# High-level module directly depends on low-level module
report_generator = ReportGenerator()
data = Database().fetch_data()
report = report_generator.generate_report(data)
print(report)

In this example, ReportGenerator (high-level) directly depends on Database (low-level), violating the Dependency Inversion Principle.

# Abstraction (Interface)
class DataSource:
def fetch_data(self):
pass

# High-level module depends on abstraction
class ReportGenerator:
def __init__(self, data_source):
self.data_source = data_source

def generate_report(self):
data = self.data_source.fetch_data()
# Logic to generate a report
return f"Report: {data}"

# Low-level module implements the abstraction
class Database(DataSource):
def fetch_data(self):
# Logic to fetch data from a database
return "Data from database"

# Dependency injection to invert the dependency
report_generator = ReportGenerator(Database())
report = report_generator.generate_report()
print(report)
  1. We introduced an DataSource abstraction (Interface).
  2. The ReportGenerator high-level module depends on the Database abstraction, not directly on the Database low-level module.
  3. The Database class implements the DataSource abstraction.
  4. Dependency injection is used to provide the Database instance to the ReportGenerator .

Now, the high-level module ( ReportGenerator ) is not directly coupled to the low-level module ( Database ) , adhering to the Dependency Inversion Principle. If you want to switch to a different data source, create a new class implementing DataSource without modifying the ReportGenerator class.

--

--