SOLID Principles: Best Practices for Writing Maintainable and Extensible Code in OOP

Arindam Das
9 min readApr 29, 2023

--

Image from Google Image

SOLID principles are a set of five design principles used in software development to make code more maintainable, flexible, and robust. These principles help developers create code that is easy to understand, modify, and extend. The SOLID acronym stands for:

  • 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)

In this article, we will go over each principle in detail and provide examples to illustrate their usage.

Single Responsibility Principle (SRP)

The Single Responsibility Principle (SRP) is one of the SOLID principles in software development. The principle states that a class should have only one reason to change or, in other words, a class should have only one responsibility. This means that a class should be responsible for only one thing and should not have multiple responsibilities or reasons to change.

By adhering to the SRP, we can create code that is more modular, easier to understand, and easier to maintain. When a class has a single responsibility, it becomes easier to modify, extend, and test that class, since we only need to change the code related to that specific responsibility.

For example, consider a class that is responsible for processing orders and generating invoices. If we change the way we generate invoices, we would need to modify this class, even though it is not directly related to order processing. This violates the SRP since the class has multiple responsibilities. Instead, we can split this class into two separate classes: one for processing orders and one for generating invoices. This way, we can modify each class independently without affecting the other, making the code more modular and easier to maintain.

Here’s an example of a class that violates the SRP:

class Employee:
def __init__(self, name: str, salary: int):
self.name = name
self.salary = salary

def calculate_pay(self, hours_worked: int):
# Calculate pay based on salary and hours worked
pass

def save_employee(self):
# Save employee data to database
pass

In this example, the Employee class has two responsibilities: calculating pay and saving employee data to a database. To adhere to the SRP, we can split this class into two separate classes: one for calculating pay and one for saving employee data.

class Employee:
def __init__(self, name: str, salary: int):
self.name = name
self.salary = salary

def calculate_pay(self, hours_worked: int):
# Calculate pay based on salary and hours worked
pass

class EmployeeDatabase:
def save_employee(self, employee: Employee):
# Save employee data to database
pass

By splitting the responsibilities into separate classes, we can modify each class independently without affecting the other. This makes the code more modular and easier to maintain, as well as making it easier to test each class separately.

Open-Closed Principle (OCP)

The Open-Closed Principle (OCP) is one of the SOLID principles in software development. The principle states that a class should be open for extension but closed for modification. This means that we should be able to extend the behavior of a class without modifying its source code. In other words, we should be able to add new features to a class without changing its existing code.

The OCP is important because it helps us create code that is more maintainable and less error-prone. When we modify existing code, there is a risk that we may introduce bugs or unintended side-effects. By following the OCP, we can avoid this risk by only adding new code instead of modifying existing code.

One common way to follow the OCP is by using the strategy pattern. The strategy pattern is a design pattern that allows us to define a family of algorithms, encapsulate each one, and make them interchangeable. This way, we can define a set of behaviors in a class, and then use different strategies to change its behavior at runtime without modifying the class’s code.

Here’s an example of a class that violates the OCP:

class Payment:
def __init__(self, amount: int, method: str):
self.amount = amount
self.method = method

def pay(self):
if self.method == 'credit_card':
# Process credit card payment
pass
elif self.method == 'bank_transfer':
# Process bank transfer payment
pass

In this example, the Payment class has two payment methods: credit card and bank transfer. However, if we want to add a new payment method, such as PayPal, we would need to modify the Payment class. This violates the OCP since we are modifying the existing code instead of extending it.

To adhere to the OCP, we can use the strategy pattern to encapsulate the payment methods into separate classes:

class Payment:
def __init__(self, amount: int, method: str, payment_processor):
self.amount = amount
self.method = method
self.payment_processor = payment_processor

def pay(self):
self.payment_processor.process_payment(self.amount)

class CreditCardProcessor:
def process_payment(self, amount: int):
# Process credit card payment
pass

class BankTransferProcessor:
def process_payment(self, amount: int):
# Process bank transfer payment
pass

class PayPalProcessor:
def process_payment(self, amount: int):
# Process PayPal payment
pass

In this example, we’ve created separate classes for each payment method, and we’ve encapsulated the payment processing logic into each class. We’ve also introduced a payment_processor parameter in the Payment class constructor, which allows us to pass in a specific payment processor at runtime.

By using the strategy pattern, we can add new payment methods by creating a new payment processor class and passing it into the Payment class constructor. This way, we are extending the behavior of the Payment class without modifying its existing code, which adheres to the OCP.

Liskov Substitution Principle (LSP)

The Liskov Substitution Principle (LSP) is one of the SOLID principles in software development. The principle states that objects of a superclass should be replaceable with objects of its subclasses without breaking the program’s correctness. In other words, a subclass should be able to substitute for its superclass without causing any unexpected behavior or errors in the program.

The LSP is important because it helps us create code that is more flexible and maintainable. By following the LSP, we can write code that is easier to extend and modify without introducing unexpected behavior or errors.

To understand the LSP, let’s consider an example of a class hierarchy:

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):
super().__init__(side, side)

def set_side(self, side):
self.width = self.height = side

In this example, we have a Rectangle class and a Square class, where the Square class is a subclass of Rectangle. The Square class overrides the __init__ method to ensure that both the width and height are the same, making it a square. It also has a set_side method to set the side of the square.

However, this class hierarchy violates the LSP since a Square object cannot be substituted for a Rectangle object without breaking the program’s correctness. Specifically, the set_side method of the Square class changes both the width and height, which is not possible for a Rectangle object, where the width and height can be different.

To adhere to the LSP, we can refactor the class hierarchy to use a Shape class as a superclass and separate the Rectangle and Square classes:

