SOLID Principles

Author pic

Written By - Garvit Maloo

18 October, 2024

SOLID principles are five core guidelines for object-oriented design that promote cleaner, more maintainable code. In this blog, we discuss all the five principles, how they can make our code better and maintainable and what problems we could run into if we violate these principles. And as usual, I am going do it with the help of some examples and code snippets. Lets do it!

Single responsibility principle

As per this principle, a class should have one and only one responsibility. In other words, there should be only one reason for a class to change. If a class handles multiple functionalities, we should break it down into classes such that each class has only one responsibility.

The code given below is an overly-simplified code for a payment processing system that takes in total amount, calculate taxes and process the payment.

class PaymentProcessor {
    public PaymentProcessor(){}

    public void processPayment(double amount){
        double taxAmount = this.calculateTax(amount, 10);

        System.out.println("Processing a payment of " + amount);
        System.out.println("Total tax amount " + taxAmount);
    }

    public double calculateTax(double amount, double taxPercent){
        return amount * (taxPercent / 100);
    }
}

The problem with this class is that it has 2 responsibilities or reasons to change - tax calculation and payment processing. Right now it might not seem to be a problem but consider a scenario when "multiple unrelated functionalities" gets stuffed in this class. It would lead to something we call "god class" which would be very difficult to maintain and making changes in this class would produce unexpected bugs.

So, just ask yourself do I need to change this class for more than 1 reasons? If yes, it would be better to break it down into smaller class(es) with just one responsibility or 1 reason to change.

Here is an improved version of the above code.

class Tax {
  public static double calculateTax(double amount, int taxPercent){
    return amount * (taxPercent / 100);
  }
}

class PaymentProcessor {
    public PaymentProcessor(){}

    public void processPayment(double amount){
        double taxAmount = Tax.calculateTax(amount, 10);

        System.out.println("Processing a payment of " + amount);
        System.out.println("Total tax amount " + taxAmount);
    }
}

Make as many changes as you want in the Tax class and PaymentProcessor class will remain untouched, thus ensuring there are no unexpected bugs in the logic of PaymentProcessor class. This is how the single responsibility principle can make our code better and maintainable.

Open-Closed Principle

As per this rule, a class should be open for extension but closed for modification. It means we should be able to add more functionality to a class but without modifying it's existing structure. Following this rule will make your code extensible without making too many changes at different places, thus making it easier to add new functionality. Lets understand this with an example. First let's see un-optimized code.

class CardPaymentService {
    public CardPaymentService(){}

    public void processPayment(){
        System.out.println("Processing payment through card..");
    }
}

class UPIPaymentService {
    public UPIPaymentService(){}

    public void processPayment(){
        System.out.println("Processing payment through UPI..");
    }
}

class PaymentService{
    private CardPaymentService cardService;
    private UPIPaymentService upiService;

    public PaymentService(){
        cardService = new CardPaymentService();
        upiService = new UPIPaymentService();
    }

    public void processPayment(String service){
        if(service == "Card"){
            cardService.processPayment();
        }else if(service == "UPI"){
            upiService.processPayment();
        }else{
            System.out.println("Unsupported payment method!");
        }
    }
}

The PaymentService class is the main class which has a method to process payment using different services like cards or UPI. The problem with this approach is that if we have to add a new payment service, say Net banking, we will have to modify the PaymentService class. This makes it open for extension but not closed for modification. So, lets see how we can make use of OCP to make it better.

interface PaymentProcessor {
    void processPayment();
}

class CardPaymentService implements PaymentProcessor {
    public CardPaymentService(){}

    @Override
    public void processPayment() {
        System.out.println("Processing payment through Card..");
    }
}

class UPIPaymentService implements PaymentProcessor {
    public UPIPaymentService(){}

    @Override
    public void processPayment() {
        System.out.println("Processing payment through UPI..");
    }
}

class PaymentService {
    private PaymentProcessor paymentProcessor;

    public PaymentService(PaymentProcessor processor){
        this.paymentProcessor = processor;
    }

    public void processPayment(){
        paymentProcessor.processPayment();
    }
}


public static void main(String[] args) {
    CardPaymentService cardPayments = new CardPaymentService();
    UPIPaymentService upiPayments = new UPIPaymentService();

    PaymentService cardPaymentService = new PaymentService(cardPayments);
    cardPaymentService.processPayment();

    PaymentService upiPaymentService = new PaymentService(upiPayments);
    upiPaymentService.processPayment();
}

