SOLID is groups of 5 principles that is used to build a better object-oriented code. These principles are:

  1. Single Responsibility principle
  2. Open-Closed principle
  3. Liskov substitution principle
  4. Interface segregation principle
  5. Dependency inversion principle

1. Single Responsibility Principle

It states that a class should have only one responsibility. If a class has more than one responsibilities, it should be divided into separate class.

Example:

Say, we want to implement a simple program to interact with the library catalog system. For that, let’s create a python class Book to represent a single book with attributes: title, author, genre, availability(whether the book is available for borrowing or not).

class Book:
def __init__(self, title, author, isbn, genre, availability):
self.title = title
self.author = author
self.isbn = isbn
self.genre = genre
self.availability = availability

def get_detail(self):
return (self.title, self.author, self.isbn, self.genre, self.availability)

Now, that we created a class for book, let’s create a LibraryCatalog class to manage the collection of books with following functionalities:

  1. add books by storing each book objects.
  2. get book details and get all books from the list of objects
class LibraryCatalog:
def __init__(self):
self.books_list = []

def add_book(self, book):
self.books_list.append(book)

def get_book_details(self, title):
for book in self.books_list:
if book.title == title:
return book.get_detail()
return "Book not found in library catalog"

def get_all_books(self):
if len(self.books_list) == 0:
return "No books available in the catalog"
return [book.get_detail() for book in self.books_list]

Now, we need a book borrowing process, let’s implement this resposibility with BookMangaer Class. Here, we have to check the availability of the book before borrowing them and make them available to borrow after returning them.

class BookManager:
"""
class for borrowing and returning book
"""
def __init__(self, library_catalog:LibraryCatalog):
self.books_list = library_catalog.books_list

def borrow_book(self, title):
for book in self.books_list:
if book.title == title:
if book.availability:
book.availability = False
print(f"{title} is borrowed.")
else:
print(f"{title} is not available")

print("f{title} is not found in library catalog")

def return_book(self, title):
for book in self.books_list:
if book.title == title:
if not book.availability:
book.availability = True
print(f"{title} is returned.")
else:
print(f"{title} is already available")

print("f{title} is not found in library catalog")

We have divided the responsibility of borrowing/returning, add/get book details and book details into different class. It follows the single responsibility principle.The overall implementation is as follow:

class Book:
def __init__(self, title, author, isbn, genre, availability):
self.title = title
self.author = author
self.isbn = isbn
self.genre = genre
self.availability = availability

def get_detail(self):
return (self.title, self.author, self.isbn, self.genre, self.availability)

class LibraryCatalog:
def __init__(self):
self.books_list = []

def add_book(self, book):
self.books_list.append(book)

def get_book_details(self, title):
for book in self.books_list:
if book.title == title:
return book.get_detail()
return "Book not found in library catalog"

def get_all_books(self):
if len(self.books_list) == 0:
return "No books available in the catalog"
return [book.get_detail() for book in self.books_list]

class BookManager:
"""
class for borrowing and returning book
"""
def __init__(self, library_catalog:LibraryCatalog):
self.books_list = library_catalog.books_list

def borrow_book(self, title):
for book in self.books_list:
if book.title == title:
if book.availability:
book.availability = False
print(f"{title} is borrowed.")
else:
print(f"{title} is not available")

print("f{title} is not found in library catalog")

def return_book(self, title):
for book in self.books_list:
if book.title == title:
if not book.availability:
book.availability = True
print(f"{title} is returned.")
else:
print(f"{title} is already available")

print("f{title} is not found in library catalog")


catalog = LibraryCatalog()
book_manager = BookManager(catalog)

book1 = Book("Harry Potter and the Philosopher's Stone", "J.K. Rowling", "9780747532743", "Fantasy", True)
book2 = Book("The Great Gatsby", "F. Scott Fitzgerald", "9780743273565", "Classic", True)
catalog.add_book(book1)
catalog.add_book(book2)

print()
print("get all books")
print(catalog.get_all_books())

print()
print("get individual book detail")
book_title = "The Great Gatsby"
print(catalog.get_book_details(title=book_title))

print()
print("BORROW BOOK")
book_manager.borrow_book(book_title)
print(catalog.get_book_details(title=book_title))

print("\n")
print("RETURN BOOK")
book_manager.return_book(book_title)
print(catalog.get_book_details(title=book_title))

2. Open Closed principle