class Shape:
def get_area(self):
pass

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

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

class Square(Shape):
def __init__(self, side):
self.side = side

def get_area(self):
return self.side * self.side

In this refactored code, we’ve separated the Shape class as a superclass and defined the get_area method as an abstract method. This ensures that any subclass of Shape must implement the get_area method. We’ve also implemented the Rectangle and Square classes as subclasses of Shape and implemented their own get_area methods.

With this new class hierarchy, we can now substitute a Rectangle object with a Square object, or vice versa, without breaking the program’s correctness. The Square object no longer has a set_side method, which ensures that it follows the same interface as the Rectangle object.

Interface Segregation Principle (ISP)

The Interface Segregation Principle (ISP) is one of the SOLID principles in software development. The principle states that no client should be forced to depend on methods it does not use. In other words, a class should not be required to implement interfaces or methods that it does not need.

The ISP is important because it helps us create code that is more modular and flexible. By following the ISP, we can avoid bloated interfaces and reduce the risk of introducing errors and complexity into our code.

To understand the ISP, let’s consider an example of a class hierarchy:

class Printer:
def print(self, document):
pass

class Scanner:
def scan(self, document):
pass

class Fax:
def fax(self, document, number):
pass

class AllInOnePrinter(Printer, Scanner, Fax):
def print(self, document):
# Implement print method
pass

def scan(self, document):
# Implement scan method
pass

def fax(self, document, number):
# Implement fax method
pass

In this example, we have a Printer, Scanner, and Fax class, each with their own methods. We also have an AllInOnePrinter class, which is a subclass of all three classes and implements all of their methods.

However, this class hierarchy violates the ISP since the AllInOnePrinter class is forced to implement all the methods of its superclass, even if it doesn’t need them. This can lead to bloated interfaces and unnecessary complexity in the code.

To adhere to the ISP, we can refactor the class hierarchy to use smaller, more focused interfaces:

class Printer:
def print(self, document):
pass

class Scanner:
def scan(self, document):
pass

class Fax:
def fax(self, document, number):
pass

class MultiFunctionDevice(Printer, Scanner):
pass

class AllInOnePrinter(MultiFunctionDevice, Fax):
def fax(self, document, number):
# Implement fax method
pass

In this refactored code, we’ve separated the Printer, Scanner, and Fax classes into their own interfaces. We’ve also defined a new MultiFunctionDevice interface that combines the Printer and Scanner interfaces. Finally, we’ve implemented the AllInOnePrinter class as a subclass of MultiFunctionDevice and Fax, and implemented only the fax method.

With this new class hierarchy, we can now create more specialized interfaces that only contain the methods that a class needs. This reduces the risk of introducing unnecessary complexity and errors into our code.

Dependency Inversion Principle (DIP)

The Dependency Inversion Principle (DIP) is one of the SOLID principles in software development. The principle states that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions. In addition, abstractions should not depend on details. Details should depend on abstractions.

In simpler terms, the DIP suggests that we should design our software systems in a way that high-level modules are not dependent on low-level modules. This is achieved by using interfaces or abstract classes to define contracts between modules. By doing so, we can create a more flexible and scalable architecture that is easy to maintain and modify.

To better understand the DIP, let’s consider an example of a class hierarchy:

class PaymentGateway:
def process_payment(self, amount):
pass

class CreditCardGateway(PaymentGateway):
def process_payment(self, amount):
# Implement credit card payment
pass

class PayPalGateway(PaymentGateway):
def process_payment(self, amount):
# Implement PayPal payment
pass

class ShoppingCart:
def __init__(self, payment_gateway: PaymentGateway):
self.payment_gateway = payment_gateway

def checkout(self, amount):
self.payment_gateway.process_payment(amount)

In this example, we have a PaymentGateway class that defines a common interface for processing payments. We also have two subclasses, CreditCardGateway and PayPalGateway, which implement the process_payment method for specific payment types. Finally, we have a ShoppingCart class that depends on the PaymentGateway class to process payments.

However, this class hierarchy violates the DIP since the ShoppingCart class is dependent on the concrete PaymentGateway classes. This makes the code inflexible and difficult to maintain, especially if we need to add more payment types in the future.

To adhere to the DIP, we can refactor the class hierarchy to use interfaces or abstract classes:

from abc import ABC, abstractmethod

class PaymentGateway(ABC):
@abstractmethod
def process_payment(self, amount):
pass

class CreditCardGateway(PaymentGateway):
def process_payment(self, amount):
# Implement credit card payment
pass

class PayPalGateway(PaymentGateway):
def process_payment(self, amount):
# Implement PayPal payment
pass

class ShoppingCart:
def __init__(self, payment_gateway: PaymentGateway):
self.payment_gateway = payment_gateway

def checkout(self, amount):
self.payment_gateway.process_payment(amount)

In this refactored code, we’ve defined an abstract PaymentGateway class that defines a common interface for processing payments. We’ve also defined CreditCardGateway and PayPalGateway subclasses that implement the process_payment method. Finally, we’ve updated the ShoppingCart class to depend on the PaymentGateway abstract class.

By using an abstract class to define the PaymentGateway contract, we can create a more flexible architecture that is easy to extend and modify. In addition, by depending on the abstract PaymentGateway class, the ShoppingCart class is not dependent on the concrete CreditCardGateway and PayPalGateway classes.

Conclusion

In summary, the SOLID principles are a set of design principles that encourage developers to write code that is more maintainable, extensible, and robust. By following these principles, we can create software systems that are easier to understand, test, and modify over time. While these principles may require more upfront effort to implement, they can save significant time and resources in the long run by reducing the complexity and fragility of software systems.

--

--