SOLID Principles: A Clarity Journey

I started with Head First Design Patterns by Eric Freeman and Elisabeth Robson, which gave me a solid introduction to system design principles.

Initially, it felt pretty straightforward, but as I delved into the SOLID principles,

I became confused instead of gaining a clear grasp.

It felt like every concept was overlapping.

Each principle seemed both similar to and entirely different from the others, making it difficult for me to grasp them.

What I was doing wrong was viewing them as rigid rules, bookish theories, or just theoretical concepts, rather than considering how they could be applied to solve real-world problems.

To clear the confusion, I began to understand them by relating them to my own experiences. I started asking myself questions, relating the principles to real-world scenarios, and answering them in my own words.

I would think, "What’s this principle really about?" and "How does it fit with the others?” Over time, this approach helped me clear up the confusion and see how each principle has its own role but also works together with the others.

I’m still in the process of learning and figuring things out, so there might be some inaccuracies or gaps in my understanding.

If you notice something that could be improved or have any advice, please feel free to reach out.

This blog is more about documenting my personal learning journey rather than presenting a definitive guide or textbook theory.

I hope my reflections will provide clarity and inspire your own exploration of these foundational principles.


Now before we move forward let’s go through some basic things.

  • Object-Oriented Design helps create code that's easy to adapt, scale, maintain, and reuse.

  • SOLID principles aim to reduce tight coupling, where classes rely too heavily on each other.

  • Loose coupling, where classes depend less on each other, makes your code more flexible, reusable, and stable.


SOLID Principles?

And now I'll move on to each SOLID principle one by one, starting from here.


SINGLE RESPONSIBILITY PRINCIPLE (SRP)

It says: A class should have only one reason to change, meaning there should be only one reason for its modification. In simple words, each class should have only one "responsibility."

Code Example(Without SRP)

class Employee {
    private String name;
    private double monthlySal;

    public Employee(String name, double monthlySal) {
        this.name = name;
        this.monthlySal = monthlySal;
    }

    public void printDetails() {
        System.out.println("Employee name: " + name + " and Salary: " + monthlySal);
    }

    public double calcSal() {
        return monthlySal * 12;
    }
}

In the above example, we can see that the Employee class has two responsibilities.

  1. Printing the details of the employee (printDetails() method).

  2. Calculating the salary (calcSal() method).

But if we need to add more details like contact no. , address, etc, and printing them ….. we’d end up modifying this class further.

This would violate the SRP, as the class has more than one reason to change.

  • Why is this a Violation ?

The Employee class manages both employee data and salary calculation, leading to multiple responsibilities.

According to SRP, each class should have a single responsibility to avoid complexity and improve maintainability.

  • So how can we resolve this problem?

We can do that by creating a separate class to handle the salary-related responsibilities. what I mean is - we can separate the responsibilities into different classes.

Code Example(With SRP)

class Employee {
    private String name;
    private double monthlySal;

    public Employee(String name, double monthlySal) {
        this.name = name;
        this.monthlySal = monthlySal;
    }

    public void printDetails() {
        System.out.println("Employee name: " + name);
        System.out.println("Salary: " + monthlySal);
    }

    public double getSal() {
        return monthlySal;
    }
}

class SalaryCalculator {
    public double calculateAnnualSalary(Employee emp) {
        return emp.getSal() * 12;
    }
}

In this example we can see that each class now has one single responsibility-

  • The Employee class is now solely responsible for managing employee data and printing details.

  • The SalaryCalculator class handles salary-related calculations.

And now this follows SRP without any violation.


OPEN-CLOSED PRINCIPLE (OCP)

It says: Classes should be open for extension but closed for modification.

  • In simple words, when requirements change, rather than altering existing, error-free, and tested code, we should extend the code with new functionality while keeping the existing code unchanged.

  • Basically, we should just make our own extensions with the behavior we want while still keeping the existing code/class closed for modification.

Code Example(Without OCP)

class AreaCalculator {
    public double calculateRectangleArea(double width, double height) {
        return width * height;
    }