Open closed principles states that software entities(classes, modules, functions, etc.) should be open for extension, but closed for modification.

Here, Product class is implemented and total price is calculated based on the price of the product.

class Product:
def __init__(self, price):
self.price = price

def calculate_total_price(products):
total_price = 0
for product in products:
total_price += product.price
return total_price

# Using the calculate_total_price function with a list of products
products = [Product(100), Product(50), Product(75)]
print("Total Price:", calculate_total_price(products))

Let’s say, we have to add a discount features, where some products might have a discount applied to their prices. To add this feature, we would need to modify the Product class and calculate_total_price function. This violates the open closed principle. So to incorporate the discount features, we can create a new class Discount that can provide the discount for the products as shown below.

class Product:
def __init__(self, price):
self.price = price


def calculate_total_price(products):
total_price = 0
for product in products:
total_price += product.price
return total_price


class Discount:
def __init__(self, product: Product, discount_percent: float):
"""
discount_percent(float) = [0,..., 1]
"""
self.product = product
self.discount_percent = discount_percent

def give_discount(self):
self.product.price = self.product.price * (1 - self.discount_percent)


# Using the calculate_total_price function with a list of products
products = [Product(200), Product(100), Product(50)]
print("Total Price before discount:", calculate_total_price(products))

# apply discount
discount_percent = 0.2
for product in products:
Discount(product, discount_percent).give_discount()

print("Total Price after discount:", calculate_total_price(products))

3. Liskov Substitution Principle(LSP)

It states that Sub types must be substitutable for their base types. It means objects should be replaceable by their subtypes without altering how the program works.

Example: Below is an implementation of a banking system for account handling. There is a savings account and a checking account class. The cheking account inherits the savings account as both have the same functionality and the checking account allows overdrafts (allow processing transactions even if there is not sufficient balance). Now, we are going to redisgn the following program such that it follows the Liskov Substitution Principle.

class SavingsAccount():
def __init__(self, balance) -> None:
self.balance = balance

def withdraw(self, amount):
# Savings account does not allow overdrafts
if amount <= self.balance:
self.balance -= amount
print(f"Withdrew ${amount}. Remaining balance: ${self.balance}")

else:
print("Insufficient funds!")

class CheckingAccount(SavingsAccount):
def __init__(self, balance, overdraft_limit):
super().__init__(balance)
self.overdraft_limit = overdraft_limit

def withdraw(self, amount):
# Checking account allows overdrafts but with a limit
if amount <= self.balance + self.overdraft_limit:
self.balance -= amount
print(f"Withdrew ${amount}. Remaining balance: ${self.balance}")
else:
print("Exceeds overdraft limit or insufficient funds!")

def perform_bank_actions(account):
account.withdraw(100)
account.withdraw(200)
account.withdraw(500)

if __name__ == "__main__":
# Creating instances of SavingsAccount and CheckingAccount
savings_account = SavingsAccount(500)
checking_account = CheckingAccount(1000, overdraft_limit=200)

# Performing actions on both accounts
perform_bank_actions(savings_account)
perform_bank_actions(checking_account)

In this example, we can’t substitute the instance of savings account to the checking account. Even though, a checking account is a specific type of a saving account, the classes that represent those account shouldn’t be in a parent-child relationship if LSP is followed.

To follow LSP, we can create a base class for both savings account and checking account as follow:

from abc import ABC, abstractmethod

class Account(ABC):
@abstractmethod
def withdraw(self):
pass

class SavingsAccount(Account):
def __init__(self, balance) -> None:
self.balance = balance

def withdraw(self, amount):
# Savings account does not allow overdrafts
if amount <= self.balance:
self.balance -= amount
print(f"Withdrew ${amount}. Remaining balance: ${self.balance}")

else:
print("Insufficient funds!")


class CheckingAccount(Account):
def __init__(self, balance, overdraft_limit):
self.balance = balance
self.overdraft_limit = overdraft_limit

def withdraw(self, amount):
# Checking account allows overdrafts but with a limit
if amount <= self.balance + self.overdraft_limit:
self.balance -= amount
print(f"Withdrew ${amount}. Remaining balance: ${self.balance}")
else:
print("Exceeds overdraft limit or insufficient funds!")


def perform_bank_actions(account):
account.withdraw(100)
account.withdraw(200)
account.withdraw(500)


