Skip to content

Depth of SOLID Principle

Published: at 15 min read

Table of contents

Open Table of contents

Story of evolution of Solid Principle

SOLID principles were introduced to address common challenges in software development and to provide developers with a set of guidelines for creating more maintainable, flexible, and robust software systems.

The SOLID principles came into picture as a

They have been widely adopted in the software development community due to the following reasons:

  1. Growing Complexity: When software systems grow in size and complexity, it is difficult to maintain and extend them. Without proper design principles and become hard to maintain and prone to bugs.

  2. Changing Requirements: Software requirements often change over time with new features or modifications and with poorly designed code can be difficult and risky to change.The SOLID principles promote designs that are more flexible and adaptable to change.

  3. Reduction of Technical Debt: Technical debt refers to the cost of fixing and refactoring poorly designed code in the future. Adhering to SOLID principles helps reduce technical debt by promoting good design practices from the scratch.

  4. Code Reusability: SOLID principles encourage the creation of modular and loosely coupled code. which makes modularity easier to reuse code components in different parts of an application or different projects.

  5. Overall Maintainability and Quality: The primary objective of SOLID principles is to improve the maintainability and quality of software. Well-designed and written code following these principles is easier to understand, modify, and maintain over the long term.

  6. Team Collaboration: Multiple developers often work on the same project. SOLID principles provide a common set of guidelines that make it easier for developers to collaborate by creating a shared understanding of good design practices.

  7. Testability: Code that follows the SOLID principles is typically easier to test because it is well-organized and has clear boundaries which encourages the adoption of unit testing and test-driven development (TDD), leading to more reliable software.

By applying these principles, software developers can produce higher-quality code that is easier to work with and less prone to errors.

Exploring SOLID Principle

solidPrinciple

Section 1 - Single Responsibility Principle (SRP)

Every software component should have one only one reason to change.

OR

In other words, a class should have a single, well-defined purpose.

Taking an example to go in-depth of above Principle ~

cohesion

  1. Cohesion - Cohesion is the degree to which the various parts of a software component are related.

In the above example calculateArea() and calculatePerimeter() are closely related, in that they deal with the measurement of a square and causes high level of Cohesion between these two above methods.

Similarly the draw() and rotate() are closely related and has high level of cohesion in between them.

But Overall the whole function cohesion is LOW as calculateArea() and draw() are not related.

cohesionExample In this above example, by splitting the methods into two classes, the two classes increase the level of cohesion.

Aspect of Single Responsibility Principle is that - we should always aim for high cohesion within a component/class.

  1. Coupling - Coupling is define as the level of inter dependency between various software components.

In software tight coupling is an undesirable feature.

couplingExample

In this above example the

Now here in this we are using MySQL, but sometime in future we might have a requirement of changing the database like MongoDB then most of the code will need to change.

So we can say that the Student class is tightly couples with the database layer we use at the back end. The Student class should not be made cognizant of the low level details related to dealing with the back end database. So Tight Coupling is bad in software.

looseCoupling

In this we have created StudentRepository() method from inside Student class and changes requires then we can only update the StudentRepository() class and thus it removes the tight coupling and made it loose.

Loose Coupling helps attain better adherence to the Single Responsibility Principle

Section 2 - Open Closed Principle (OCP)

Software components should be closed for modification, but open for extension

Here if we explain the terms of definition of the principle we have ~

  1. Closed For Modification - it means new features getting added to the software component, but should not have the option to modify the existing code.

  2. Open For Extension - it means a software component should be extendable to add a new feature or to add a new behavior to it.

Taking an example to go in-depth of above Principle ~

Let’s say we have an insurance company which provides health insurance.

public class InsurancePremiumDiscountCalculator {
    public int calculatePremiumDiscountPercent(HealthInsuranceCustomerProfile customer) {
        if(customer.isLoyalCustomer()) {
            return 20;
        }
        return 0;
    }
}
public class HealthInsuranceCustomerProfile {
    public boolean isLoyalCustomer() {
        return true // or false
    }
}

In future if the insurance company wants to cover vehicle then, the above example will not able to provide solution for both health and vehicle. So we add a new class ->

public class VehicleInsuranceCustomerProfile {
    isLoyalCustomer() {
        return true // or false
    }
}

