SOLID Principles: Vital for SDEs in creating scalable, robust applications

Rajagopal

9 min read

Hello readers. I hope you are all doing well. I think you must be reading this blog amidst  your daily routine of having a cup of coffee or you may be a fellow software developer who had just come to read this blog after its title, which you had heard long ago when you were pursuing your engineering. If you do so, yes! You are absolutely right. This blog is all about reminiscing those theories and Solid principles that you had come across during your First Degree or Higher Degree in CS.

Most of us will consider these theories to be the worst part ever that  we could have in our curriculum, and often we used to skip learning these long principles and proofs. But have you ever wondered that, you couldn’t even solve a simple math problem, when you are unaware of these golden theories.

Here is a small task for you. 

  • Print all the prime numbers between 0 to ‘n’, where ‘n’ > 0.

Classical approach might look like this.

class Solution():
	def PrimeNumbers(self, n: int) -> str:
           seq = “”
	   for i in range(2, n+1):
               for j in range(2,i):
	            if i%j == 0:
		        break
                    else:
                        seq += ‘ ’+str(i)
           return seq

This works fine, you could print a sequence of prime numbers between 0 to ‘n’. But if you notice the question clearly, you could see that there is no limitation for ‘n’. It can be any largest number that it could be. 

Lets try executing the same code with n = 100000.

Don’t you see any difference? Yes! It will take some time to print the results, as ‘n’ scales up. It might print the sequence quickly in the earlier execution, but this time, it couldn’t. 

You may even optimize the code with the .join() method instead of ‘+=’. But you could only get a decent decrease in execution time, but not to the least.

This is where theories, principles and algorithms come into play. If you had ever learned about Sieve of Eratosthenes, then this would become a piece of cake!

Sieve of Eratosthenes is the ideal algorithm for finding prime numbers between 0 to ‘n’, where ‘n’ can be any largest number. It executes the code in O(n loglog N) time complexity.

Most of the theories and principles introduced in Computer Science are an outcome of someone’s experience in solving a complex problem. 

Big Tech companies like Meta, Amazon, Apple, Netflix, Google (MAANG companies) are building their robust product, by following gold standard principles and algorithms. 

For example, if you have a friend working at Google, ask him/her about what exponential backoff is. https://cloud.google.com/storage/docs/retry-strategy

You may feel that I am emphasizing more on something else except talking about SOLID principles, and even you may end up in reading this blog. But what I am actually trying to emphasize  is, how crucial it is to understand and follow algorithms, principles and theories in order to be successful in building robust and scalable products.

SOLID principles is one of those theories,but there are also many other things out there to follow and as it is titled as “SOLID Principles”, I will be talking about it in this blog. 

Before getting into this, I assume that whoever is reading the blog, already has a good understanding in Object Oriented Programming and its characteristics.

I also would like to say that, SOLID principles will experience a blend of some of the OOPs characteristics, and I request readers not to confuse it with one another.

THE SOLID PRINCIPLE

“SOLID” is an acronym representing a set of design principles in object-oriented programming. Each letter in the acronym corresponds to one of the five principles:

  • Single Responsibility Principle (SRP)
  • Open/Closed Principle (OCP)
  • Liskov Substitution Principle (LSP)
  • Interface Segregation Principle (ISP)
  • Dependency Inversion Principle (DIP)

Single Responsibility Principle (SRP)

It is the first principle for building an application using OOPs, that states “A class should have only one reason to change.” According to this principle, a class should serve a single purpose or it should take a single responsibility, in such a way to provide smooth functionality.

For Example,

Consider the scenario where you’re developing an ERP (Enterprise Resource Planning) tool for a business. This tool is expected to manage various aspects, including handling orders, managing billing, and processing payments. In the current implementation, a single class named ERP is responsible for all these functionalities. Consequently, if you instantiate an object of the ERP class to manage orders, it inherits not only the order-related methods but also an amalgamation of methods dealing with billing and payments. This violates the Single Responsibility Principle, which suggests that a class should have only one reason to change. In this case, the ERP class has multiple reasons to change—any modification to order handling, billing, or payment functionality could impact the entire class, making it less maintainable and harder to understand