    public double calculateCircleArea(double radius) {
        return Math.PI * radius * radius;
    }
}

Now what if I need to calculate the area for more shapes like a triangle or square?

In that case I must modify the AreaCalculator class, but that’ll violate the OCP (as the class is closed for modification) and I’m not allowed to alter it.

So, how can we fix it?

Code Example(With OCP)

// This is an interface that all shape classes must implement.
interface Shape {
    double calculateArea(); // This interface has one method and that calculates the area of the shape.
}

// Rectangle class implements the Shape interface.
class Rectangle implements Shape {
    private double width;
    private double height;

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

    @Override
    public double calculateArea() {
        return width * height;
    }
}

// Circle class implements the Shape interface.
class Circle implements Shape {
    private double radius;

    public Circle(double radius) {
        this.radius = radius;
    }

    @Override
    public double calculateArea() {
        return Math.PI * radius * radius;
    }
}

// AreaCalculator class uses the Shape interface.
class AreaCalculator {
    public double calculateArea(Shape shape) { //This method takes an object of Shape type.
        return shape.calculateArea();
    }
}

public class Main {
    public static void main(String[] args) {
        Shape rectangle = new Rectangle(10, 20);
        Shape circle = new Circle(5);

        AreaCalculator calculator = new AreaCalculator();

        // Calculate the areas
        System.out.println("Rectangle Area: " + calculator.calculateArea(rectangle));
        System.out.println("Circle Area: " + calculator.calculateArea(circle));
    }
}

Now coming back to our question again -

Q- What if we want to calculate the area of another shape, how do we add another shape, how can we do it without modifying the class, without violating OCP and how does this above code can help us in doing that ?

The answer is simple.

We need to follow OCP. Since we already know that all the classes need to implement the shape interface, So if we need a new shape lets say a square then all we need to do is - create a square shape class, implement the Shape interface and override the method.

class Square implements Shape {
    private double side;

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

    @Override
    public double calculateArea() {
        return side * side;
    }
}

To use the new shape, we simply create an instance of Square and pass it to the AreaCalculator:

Shape square = new Square(4);
AreaCalculator calculator = new AreaCalculator();
System.out.println("Square Area: " + calculator.calculateArea(square));

Java will call the specific implementation calculateArea() based on the actual type of the object. And if we look closely this is simply

"Runtime Polymorphism".

And that is how we created our own extension of the Square shape without modifying the original code.

* For passing I prefer doing it like this -

AreaCalculator calculator = new AreaCalculator();

System.out.println("Rectangle Area: " + calculator.calculateArea(new Rectangle(10, 20))); 
System.out.println("Circle Area: " + calculator.calculateArea(new Circle(5)));

Reasons-

  1. I get this feeling of Hard Coding in Approach 1:

    I was explicitly creating the shape's objects and passing the values so it felt predictable and hardcoded because I kind of already predefined what shapes and values to pass.

    It feels less flexible or "dynamic" because I know ahead of time what objects you’re dealing with.

  2. Natural Flow in Approach 2:

    This second approach feels more natural driven and dynamic. The reason for feeling that is- I extend the behaviour according to my requirement(i.e I created a new shape), then I invoke the behavior I needed (e.g., calculating the area) and at last I specify exactly what shape and values I want at the point of use.

    This makes it feel less "predefined".

NOTE

After learning about these two SOLID principles (SRP & OCP) I was confused. The reason for the confusion was another principle that I read in the book - "Head First Design Patterns".

  • Principle- "Identify the aspects of your application that vary and separate them from what stays the same”.

In simple words, it says that if you see that some part of your code is changing with every new change then pull it out and isolate it or separate it from all the stuff that doesn't change. Basically, it is asking us to take the parts that vary and encapsulate them, so that later we can change, alter, or extend the parts that vary without affecting those that don't.

  • At first, this seemed similar to the Single Responsibility Principle (SRP), which also involves separation but focuses on dividing responsibilities within a class. The key difference is that SRP is about giving a class only one responsibility, while this principle is about separating changing aspects from stable ones like separation is done based on what remains constant and what remains variable.