and also we need to update the InsurancePremiumDiscountCalculator class to cover both health and vehicle insurance.

We can create method overloading but if the insurance company wants to provide insurance for Home too in future then the method overloading will not be a good solution.

So we can have an interface ~

public interface
CustomerProfile {
    isLoyalCustomer() {
        return true // or false
    }
}

and for Home, Vehicle and Health we have below function ~

public class HomeInsuranceCustomerProfile implements CustomerProfile {
    @Override
    public boolean isLoyalCustomer() {
        return true // or false
    }
}
public class VehicleInsuranceCustomerProfile implements CustomerProfile {
    @Override
    public boolean isLoyalCustomer() {
        return true // or false
    }
}
public class HealthInsuranceCustomerProfile implements CustomerProfile {
    @Override
    public boolean isLoyalCustomer() {
        return true // or false
    }
}

and finally we can update -

public class InsurancePremiumDiscountCalculator {
    public int calculatePremiumDiscountPercent(CustomerProfile customer) {
        if(customer.isLoyalCustomer()) {
            return 20;
        }
        return 0;
    }
}

So any further changes of covering insurance in any cases will not require changes in InsurancePremiumDiscountCalculator class.

Key Takeaways of OCP ~

  1. Ease of adding new features.
  2. Leads to minimal cost of developing and testing software.
  3. Open Closes Principle often requires decoupling, which in turn automatically follows the Single Responsibility Principle.

Caution of Open Closed Principle -

  1. Do not follow the Open Closed Principle blindly.
  2. One will end up with a huge number of classes that can complicate the overall design.
  3. Make a subjective, rather than an objective decision.

Hence we can say that -

Section 3 - Liskov Substitution Principle (LSP)

Objects should be replaceable with their subtypes without affecting the correctness of the program

Taking an example to go in-depth of above Principle ~

Lets say we have a generic Car class and we have a racing car class. Where racing car follows “is-a” relation of car class.

public class Car {
    public double getCabinWidth() {
        // return cabin width
    }
}
public class RacingCar extends Car {
    @override
    public double getCabinWidth() {
        //UNIMPLEMENTED
    }
    public double getCockpitWidth() {
        // return cockpit width
    }
}
public class CarUtils {
    public static void main(String[] args) {
        Car first = new Car();
        Car second = new Car();
        Car third = new RacingCar();

        List<Car> myCars = new ArrayList<>();
        myCars.add(first);
        myCars.add(second);
        myCars.add(third);

        for(Car car : myCars) {
            System.out.println(car.getCabinWidth())
        }
    }
}

In this above example, the racing car does not have cabin but has cockpit so while extending the Car class, the RacingCar class is overriding the getCabinWidth() class but leaves it unimplemented.

As the above example does not follow the Liskov Substitution Principle, so we require a test standard that is more strict than the “is-a” test.

To solve the above issue of Car and RacingCar, lets have Vehicle class which got extended by both Car and RacingCar classes.

public class Vehicle() {
    public double getInteriorWidth() {
        //return interior width
    }
}
public class Car extends Vehicle {
    @Override
    public double getInteriorWidth() {
        return this.getCabinWidth();
    }

    public double getCabinWidth() {
        // return cabin width
    }
}
public class RacingCar extends Vehicle {
   @Override
    public double getInteriorWidth() {
        return this.getCockpitWidth();
    }

    public double getCockpitWidth() {
        // return cockpit width
    }
}

So While using the above classes, we have -

public class VehicleUtils {
    public static void main(String[] args) {
        Vehicle first = new Car();
        Vehicle second = new Car();
        Vehicle third = new RacingCar();

        List<Vehicle> myVehicles = new ArrayList<>();
        myVehicles.add(first);
        myVehicles.add(second);
        myVehicles.add(third);

        for(Vehicle vehicle : myVehicles) {
            System.out.println(vehicle.getInteriorWidth())
        }
    }
}

In this case, Liskov Substitution Principle is followed.

Section 4 - Interface Segregation Principle (ISP)

No client should be forced to depend on methods it does not use

Taking an example to go in-depth of above Principle ~

Lets take an example of multi-function all-in-one Xerox WorkCenter that has a printer, scanner, copier and a fax machine all built into one. To design this multi-function machine, we can design a common interface.