Before SRP
class ERP:

    def __init__(self):

        self.orders = []

    def take_order(self, order):

        print(f"Taking order: {order}")

        self.orders.append(order)

    def generate_invoice(self, order):

        print(f"Generating invoice for order: {order}")

    def process_payment(self, order):

        print(f"Processing payment for order: {order}")

erp_instance = ERP()

erp_instance.take_order("Order123")

erp_instance.generate_invoice("Order123")

erp_instance.process_payment("Order123")

After SRP

class OrderManager:

    def __init__(self):

        self.orders = []

    def take_order(self, order):

        print(f"Taking order: {order}")

        self.orders.append(order)

class BillingProcessor:

    def generate_invoice(self, order):

        print(f"Generating invoice for order: {order}")

    def process_payment(self, order):

        print(f"Processing payment for order: {order}")

order_manager = OrderManager()

billing_processor = BillingProcessor()

# Handle an order

order_manager.take_order("Order123")

# Generate invoice and process payment separately

billing_processor.generate_invoice("Order123")

billing_processor.process_payment("Order123")

Open/Closed Principle (OCP)

Let’s consider the Open/Closed Principle (OCP) in the context of the ERP use case. The Open/Closed Principle states that “A class should be open for extension but closed for modification.” This means that you should be able to add new functionality to a class without altering its existing code.

In the ERP context, let’s say we want to extend the functionality to support a new feature, such as a discount system. We’ll create an DiscountProcessor class that calculates discounts for orders.

class ERPComponent:

    def __init__(self, name):

        self.name = name

    def process(self, order):

        pass  

class OrderManager(ERPComponent):

    def __init__(self):

        super().__init__("Order Manager")

    def process(self, order):

        print(f"{self.name} is taking order: {order.order_id}")

class BillingProcessor(ERPComponent):

    def __init__(self):

        super().__init__("Billing Processor")

    def process(self, order):

        print(f"{self.name} is generating invoice for order: {order.order_id}")

class DiscountProcessor(ERPComponent):

    def __init__(self):

        super().__init__("Discount Processor")

    def process(self, order):

        print(f"{self.name} is applying discount for order: {order.order_id}")

order_manager = OrderManager()

billing_processor = BillingProcessor()

discount_processor = DiscountProcessor()

order = Order("Order123", 100)

order_manager.process(order)

billing_processor.process(order)

discount_processor.process(order)

We can avoid violating the Open/Closed Principle by implementing inheritance. The base class ERPComponent remains untouched and following sub classes inherited from the ERPComponent serve the purpose of additional functionalities in the ERP application by overriding the process method of base class.

You can also implement the same with the abc module (abstract base class) and abstractmethod decorator, along with the inheritance.

Liskov Substitution Principle (LSP)

The Liskov Substitution Principle (LSP) states that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program, that is, “Subtypes must be substitutable for their base types”. 

class Order:

    def __init__(self, order_id, total_amount):

        self.order_id = order_id

        self.total_amount = total_amount

    def calculate_total(self):

        return self.total_amount

class DiscountedOrder(Order):

    def __init__(self, order_id, total_amount, discount):

        super().__init__(order_id, total_amount)

        self.discount = discount

    def calculate_total(self):

        discounted_amount = super().calculate_total() * (1 - self.discount)

        return discounted_amount

class OrderProcessor:

    def process_order(self, order):

        total_amount = order.calculate_total()

        print(f"Processing order: {order.order_id}, Total Amount: {total_amount}")

# Example usage after applying LSP

order_processor = OrderProcessor()

# Process a regular order

regular_order = Order("Order123", 100)

order_processor.process_order(regular_order)

# Process a discounted order

discounted_order = DiscountedOrder("DiscountedOrder456", 150, 0.1)

order_processor.process_order(discounted_order)

In this example we have created classes for handling both normal orders and discounted orders, but if you notice clearly, there is a common OrderProcessor that processes both the normal and discounted orders. According to LSP, a base class object has to be substitutable by its subclass object, likewise  DiscountedOrder is a subclass of class Order and OrderProcessor can process Order object, as well as DiscountedOrder object, instead of creating separate order processor classes.

Interface Segregation Principle (ISP)