And right after clearing one confusion, it got even more confusing because it feels very similar to OCP as well, even more than SRP. OCP says that-

A class should be open for extension but closed for modification. And in the example above we clearly saw how we separated things neatly so we can prevent modifying the existing code.

Now - "Identify the aspects that vary and separate them from what stays the same" sounds very similar to OCP now as it also says something like that.

Why the confusion?

Here’s how they relate:

  1. Minimizing Impact of Changes: Both principles aim to reduce the impact of changes. OCP promotes extending classes rather than altering them, while separating varying aspects isolates changeable behaviors, often leading to extensions rather than modifications.

  2. Shared Techniques: Both principles use concepts like polymorphism and abstraction. They often involve creating an interface for stable behavior and multiple implementations for varying behavior.

These similarities can be confusing, but understanding them can also help clear up the confusion.

  • I believe the "Separating what varies" principle is a broad concept we can apply in many scenarios. It can help us identify parts of the code that change and isolate them from the stable parts. This can be useful when we’re dealing with classes that frequently change behavior, as it prevents these changes from affecting the constant parts.

  • And I feel OCP (Open-Closed Principle) fits somewhere within the "Separating what varies" principle. It has the same goal and a similar approach but with a subtle difference.

    To me, OCP feels like a subset of "Separating what varies." OCP specifically focuses on extending behavior, such as adding new functionality, without modifying existing code.

To conclude, "Identify the aspects that vary and separate them from what stays the same" offers a broad design strategy for structuring code by isolating changing parts from stable ones. OCP is a specific application of this strategy, focusing on extending systems without modifying existing code.


LISKOV SUBSTITUTION PRINCIPLE (LSP)

It says: If class B is a subtype of class A, then objects of class A should be replaceable with objects of class B without altering the correctness of the program.

  • In other words, a subclass should extend the capability of the base class, not narrow it down.

Code Example(Without LSP)

class Bird {
    public void legs() {
        System.out.println("Has two legs");
    }

    public void fly() {
        System.out.println("Can fly");
    }
}

class Sparrow extends Bird { }

class Eagle extends Bird {
    public void claws() {
        System.out.println("Eagle has claws");
    }
}

class Penguin extends Bird {
    @Override
    public void fly() {
        System.out.println("Penguins can't fly");
    }
}

public class Main {
    public static void main(String[] args) {
        List<Bird> birdList = new ArrayList<>();
        birdList.add(new Sparrow());
        birdList.add(new Eagle());
        birdList.add(new Penguin());

        for (Bird bird : birdList) {
            bird.fly();
        }
    }
}

Violations-

  1. Substitution Issue

    According to LSP, subclasses should be substitutable for their base classes without breaking the expected behavior. Here, when Penguin is substituted for Bird, it breaks the expected behavior because Penguin cannot fly.

    The assumption that all birds can fly is violated (if we don’t override the fly() ), leading to potential logical errors or incorrect assumptions in the program.

  2. Narrowing Behavior

    The Penguin class narrows down the behavior of Bird by overriding fly() to indicate that penguins cannot fly. This violates the LSP because Penguin reduces the functionality of the Bird class, contrary to LSP's requirement that subclasses should extend, not restrict, the capabilities of their base classes.

    For eg. Eagle class extended the capability of Bird ( added the claws() ).

So again, how do we fix this?

Code Example(With LSP)

interface Flyable { //Only birds that can fly will implement this interface.
    void fly();
}

class Bird {
    public void legs() {
        System.out.println("Has two legs");
    }
}

class Sparrow extends Bird implements Flyable {
    @Override
    public void fly() {
        System.out.println("Can fly");
    }
}

class Eagle extends Bird implements Flyable {
    @Override
    public void fly() {
        System.out.println("Can fly");
    }

    public void claws() {
        System.out.println("Eagle has claws");
    }
}

class Penguin extends Bird {
    /* No fly method here, as penguins can't fly. 
       This maintains the LSP because penguins are not assumed to be able to fly.
       Penguins have unique behavior that does not affect the flying birds. */
    public void swim() {
        System.out.println("Penguins can swim");
    }
}