public interface IMultiFunction {
    public void print();
    public void getPrintSpoolDefault();
    public void scan();
    public void scanPhoto();
    public void fax();
    public void internetFax();
}

Now one can create a concrete class that represents this particular device - The Xerox WorkCentre.

public class XeroxWorkCenter implements IMultiFunction {
    @Override
    public void print() {
        //printing code
    }
    @Override
    public void getPrintSpoolDefault() {
        //gets spool details
    }
    @Override
    public void scan() {
        //code for scanning
    }
    @Override
    public void scanPhoto() {
        //code for photos scans
    }
    @Override
    public void fax() {
        //code for fax
    }
    @Override
    public void internetFax() {
        //code for internet fax
    }

}

if we go for another device HP PrinterNScanner which is not as much as multi-faceted like Xerox WorkCenter and does not have the some functions - fax and internetFax

public class HPPrinterNScanner implements IMultiFunction {
    @Override
    public void print() {
        //printing code
    }
    @Override
    public void getPrintSpoolDefault() {
        //gets spool details
    }
    @Override
    public void scan() {
        //code for scanning
    }
    @Override
    public void scanPhoto() {
        //code for photos scans
    }
    @Override
    public void fax() {}
    @Override
    public void internetFax() {}

}

Again if we check for Cannon Printer, it is not multi-faceted like Xerox WorkCenter and does not have the some functions -scan, scanPhoto, fax and internetFax

public class CannonPrinter implements IMultiFunction {
    @Override
    public void print() {
        //printing code
    }
    @Override
    public void getPrintSpoolDefault() {
        //gets spool details
    }
    @Override
    public void scan() {}
    @Override
    public void scanPhoto() {}
    @Override
    public void fax() {}
    @Override
    public void internetFax() {}
}

Here in HPPrinterNScanner and CanonPrinter classes, there are some unimplemented methods which always indicate of poor design and goes against Interface Segregation Principle.

In here, the problem is - due to blank implementation of methods of the interface breaks the code.

To Solve this above problem, we can spilt the Big Interface into smaller interfaces. So we have three interfaces -

public interface IPrint() {
    public void print();
    public void getPrintSpoolDefault();
}
public interface IScan() {
    public void scan();
    public void scanPhoto();
}
public interface IFax() {
    public void fax();
    public void internetFax();
}

So as we segregate the interfaces then the classes will work in different ways -

public class XeroxWorkCenter implements IPrint, IFax, IScan {
    @Override
    public void print() {
        //printing code
    }
    @Override
    public void getPrintSpoolDefault() {
        //gets spool details
    }
    @Override
    public void scan() {
        //code for scanning
    }
    @Override
    public void scanPhoto() {
        //code for photos scans
    }
    @Override
    public void fax() {
        //code for fax
    }
    @Override
    public void internetFax() {
        //code for internet fax
    }

}
public class HPPrinterNScanner implements IPrint, IScan {
    @Override
    public void print() {
        //printing code
    }
    @Override
    public void getPrintSpoolDefault() {
        //gets spool details
    }
    @Override
    public void scan() {
        //code for scanning
    }
    @Override
    public void scanPhoto() {
        //code for photos scans
    }
}
public class CannonPrinter implements IPrint {
    @Override
    public void print() {
        //printing code
    }
    @Override
    public void getPrintSpoolDefault() {
        //gets spool details
    }
}

In the usages of the interfaces, we can see that the classes have become much cleaner now. If we decide to package these classes as a library, and pass it on to a programmer then there is no ambiguity.

So we have solved the problem by segregating the interfaces and thus following the Interface Segregation Principle.

Techniques to Identify Violations of ISP ~

  1. Fat Interfaces - means interfaces with high number of methods. In the above example of interface IMultiFunction - has multiple methods

  2. Interfaces with Low Cohesion - In the above example of interface IMultiFunction, we have scanPhoto() and fax() methods have low cohesion and leading to ISP.

  3. Empty Method Implementations - blank implementation of interface methods is almost always a violation of the interface segregation principle.

Hence we can say that - Most of the SOLID principles are intricately linked to one another.

  1. After segregating the big interfaces to small interfaces according to its work process, Interface Segregation Principle follows Single Responsibility Principle.

  2. After segregating the big interfaces to small interfaces according to its work process, Interface Segregation Principle follows Liskov Substitution Principle.