The Interface Segregation Principle (ISP) states that a class should not be forced to implement interfaces it does not use, that is, Clients should not be forced to depend upon methods that they do not use. Interfaces belong to clients, not to hierarchies.” This will look quite similar to the Single Responsibility Principle, in which we make a class to function for a single role. Likewise in ISP, clients are classes and subclasses, and interfaces consist of methods and attributes. In other words, if a class doesn’t use particular methods or attributes, then those methods and attributes should be segregated into more specific classes.

Example,

from abc import ABC, abstractmethod

# Interface for order processing
class OrderProcessing(ABC):
    
     @abstractmethod
     def process_order(self, order):

         pass

# Interface for billing

class Billing(ABC):

    @abstractmethod
    def generate_invoice(self, order):

        pass

# Concrete class implementing OrderProcessing interface

class OrderProcessor(OrderProcessing):

      def process_order(self, order):

          print(f"Processing order: {order.order_id}")

# Concrete class implementing Billing interface

class BillingProcessor(Billing):

      def generate_invoice(self, order):

          print(f"Generating invoice for order: {order.order_id}")

# Example usage after applying ISP

order_processor = OrderProcessor()

billing_processor = BillingProcessor()

# Process an order

class Order:

    def __init__(self, order_id, total_amount):

        self.order_id = order_id

        self.total_amount = total_amount

order = Order("Order123", 100)

order_processor.process_order(order)

# Generate an invoice

billing_processor.generate_invoice(order)

This design adheres to the Interface Segregation Principle, allowing classes to implement only the interfaces that are relevant to their specific functionality, promoting a more modular and maintainable system.

Dependency Inversion Principle (DIP)

The principle is stated as follows:“High-level modules should not depend on low-level modules. Both should depend on abstractions (interfaces or abstract classes). Abstractions should not depend on details. Details should depend on abstractions.”

In simpler terms, the Dependency Inversion Principle suggests that the direction of dependency between high-level and low-level modules should be inverted. Instead of high-level modules depending on the details of low-level modules, both should depend on abstractions

from abc import ABC, abstractmethod

# Abstraction for Order
class Order(ABC):

    @abstractmethod
    def process(self):
        pass

# High-level module representing order processing

class OrderProcessor:

    def __init__(self, order):

        self.order = order

    def process_order(self):

        self.order.process()

# Low-level module implementing Order

class RegularOrder(Order):

    def process(self):

        print("Processing a regular order")

# Low-level module implementing Order

class DiscountedOrder(Order):

    def process(self):

        print("Processing a discounted order")

# Example usage after applying DIP

regular_order = RegularOrder()

discounted_order = DiscountedOrder()

regular_order_processor = OrderProcessor(regular_order)

discounted_order_processor = OrderProcessor(discounted_order)

# Process orders without modifying OrderProcessor

regular_order_processor.process_order()

discounted_order_processor.process_order()

Order is an abstraction (abstract class) that declares a method process.RegularOrder and DiscountedOrder are low-level modules implementing the Order abstraction. OrderProcessor is a high-level module that depends on the abstraction Order. It can process any order without being concerned about the specific implementation of the order.

By following the Dependency Inversion Principle, the high-level module (OrderProcessor) depends on the abstraction (Order), and the low-level modules (RegularOrder and DiscountedOrder) also depend on the same abstraction. This decouples the high-level and low-level modules, making the system more flexible and easier to extend without modifying existing code.

Conclusion

SOLID principles in our software development projects provide a strong foundation for building robust, maintainable, and flexible systems. These principles—Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion—guide us towards writing code that is modular, scalable, and resilient to changes.By following SOLID principles, our code becomes more maintainable and adaptable. Each class or module has a specific, well-defined purpose, making the codebase easier to understand. 

Source : https://images.app.goo.gl/u158hRETrdhzPEXw6

In conclusion, incorporating SOLID principles elevates our software development process, fostering a codebase that is not only resilient to change but also promotes clarity, maintainability, and long-term success. While implementing these principles requires thoughtful design, the resulting benefits contribute significantly to the overall health and longevity of our projects.

Related posts:

Leave a Reply

Your email address will not be published. Required fields are marked *