Here, we made use of abstraction while making different payment services and the main PaymentService class. Now even if we add more payment service methods like Net banking, we just have to create a new class for it and use it in the main program. We won't have to touch the PaymentService class. It is open for extension and also closed for modification. Check the code below -

class NetBankingPaymentService implements PaymentProcessor {
    public NetBankingPaymentService(){}

    @Override
    public void processPayment() {
        System.out.println("Processing payment through Net banking..");
    }
}

public static void main(String[] args) {
    CardPaymentService cardPayments = new CardPaymentService();
    UPIPaymentService upiPayments = new UPIPaymentService();
    NetBankingPaymentService service = new NetBankingPaymentService();

    PaymentService cardPaymentService = new PaymentService(cardPayments);
    cardPaymentService.processPayment();

    PaymentService upiPaymentService = new PaymentService(upiPayments);
    upiPaymentService.processPayment();

    PaymentService netBankingService = new PaymentService(service);
    netBankingService.processPayment();
}

We can add as many payment service providers as we want without having to modify the main PaymentService class. Isn't it amazing?

Liskov's Substitution Principle

As per the Liskov's substitution principle, we should be able to replace a parent object with a child object such that everything works perfectly even after this substitution. In other words, every child class should follow the contract(s) established by the parent class. Check the example given below -

// Parent class
class TaxScheme {
    private String name;
    private double taxPercent;

    public TaxScheme(String name, double taxPercent){
        this.name = name;
        this.taxPercent = taxPercent;
    }

    public double calculateTaxAmount(double amount){
        return (taxPercent / 100) * amount;
    }
}

// Child class
class ServiceTaxScheme extends TaxScheme {
    public ServiceTaxScheme(double taxPercent){
        super("Service Tax", taxPercent);
    }

    @Override
    public double calculateTaxAmount(double amount) {
        return (((taxPercent / 100) * amount) + (amount * 0));
    }
}

Note that we have overridden the parent method calculateTaxAmount but the overall behavior is still the same. If we make objects from parent and child class and substitute them, we will not see any difference in the execution of the program or output. This is because the child class is following all the contracts established by the parent class.

public static void main(String[] args) {
        TaxScheme myScheme = new TaxScheme("New", 7);
        System.out.println(myScheme.calculateTaxAmount(60)); // prints 4.2

        TaxScheme serviceTaxScheme = new ServiceTaxScheme(7);
        System.out.println(serviceTaxScheme.calculateTaxAmount(60)); // prints 4.2

        // parent substituted with child object
        myScheme = serviceTaxScheme;
        System.out.println(myScheme.calculateTaxAmount(60)); // prints 4.2 - expected output
}

If we make a child class which do not follow the rules established by the parent class and try to substitute parent object with child object, it would look something like this -

class WorkerTaxScheme extends TaxScheme {
    public WorkerTaxScheme(double taxPercent){
        super("Worker Scheme", taxPercent);
    }

    @Override
    public double calculateTaxAmount(double amount) {
        if(amount < 100) return 0; // behavior changed here..

        return (((taxPercent / 100) * amount) + (amount * 0));
    }
}

public static void main(String[] args) {
    TaxScheme myScheme = new TaxScheme("New", 7);
    System.out.println(myScheme.calculateTaxAmount(60)); // prints 4.2

    TaxScheme serviceTaxScheme = new ServiceTaxScheme(7);
    System.out.println(serviceTaxScheme.calculateTaxAmount(60)); // prints 4.2

    // parent substituted with child object
    myScheme = serviceTaxScheme;
    System.out.println(myScheme.calculateTaxAmount(60)); // prints 4.2 - expected output

    // LSP violated
    TaxScheme workersScheme = new WorkerTaxScheme(5);
    myScheme = workersScheme;
    System.out.println(myScheme.calculateTaxAmount(50)); // prints 0. Output expected - 2.5
}

The main reason why it is important to follow this principle is because we want all the sub-classes to have the same behavior as that of the parent class. Otherwise, unexpected bugs could creep into our application overtime and it would be very difficult to maintain such applications and even more difficult to add new features without breaking the existing ones.

One thing that I think can be really helpful while following this principle is documenting the behavior of the parent classes so that it is clear what is expected from the child classes.

Interface Segregation Principle

According to this principle, no code should be forced to depend on methods which are not used by it. This commonly happens when we are trying to implement an interface and it has certain methods which are not needed by the class. Consider this example -

interface Printable {
    void print();
    void scan();
    void fax();
}

class AdvancedPrinter implements Printable {
    public AdvancedPrinter(){}