SOLID Principles complement each other, and work together in unison, to achieve the common purpose of well-designed software

Section 5 - Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level modules.Both should depend on abstractions.

Abstractions should not depend on details. Details should depend on abstractions.

Lets understand the terms of the definition -

  1. High-Level Modules: Typically components/classes in a software system that deal with more abstract and complex behavior and often responsible for coordinating the overall flow of the application and making decisions, based on business rules or application logic.

  2. Low-Level Modules: Components/classes that deal with specific, detailed, and often lower-level tasks. They are responsible for carrying out specific operations or providing specific functionalities and more focused on the implementation details and may not be aware of the broader context of the application.

Taking an example to go in-depth of above Principle ~

Lets assume we have a relationship, where -

In here ProductCatalog belongs to High-level module and SQLProductRepository belongs to Low-level module.

import java.util.Arrays;
import java.util.List;

public class SQLProductRepository {

    public List<String> getAllProductNames() {
        return Arrays.asList("soap", "toothpaste");
    }
}
import java.util.List;

public class ProductCatalog {

    public void listAllProducts() {
        SQLProductRepository sqlProductRepository = new SQLProductRepository();
        List<String> allProductNames = sqlProductRepository.getAllProductNames();

        //Display product names
    }
}

As in the above code snippet, it shown that the ProductCatalog directly depends on SQLProductRepository, so this is the violation of the Principle.

To solve this problem we can create an interface -

import java.util.List;

public interface ProductRepository() {
    public List<String> getAllProducts();
}

and we have SQLProductRepository implementing ProductRepository

import java.util.Arrays;
import java.util.List;

public class SQLProductRepository implements ProductRepository {

    public List<String> getAllProductNames() {
        return Arrays.asList("soap", "toothpaste");
    }
}

and for ProductCatalog we can have -

public class ProductFactory {
    public static ProductRepository create() {
        return new SQLProductRepository();
    }
}

Now, from the ProductCatalog class, we will invoke this factory method which will instantiate and return a SQLProductRepository object.

But as the reference object is ProductRepository, so we don’t have any tight coupling with SQLProductRepository anywhere in the ProductCatalog class.

import java.util.List;

public class ProductCatalog {

    public void listAllProducts() {
        ProductRepository productRepository =  ProductFactory,create();
        List<String> allProductNames = productRepository.getAllProductNames();

        //Display product names
    }
}

So right now we have a new arrangement and we call it as an Abstraction.

Here we are not violating the principle - i.e the high level module does not depend on the low level module anymore.

Dependency Inversion or Inversion of Dependency, suggests that instead of high-level modules depending directly on low-level modules, both should depend on abstractions (usually represented as interfaces or abstract classes).

Concept of Dependency Injection ~

Lets update some code -

import java.util.List;

public class ProductCatalog {

    private ProductRepository productRepository;

    public ProductCatalog (ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    public void listAllProducts() {
       List<String> allProductNames = productRepository.getAllProductNames();

        //Display product names
    }
}

and now we have a new class

public class ECommerceMainApp {
    public static void main(String[] args){
        ProductRepository productRepository = ProductFactory.create();
        ProductCatalog productCatalog = new ProductCatalog(productRepository);
        productCatalog.listAllProducts();
    }
}

So from the above examples of updated code we have a constructor for the ProductCatalog class, which takes in a ProductRepository object. We also have a class with a main method that creates and makes use of the ProductCatalog class.

It will first call the factory method, get the instantiated SQLProductRepository object, and then invoke the constructor on the ProductCatalog class by passing on the newly instantiated object.

So now, when ProductCatalog is created, it gets a SQLProductRepository object. ProductCatalog is free to use the SQLProductRepository object where ever and whenever it wants. ProductCatalog does NOT need to worry about any instantiation.

In other words, we are injecting the dependency INTO ProductCatalog , instead of ProductCatalog worrying about instantiating the dependency.

This is the concept of Dependency Injection. Dependency injection avoids tight coupling.

Summary

After a thorough explanation we can say that -

Share :
Written by:Parita Dey

Interested in Writing Blogs, showcase yourself ?

If you're passionate about technology and have insights to share, we'd love to hear from you! Fill out the form below to express your interest in writing technical blogs for us.

If you notice any issues in this blog post or have suggestions, please contact the author directly or send an email to hi@asdevs.dev.