public class Main {
    public static void main(String[] args) {
        List<Flyable> flyingBirds = new ArrayList<>();
        flyingBirds.add(new Sparrow());
        flyingBirds.add(new Eagle());

        for (Flyable bird : flyingBirds) {
            bird.fly();
        }

        Penguin penguin = new Penguin();
        penguin.swim();  
        /* Penguins are handled separately and do not appear in the flying birds list.
           Eagles extend functionality (with claws) and also adhere to LSP as they fit the Flyable interface. */
    }
}

Applying LSP: Use interfaces or abstract classes to separate varying behaviors and prevent subclasses from narrowing the functionality of their base classes.


Interface Segregation Principle (ISP)

It says: Interfaces should be in a way, that client should not end up implementing the unnecessary functions they do not need.

This principle simply states that no client should be forced to depend on the methods it doesn't use. It encourages designing smaller, more specific interfaces rather than a large monolithic interface that many classes implement but only need and use a subset of it.

Code Example(Without ISP)

interface Worker {
    void getPaid();
    void workHours();
    void submitReport();
}

class FullTimeEmployee implements Worker {
    @Override
    public void getPaid() {
        System.out.println("Full-time employee is paid.");
    }

    @Override
    public void workHours() {
        System.out.println("Full-time employee works 40 hours a week.");
    }

    @Override
    public void submitReport() {
        System.out.println("Full-time employee submits report.");
    }
}

class Contractor implements Worker {
    @Override
    public void getPaid() {
        System.out.println("Contractor does not use payroll.");
    }

    @Override
    public void workHours() {
        System.out.println("Contractor does not have fixed work hours.");
    }

    @Override
    public void submitReport() {
        System.out.println("Contractor submits report.");
    }
}

Violation of ISP

  • Unnecessary Implementation:

    We can clearly see that the Contractor class is forced to implement methods (getPaid() and workHours()) that are irrelevant to its functionality.

So, how do we fix this?

Code Example(With ISP)

interface Payable {
    void getPaid();
}

interface Workable {
    void workHours();
}

interface Reportable {
    void submitReport();
}

// Full-time employee implements all relevant interfaces
class FullTimeEmployee implements Payable, Workable, Reportable {
    @Override
    public void getPaid() {
        System.out.println("Full-time employee is paid.");
    }

    @Override
    public void workHours() {
        System.out.println("Full-time employee works 40 hours a week.");
    }

    @Override
    public void submitReport() {
        System.out.println("Full-time employee submits report.");
    }
}

// Contractor only implements Reportable as that's all they need
class Contractor implements Reportable {
    @Override
    public void submitReport() {
        System.out.println("Contractor submits report.");
    }
}

So what we did was split the original large Worker interface into Payable, Workable, and Reportable. Each interface now has a single responsibility and now Contractor only needs to implement Reportable.

NOTE

After reading and learning about ISP, I was again confused because of a principle that I read in the book- "Head First Design Patterns".

Principle - Program to an interface, not an implementation.

  • Firstly, this doesn't mean we should always use Java interfaces. The principle is broader, focusing on polymorphism and abstraction.

  • It’s about writing code that relies on abstractions (like interfaces or abstract classes) rather than specific implementations.

  • It’s about using supertypes (Using interfaces, abstract classes, and classes on the LHS). For eg., if you have a Shape class and use Shape shape = new Circle(); or

    Shape shape = new Square();, you're programming to an interface, making your code flexible and interchangeable.

Why the confusion?

  1. Both ISP and this principle help decouple our code. ISP focuses on breaking down large, rigid interfaces into more specific ones, so clients depend only on what they need. It advises, "Don’t force classes to implement interfaces they don’t need."

    On the other hand, "program to an interface" encourages designing code to rely on abstractions (like interfaces or abstract classes) rather than concrete implementations. This approach sounds similar to ISP, as both emphasize flexibility and reducing dependencies.

  2. Interface usage: Both principles encourage using interfaces to avoid rigid or hard dependencies on concrete implementations.