    @Override
    public void print() {
        System.out.println("Printing..");
    }

    @Override
    public void scan() {
        System.out.println("Scanning..");
    }

    @Override
    public void fax() {
        System.out.println("Sending fax..");
    }
}

class SimplePrinter implements Printable {
    public SimplePrinter(){}

    @Override
    public void print() {
        System.out.println("Printing.."); // fine
    }

    @Override // redundant
    public void scan() {
        try {
            throw new Exception("Scan not supported!");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @Override // redundant
    public void fax() {
        try {
            throw new Exception("Fax not supported!");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

In such scenarios, consider breaking big interfaces into smaller ones. Instead of throwing exceptions, segregate Printable interface into smaller interfaces and then use whichever interfaces are needed.

interface Printable{
    void print();
}

interface Scannable{
    void scan();
}

interface Faxable {
    void fax();
}

class AdvancedPrinter implements Printable, Scannable, Faxable {
    public AdvancedPrinter(){}

    @Override
    public void print() {
        System.out.println("Printing..");
    }

    @Override
    public void scan() {
        System.out.println("Scanning..");
    }

    @Override
    public void fax() {
        System.out.println("Sending fax..");
    }
}

class SimplePrinter implements Printable {
    public SimplePrinter(){}

    @Override
    public void print() {
        System.out.println("Printing..");
    }
}

This principle is important because it avoids the problems that arise due to large, bloated interfaces. When we implement a very broad interface on a class that does not need all of its methods, we force it to implement them which can introduce unnecessary dependencies and coupling in the class.

Dependency Inversion Principle

This principle is primarily used for decoupling software systems. This principle suggests two main things -

  1. High-level modules should never be dependent on low-level modules. Both should depend on abstraction
  2. Abstraction should not depend on details. Details should depend on abstraction

You might wonder what are high-level and low-level modules. For the sake of simplicity, let's just say that the modules that use another module's methods or properties are high-level modules and the modules whose methods or properties are being used are low-level modules. Let's consider a very practical example to understand high-level, low-level modules and the DI principle.

We often use a layered architecture on our backend which might look something like this -

Without DIP

Here, repository layer < service layer < controller layer (low to high). Controller layer uses methods of service layer which in turn uses methods of repository layer, hence this order.

The problem with this architecture is that low-level modules are tightly coupled with high-level modules. Any changes in the low-level module will need us to make changes in the high-level module as well, which is not ideal. So, to fix this, we can make both the high-level and low-level modules depend on the abstraction and not the actual implementation. As long as both the modules follow the contract(s) established by the abstraction, there won't be a problem if we change the internal implementation of any of the two modules. It would look something like this -

With DIP

Now, service layer is not directly dependent on the implementation of the repository layer. Any changes in the repository layer will not affect service and controller layers. As long as you don’t make any breaking changes in the interface, you need not to make changes in service or controllers.

One of the main benefits of following this principle is that if we can make high-level modules independent of low level modules. We can easily swap out low level functionalities like databases or logging systems or so, without having to make too many changes in the high-level code. Suppose, right now our application has been working on PostgreSQL database and maybe after some time, we decide to add MongoDB database as well. With this architecture, we won't have to make too many changes in the high-level modules, that is, service and controller layers. We might have to add some new things in repository layer but as long as every layer is following the abstraction, our application won't break!

Another benefit is that dependency inversion allows for better unit testing because you can easily mock dependencies using abstractions. Without DIP, there would be concrete implementation of low-level modules in the high-level modules which can make it difficult to test high-level modules in isolation. Also, when we run the tests, high-level modules will be using low-level modules actual implementation which would make our tests slow. When implemented with DIP, we just have to mock the abstraction, not the actual implementation which solves both the problems.

So, dependency inversion principle is one of the most important principles to follow while writing object-oriented code.

That's all for this (quite a long) blog! At last, I would like to say that I have been actively looking for applying these principles in my personal projects. It might seem a little easy when reading theory and examples I gave above, but it would be a little tricky to identify where to apply these principles. Trust me, I tried it myself and I often missed some of these principles. I would know it while getting my code reviewed by AI. So, please get some practice, think of examples on your own or do some assignments and apply these yourself to get a good practical understanding of these principles.

Thank you for reading this blog! If you liked the content and if you think it might be helpful for someone else, please share it on different social media channels. Stay tuned for more fresh and knowledgeable content on OOP. 😄

Liked the content? Share it with your friends!
Share on LinkedIn
Share on WhatsApp
Share on Telegram

Related Posts

See All