if __name__ == "__main__":
# Creating instances of SavingsAccount and CheckingAccount
savings_account = SavingsAccount(500)
checking_account = CheckingAccount(1000, overdraft_limit=200)

# Performing actions on both accounts
perform_bank_actions(savings_account)
perform_bank_actions(checking_account)

4. Interface Segregation Principle(ISP)

The main idea regarding ISP is Clients should not be forced to depend upon methods that they donot use. Interfaces belongs to clients, not to hierarchies.

Example:

Suppose we have an interface called PaymentProcessor that defines methods for processing payments and refunds. Then, we have a class called OnlinePaymentProcessor that implements the PaymentProcessor interface. However, some parts of our system only need to process payments and don’t need to handle refunds.

from abc import ABC, abstractmethod

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

@abstractmethod
def process_refund(self, amount):
pass

class OnlinePaymentProcessor(PaymentProcessor):
def process_payment(self, amount):
print(f"Processing payment of ${amount}")

def process_refund(self, amount):
print(f"Processing refund of ${amount}")

Now, we need to redesign this program such that it follows the ISP principle.

from abc import ABC, abstractmethod


class ProcessPayment(ABC):
@abstractmethod
def process_payment(self):
pass


class ProcessFund(ABC):
@abstractmethod
def process_refund(self):
pass


class ProcessPayOnly(ProcessPayment):
def process_payment(self, amount):
print(f"Processing payment of ${amount}")


class ProcessFundOnly(ProcessFund):
def process_refund(self, amount):
print(f"Processing refund of ${amount}")


class ProcessPayFundBoth(ProcessPayment, ProcessFund):
def process_payment(self, amount):
print(f"Processing payment of ${amount}")

def process_refund(self, amount):
print(f"Processing refund of ${amount}")

Here, we created two separate class for payment and fund process and inherit them as required for different part of the system and so client is not forced to depend on methods they don’t use as per this implementation. Hence, ISP is followed.

5. Dependency Inversion Principle(DIP)

It states, “Abstractions should not depend upon details. Details should depend upon abstractions.”

Example: Suppose, we have a NotificationService class that is responsible for sending notifiction. The NotificationService class directly depends on the EmailSender Class to send emails. Hence, it violates the Dependency Inversion Principle. The high level NotificationService should not depend on the low-level EmailSender class, as it tightly couples the classes together.

class EmailSender:
def send_email(self, recipient, subject, message):
# Code to send an email
print(f"Sending email to {recipient}: {subject} - {message}")

class NotificationService:
def __init__(self):
self.email_sender = EmailSender()

def send_notification(self, recipient, message):
self.email_sender.send_email(recipient, "Notification", message)

# Using the NotificationService to send a notification
notification_service = NotificationService()
notification_service.send_notification("user@example.com", "Hello, this is a notification!")

Suppose, we have to incorporate the SMSSender too in the above implementation. Then, due to tight dependency between the NotificationService and the EmailSender, it becomes a necessity to change the implemenation of NotificationService. So, it is not very scalable. Hence, we need to apply dependency inversion principle such that we can add different kinds of Notification Sender like EmailSender, SMSSender, etc. as done in the following implementation.

from abc import ABC, abstractmethod


class MessageSender(ABC):
@abstractmethod
def send_message(self, recipient, message):
pass


class EmailSender(MessageSender):
def send_message(self, recipient, message):
print(f"Sending email to {recipient}: {message}")


class SMSSender(MessageSender):
def send_message(self, recipient, message):
print(f"Sending SMS to {recipient}: {message}")


class NotificationService:
def __init__(self, sender: MessageSender) -> None:
self.sender = sender

def send_notification(self, recipient, message):
self.sender.send_message(recipient, message)


email_sender = EmailSender()
notification_service = NotificationService(email_sender)
notification_service.send_notification(
"abcd@fusemachines.com", "this is a notification"
)

sms_sender = SMSSender()
notification_service = NotificationService(sms_sender)
notification_service.send_notification("9843123122", "this is a notification")class MessageSender(ABC):
@abstractmethod
def send_message(self, recipient, message):
pass

Conclusion

The SOLID principles are fundamental guidelines for writing clean, maintainable, and scalable object-oriented code. By following these principles — Single Responsibility, Open-Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion — developers can avoid code smells, reduce tight coupling, and make their systems more adaptable to change. Understanding and applying SOLID principles ensures that your code remains robust and easy to extend as requirements evolve. Thank you for reading!