How to clear the confusion?

  • While "Program to an interface" and ISP overlap, they have subtle differences. Personally, I think of "Program to an Interface" as a broader, high-level design principle. More like advice on how we should design our system, to design it in a way that we can substitute one implementation for another without altering the dependent code.

  • It’s about flexibility in design.

  • And ISP (Interface Segregation Principle) gets even more specific about how we use interfaces. It focuses on the design of interfaces themselves, ensuring they are structured and split in a way that avoids forcing clients to implement methods they don’t need.

By coding to a supertype and following ISP, you end up with a flexible, loosely coupled system. Essentially, using ISP means you’re already following "Program to an Interface" because you’re focusing on smaller, targeted abstractions instead of big, rigid implementations.

Personally, I see "Program to an Interface" as a broad principle, with "ISP" being a specific application of it.


DEPENDENCY INVERSION PRINCIPLE (DIP)

It says: High-level components should not depend on our Low-level components; rather they both should depend on abstractions.

  • In simple words, it means we should write code that relies on abstractions (like interfaces) rather than concrete classes, avoiding tight coupling and hard coding.

Code Example(Without DIP)

public interface PaymentGateway {
    void processPayment(double amount);
}

public class PayPalGateway implements PaymentGateway {
    @Override
    public void processPayment(double amount) {
        System.out.println("Amount is: " + amount);
    }
}

public class Payment {
    private PayPalGateway payPalGateway = new PayPalGateway(); // Tight Coupling

    void process(double amount) {
        payPalGateway.processPayment(amount);
    }
}

When we use the new keyword to create instances, we're hardcoding dependencies, which leads to tight coupling. This approach is rigid and doesn’t align with the Dependency Inversion Principle (DIP).

So, how to fix this?

Code Example(With DIP)

// PaymentGateway interface
public interface PaymentGateway {
    void processPayment(double amount);
}

// Concrete implementation of PaymentGateway
public class PayPalGateway implements PaymentGateway {
    @Override
    public void processPayment(double amount) {
        System.out.println("Processing payment of" + amount + " via PayPal");
    }
}

// PaymentProcessor class
public class PaymentProcessor {
    private final PaymentGateway paymentGateway;

    // Constructor injection
    public PaymentProcessor(PaymentGateway paymentGateway) {
        this.paymentGateway = paymentGateway;
    }

    public void processPayment(double amount) {
        paymentGateway.processPayment(amount);
    }
}

What changed?

  • Loose Coupling: PaymentProcessor uses PaymentGateway without relying on a specific implementation.

  • Flexibility: PaymentProcessor can work with any PaymentGateway implementation, like RazorPayGateway, without needing changes.

NOTE

When I was studying, I found it very confusing as it was getting mixed up with the principles I had already learned. Those two principles were ISP and "Program to an Interface." These three felt so similar that I wasn't able to properly differentiate between them.

After giving myself a lot of time to understand them, I can see that they’re connected, but there are subtle differences. I’ve come to the conclusion that they form a sort of layered or circular structure, where each one builds on top of the other.

I think of them as something like -

  1. Dependency Inversion Principle (DIP) is the Broadest Concept.

  2. "Program to an Interface, Not an Implementation" as a Subset.

  3. And at last - Interface Segregation Principle (ISP) as the Most Specific.

DIP

  • DIP is like the foundation—the broadest principle, it’s like general advice on how we should structure our code.

  • DIP is about ensuring that high-level components do not depend on low-level components, but rather, both should depend on abstractions (interfaces or abstract classes). This principle pushes us to think about the relationship between different parts of a system and how they interact through abstractions to remain loosely coupled and flexible.

  • Now, if you think about it, this principle connects directly to the idea of "Program to an Interface, Not an Implementation."

