Recently we have covered Top 30 Java Interview Questions, and today we want to talk about SOLID deeper, these principles, coined by the software guru Robert C. Martin (aka Uncle Bob), are:

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

But why should you care? Well, imagine you're building a Lego tower. SOLID principles are like the instruction manual that ensures your tower doesn't topple over when you try to add new pieces. They make your code:

  • More readable (your future self will thank you)
  • Easier to maintain and modify
  • More robust against changes in requirements
  • Less prone to bugs when you're adding new features

Sounds good, right? Let's break down each principle and see how they work in practice.

Single Responsibility Principle (SRP): One Job, One Class

The Single Responsibility Principle is like the Marie Kondo of programming - it's all about decluttering your classes. The idea is simple: a class should have one, and only one, reason to change.

Let's look at a classic violation of SRP:


public class Report {
    public void generateReport() {
        // Generate report content
    }

    public void saveToDatabase() {
        // Save report to database
    }

    public void sendEmail() {
        // Send report via email
    }
}

This Report class is doing way too much. It's generating the report, saving it, and sending it. It's like a swiss army knife - handy, but not ideal for any specific task.

Let's refactor this to follow SRP:


public class ReportGenerator {
    public String generateReport() {
        // Generate and return report content
    }
}

public class DatabaseSaver {
    public void saveToDatabase(String report) {
        // Save report to database
    }
}

public class EmailSender {
    public void sendEmail(String report) {
        // Send report via email
    }
}

Now each class has a single responsibility. If we need to change how reports are generated, we only touch the ReportGenerator class. If the database schema changes, we only update DatabaseSaver. This separation makes our code more modular and easier to maintain.

Open/Closed Principle (OCP): Open for Extension, Closed for Modification

The Open/Closed Principle sounds like a paradox, but it's actually quite clever. It states that software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification. In other words, you should be able to extend a class's behavior without modifying its existing code.

Let's look at a common OCP violation:


public class PaymentProcessor {
    public void processPayment(String paymentMethod) {
        if (paymentMethod.equals("creditCard")) {
            // Process credit card payment
        } else if (paymentMethod.equals("paypal")) {
            // Process PayPal payment
        }
        // More payment methods...
    }
}

Every time we want to add a new payment method, we have to modify this class. That's a recipe for bugs and headaches.

Here's how we can refactor this to follow OCP:


public interface PaymentMethod {
    void processPayment();
}

public class CreditCardPayment implements PaymentMethod {
    public void processPayment() {
        // Process credit card payment
    }
}

public class PayPalPayment implements PaymentMethod {
    public void processPayment() {
        // Process PayPal payment
    }
}

public class PaymentProcessor {
    public void processPayment(PaymentMethod paymentMethod) {
        paymentMethod.processPayment();
    }
}

Now, when we want to add a new payment method, we just create a new class that implements PaymentMethod. The PaymentProcessor class doesn't need to change at all. That's the power of OCP!

Liskov Substitution Principle (LSP): If It Looks Like a Duck and Quacks Like a Duck, It'd Better Be a Duck

The Liskov Substitution Principle, named after computer scientist Barbara Liskov, states that objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program. In simpler terms, if class B is a subclass of class A, we should be able to use B anywhere we use A without things going haywire.

Here's a classic example of violating LSP:


public class Rectangle {
    protected int width;
    protected int height;

    public void setWidth(int width) {
        this.width = width;
    }

    public void setHeight(int height) {
        this.height = height;
    }

    public int getArea() {
        return width * height;
    }
}

public class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        super.setWidth(width);
        super.setHeight(width);
    }

    @Override
    public void setHeight(int height) {
        super.setHeight(height);
        super.setWidth(height);
    }
}

This seems logical at first glance - a square is a special kind of rectangle, right? But it violates LSP because you can't use a Square everywhere you use a Rectangle without unexpected behavior. If you set the width and height of a Square separately, you'll get unexpected results.

A better approach would be to use composition instead of inheritance:


public interface Shape {
    int getArea();
}

public class Rectangle implements Shape {
    private int width;
    private int height;

    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

    public int getArea() {
        return width * height;
    }
}

public class Square implements Shape {
    private int side;

    public Square(int side) {
        this.side = side;
    }

    public int getArea() {
        return side * side;
    }
}

Now Square and Rectangle are separate implementations of the Shape interface, and we avoid the LSP violation.

Interface Segregation Principle (ISP): Small is Beautiful

The Interface Segregation Principle states that no client should be forced to depend on methods it does not use. In other words, don't create fat interfaces; split them into smaller, more focused ones.

Here's an example of a bloated interface:


public interface Worker {
    void work();
    void eat();
    void sleep();
}

public class Human implements Worker {
    public void work() { /* ... */ }
    public void eat() { /* ... */ }
    public void sleep() { /* ... */ }
}

public class Robot implements Worker {
    public void work() { /* ... */ }
    public void eat() { throw new UnsupportedOperationException(); }
    public void sleep() { throw new UnsupportedOperationException(); }
}

The Robot class is forced to implement methods it doesn't need. Let's fix this by segregating the interface:


public interface Workable {
    void work();
}

public interface Eatable {
    void eat();
}

public interface Sleepable {
    void sleep();
}

public class Human implements Workable, Eatable, Sleepable {
    public void work() { /* ... */ }
    public void eat() { /* ... */ }
    public void sleep() { /* ... */ }
}

public class Robot implements Workable {
    public void work() { /* ... */ }
}

Now our Robot only implements what it needs. This makes our code more flexible and less prone to errors.

Dependency Inversion Principle (DIP): High-Level Modules Shouldn't Depend on Low-Level Modules

The Dependency Inversion Principle might sound complex, but it's actually quite simple. It states that:

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions.
  2. Abstractions should not depend on details. Details should depend on abstractions.

Here's an example of violating DIP:


public class LightBulb {
    public void turnOn() {
        // Turn on the light bulb
    }

    public void turnOff() {
        // Turn off the light bulb
    }
}

public class Switch {
    private LightBulb bulb;

    public Switch() {
        bulb = new LightBulb();
    }

    public void operate() {
        // Switch logic
    }
}

In this example, the Switch class (high-level module) depends directly on the LightBulb class (low-level module). This makes it hard to change the Switch to control other devices.

Let's refactor this to follow DIP:


public interface Switchable {
    void turnOn();
    void turnOff();
}

public class LightBulb implements Switchable {
    public void turnOn() {
        // Turn on the light bulb
    }

    public void turnOff() {
        // Turn off the light bulb
    }
}

public class Switch {
    private Switchable device;

    public Switch(Switchable device) {
        this.device = device;
    }

    public void operate() {
        // Switch logic using device.turnOn() and device.turnOff()
    }
}

Now both Switch and LightBulb depend on the Switchable abstraction. We can easily extend this to control other devices without changing the Switch class.

Wrapping Up: SOLID as a Rock

SOLID principles might seem like a lot to take in at first, but they're incredibly powerful tools in your OOP toolkit. They help you write code that's:

  • Easier to understand and maintain
  • More flexible and adaptable to change
  • Less prone to bugs when adding new features

Remember, SOLID isn't a strict set of rules, but rather a guide to help you make better design decisions. As you apply these principles in your daily coding, you'll start to see patterns emerge, and your code will naturally become more robust and maintainable.

So, next time you're designing a class or refactoring some code, ask yourself: "Is this SOLID?" Your future self (and your team) will thank you for it!

"The secret to building large apps is never build large apps. Break your applications into small pieces. Then, assemble those testable, bite-sized pieces into your big application" - Justin Meyer

Happy coding, and may your code be ever SOLID!