Program to an Interface, Not an Implementation

  • The principle of "Program to an Interface" is a specific application of DIP.

    It involves using supertypes on the left-hand side of assignments.

    For example, instead of writing Circle c = new Circle(), you write

    Shape c = new Circle().

    This approach uses polymorphism, allowing you to swap implementations without breaking the code. While DIP is about the overall structure and relationships between high- and low-level components,

    "Program to an Interface" focuses on how individual components interact.

  • But it doesn’t stop there. Once we’re working with abstractions, the next question is: how do we design our interfaces? This is where the Interface Segregation Principle (ISP) comes in.

ISP

  • I think of ISP as the most specific principle. It focuses on how we design interfaces, ensuring they are small and specific rather than large and monolithic.

  • Instead of forcing a class to implement an interface with methods it doesn’t need (which can lead to bloated code), ISP advocates for breaking interfaces into smaller, more focused ones. This allows classes to implement only the methods they actually need.

And When all these three principles are used in harmony, the result is a very flexible, loosely coupled, and highly maintainable system.

We end up with a system where the high-level logic doesn’t care about the small details of low-level components, and the low-level components are interchangeable and focused on doing what they’re supposed to do without any unnecessary methods hanging around.

But there are some key differences that we need to keep in mind-

  1. DIP is not just about "program to supertypes" or only about inheritance but also about decoupling high- and low-level modules, which can include the use of dependency injection and other design patterns.

  2. Program to an Interface doesn’t necessarily dictate how the abstraction is built (it could be through inheritance or interface implementation).

  3. ISP is about breaking interfaces down for clearer responsibility, not just avoiding large interfaces.

………

And as I was wrapping things up, I noticed something interesting: following DIP often means we’re also following the Hollywood Principle.

The Hollywood Principle says, “Don’t call us, we’ll call you” which means high-level components don’t directly call low-level components. Instead, low-level components are managed by high-level ones through callbacks, interfaces, or events.

So why do I feel they kind of connect?

  • That is because- In DIP when we decouple the high-level components from the low-level components by having them depend on an interface or abstraction, we’re letting the high-level component control the flow and decide when to interact with the lower-level components.

  • And this is exactly what the Hollywood Principle is about—high-level components (the decision-makers) can “call” the lower-level components (via abstractions/interfaces) when necessary, not the other way around.

  • Both encourage Inversion of Control (IoC)—high-level components manage interactions, often using patterns like Dependency Injection, where low-level details are handled indirectly.


Additionally, there are other important principles to consider. You can explore these further in the book Head First Design Patterns by Eric Freeman and Elisabeth Robson. Some of these principles include:

  1. Favor Composition over Inheritance: Use object composition to build functionality instead of relying on inheritance.

  2. Strive for Loosely Coupled Designs: Make sure components work independently to keep your system flexible and easy to maintain.

  3. The Principle of Least Knowledge (Law of Demeter): Let objects interact only with their direct partners, avoiding deep knowledge of others' internals.


CONCLUSION:

My Process of Learning and Evolving

The SOLID principles have been a key part of my journey in understanding software design, and through this blog, I’ve documented how I navigated the confusion and reached my current understanding.

These principles—whether it’s breaking down large interfaces with ISP or using DIP to decouple components—are crucial tools that have helped me shape better, more maintainable code.

But this is not a guide or a textbook—it’s my process, with all the trial and error that comes with learning.

There might be things I misunderstood or overlooked, and I’m okay with that.

If you spot something that doesn’t quite sit right, feel free to correct me

we’re all constantly evolving and learning in this field.

At the end of the day, SOLID isn’t about rigid rules; it’s about creating code that’s adaptable and resilient.

I hope sharing my journey helps you in your own process of learning and applying these principles.

"What part of your software design could benefit most from a SOLID principle?"


My Resources:

For further reading, I recommend "Head First Design Patterns" by Eric Freeman and Elisabeth Robson. You can find it on-

Amazon

O'Reilly


Thank you for reading!


2
Subscribe to my newsletter

Read articles from dumbestprogrammer directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

dumbestprogrammer
dumbestprogrammer

Master's Student| Math & Comp Apps Grad| Aspiring Java Dev| Aspiring Java Backend Dev | Spring & Spring Boot| Adaptive Learner| Persistent and Positive