5 SOLID Principles Every Developer Should Know

Umesh MauryaUmesh Maurya
9 min read

Have you ever worked on a codebase that felt like a tangled web of confusion—where making a small change risks breaking five other things? If you’ve been there, you know how frustrating and time-consuming it can be. Clean, maintainable code isn’t just a luxury—it’s a necessity for building scalable, robust applications. That’s where good software design principles come in. Among them, the SOLID principles stand out as a powerful set of guidelines that help developers write code to understand, modify, and extend. Before diving into these principles, let’s take a step back and understand why they matter.

🧠 Why Do Design Principles Matter?

Before jumping into the SOLID principles, it's important to understand the problem they aim to solve.

As software grows, it becomes increasingly complex. Without proper structure, codebases can quickly devolve into what's commonly known as "spaghetti code"—hard to read, hard to test, and nearly impossible to maintain. In such systems, even the smallest change can cause unexpected side effects, making developers hesitant to touch anything at all.

This is where design principles come into play. They provide a roadmap for writing clean, flexible, and maintainable code. These principles help developers:

  • Reduce code duplication

  • Improve readability and testability

  • Minimize the impact of changes

  • Promote better collaboration in teams

The SOLID principles, coined by Robert C. Martin (Uncle Bob), are among the most influential design guidelines in object-oriented programming. They’re not just academic theory—they're practical, real-world strategies that can elevate your codebase from “it works for now” to “this will scale.”

Now that we understand why principles matter, let’s explore the SOLID principles one by one and see how they can transform the way we write software.

The Solid Principles


🔠 S — Single Responsibility Principle (SRP)

A class should have only one reason to change.


🧍‍♂️ Real-Life Example: The Restaurant Employee

Imagine a small restaurant where one person does everything:

  • Takes customer orders

  • Cooks the food

  • Handles billing

  • Cleans the tables

At first, this might seem efficient in a tiny setup, but what happens when:

  • Is there a big rush?

  • That person falls sick?

  • The menu changes or billing software is updated?

It becomes a mess. Any change in one responsibility (like switching to a new payment system) affects all their other duties. Mistakes happen, and service quality drops.

💡 Now compare that to a professional restaurant:

  • A waiter takes orders

  • A chef cooks

  • A cashier handles billing

  • A cleaning staff maintains hygiene

Each role has a single responsibility. They can improve, be replaced, or upgraded independently without affecting others.

🧑‍💻 In Code Terms:

Think of a class InvoiceManager that:

  • Calculates invoice totals

  • Saves the invoice to a database

  • Sends an email to the customer

That's like our overworked restaurant guy.

✅ A better design:

  • InvoiceCalculator

  • InvoiceRepository

  • InvoiceEmailSender

Each has one job, one reason to change, and the system becomes easier to maintain, test, and scale.

Code That Violates SRP

public class InvoiceManager {

    public void calculateTotal() {
        // Logic to calculate total
        System.out.println("Calculating total amount...");
    }

    public void saveToDatabase() {
        // Logic to save invoice to DB
        System.out.println("Saving invoice to database...");
    }

    public void sendInvoiceEmail() {
        // Logic to send email
        System.out.println("Sending invoice email to customer...");
    }
}

Code That Follows SRP

public class InvoiceCalculator {
    public void calculateTotal() {
        System.out.println("Calculating total amount...");
    }
}

public class InvoiceRepository {
    public void saveToDatabase() {
        System.out.println("Saving invoice to database...");
    }
}

public class InvoiceEmailSender {
    public void sendInvoiceEmail() {
        System.out.println("Sending invoice email to customer...");
    }
}

Now, each class has one clear job. If the email formatting changes, you only touch InvoiceEmailSender. Much cleaner and SRP-compliant.


🔠 O — Open/Closed Principle (OCP)

Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification


🧍‍♂️ Real-Life Example: Electric Switchboard

Imagine a modern switchboard in your home.
You’ve got buttons to control:

  • Lights

  • Fans

  • Air Conditioner

Now, you decide to add a new appliance—say, a smart curtain.
Would you rip apart the whole board and rewire everything?
Of course not. You just plug it into an existing port or add a new switch. The existing setup stays untouched.

💡 Lesson: A good design allows you to extend behavior without modifying what's already working. Less risk, more flexibility.

🧑‍💻 Code Example (Without OCP)

public class NotificationService {
    public void sendNotification(String type, String message) {
        if ("EMAIL".equals(type)) {
            System.out.println("Sending Email: " + message);
        } else if ("SMS".equals(type)) {
            System.out.println("Sending SMS: " + message);
        }
    }
}

This violates OCP. Every time a new notification type (like WhatsApp or Push) is added, you must modify this method, risking bugs.

Refactored Code That Follows OCP

public interface NotificationSender {
    void send(String message);
}

public class EmailSender implements NotificationSender {
    public void send(String message) {
        System.out.println("Sending Email: " + message);
    }
}

public class SmsSender implements NotificationSender {
    public void send(String message) {
        System.out.println("Sending SMS: " + message);
    }
}

public class NotificationService {
    public void notify(NotificationSender sender, String message) {
        sender.send(message);
    }
}

Now if you want to add a WhatsAppSender, just create a new class — no need to touch existing code. That’s Open for Extension, Closed for Modification.


🔠 L — Liskov Substitution Principle

Subtypes must be substitutable for their base types without altering the correctness of the program.”
Or simply: “If class B is a subclass of class A, you should be able to use B wherever A is expected — without breaking the behavior.


🧍‍♂️ Real-Life Example: Vehicle Rental

Imagine a car rental system.
You can rent:

  • A Car

  • A Bike

  • A Scooter

All vehicles can be booked and driven. So you create a Vehicle class and let Car, Bike, and Scooter inherit from it.

Now, suppose you add a new type: StationaryBike (you can rent it for exercise, but it doesn’t drive).
You pass it into your drive() method that works with Vehicle — and it throws an error or does nothing.

💥 You just violated LSP — because StationaryBike you cannot truly substitute Vehicle that is expected to be drivable.

💡 Lesson: Subclasses should respect the behavior of the base class. If the parent can do something (like drive), the child must honor and support that, not break it.

🧑‍💻 Code That Violates LSP

public class Bird {
    public void fly() {
        System.out.println("Bird is flying");
    }
}

public class Ostrich extends Bird {
    @Override
    public void fly() {
        throw new UnsupportedOperationException("Ostriches can't fly");
    }
}

Now, suppose you write:

public void letBirdFly(Bird bird) {
    bird.fly();
}

Calling letBirdFly(new Ostrich()) will crash. Even though Ostrich is a Bird, it breaks expected behavior.

Refactored Code That Follows LSP

Instead, structure it like this:

public interface Flyable {
    void fly();
}

public class Sparrow implements Flyable {
    public void fly() {
        System.out.println("Sparrow is flying");
    }
}

public class Ostrich {
    public void walk() {
        System.out.println("Ostrich is walking");
    }
}

Now, fly() is only implemented by birds that actually fly. No confusion, no broken expectations.


🔠 I — Interface Segregation Principle

“Clients should not be forced to depend on interfaces they do not use.”
In simpler terms: It’s better to have multiple small, specific interfaces than one big, all-purpose interface.


🧍‍♂️ Real-Life Example: Multi-Function Printer

Imagine you buy a multi-function printer that can:

  • Print

  • Scan

  • Fax

You give it to someone who just wants to print documents at home.

But every time they try to use it, the printer asks for a fax line to be connected before working. Annoying, right?

💡 Lesson: Users shouldn’t be forced to interact with features they don’t care about. They should only deal with what they actually use.

🧑‍💻 Code That Violates ISP

public interface Machine {
    void print();
    void scan();
    void fax();
}

public class OldPrinter implements Machine {
    public void print() {
        System.out.println("Printing...");
    }

    public void scan() {
        throw new UnsupportedOperationException("Scan not supported");
    }

    public void fax() {
        throw new UnsupportedOperationException("Fax not supported");
    }
}

OldPrinter just wants to print, but it’s forced to implement methods it doesn’t support. That’s a violation of ISP.

Refactored Code That Follows ISP

public interface Printer {
    void print();
}

public interface Scanner {
    void scan();
}

public interface Fax {
    void fax();
}

public class OldPrinter implements Printer {
    public void print() {
        System.out.println("Printing...");
    }
}

public class AllInOnePrinter implements Printer, Scanner, Fax {
    public void print() {
        System.out.println("Printing...");
    }

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

    public void fax() {
        System.out.println("Faxing...");
    }
}

Now, each device only implements what it actually supports. No unused or unsupported methods cluttering the code.


🔠 D — Dependency Inversion Principle (DIP)

“High-level modules should not depend on low-level modules. Both should depend on abstractions.”
And “Abstractions should not depend on details. Details should depend on abstractions.”


🧍‍♂️ Real-Life Example: Smart Remote and Devices

Imagine you have a smart remote that can control:

  • A TV

  • An AC

  • A Sound System

But instead of connecting through a universal standard (like Bluetooth or Infrared), the remote has hardcoded buttons for a specific brand — "Samsung TV," "Sony Sound System," etc.

Now you buy a new LG TV, and the remote becomes useless.

💡 Lesson: The remote (high-level logic) should not directly depend on the specific devices (low-level details).
Instead, both should agree on a common interface like PowerOn(), PowerOff(), etc.

That way, you can plug in any device that implements the interface — and the remote just works.

🧑‍💻 Code That Violates DIP

public class LightBulb {
    public void turnOn() {
        System.out.println("LightBulb is ON");
    }

    public void turnOff() {
        System.out.println("LightBulb is OFF");
    }
}

public class Switch {
    private LightBulb bulb;

    public Switch(LightBulb bulb) {
        this.bulb = bulb;
    }

    public void operate() {
        bulb.turnOn();
    }
}

Here, the Switch is tightly coupled to LightBulb. If we want to switch to Fan or Heater, we must change the switch class.

Code That Follows DIP

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

public class LightBulb implements SwitchableDevice {
    public void turnOn() {
        System.out.println("LightBulb is ON");
    }

    public void turnOff() {
        System.out.println("LightBulb is OFF");
    }
}

public class Fan implements SwitchableDevice {
    public void turnOn() {
        System.out.println("Fan is ON");
    }

    public void turnOff() {
        System.out.println("Fan is OFF");
    }
}

public class Switch {
    private SwitchableDevice device;

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

    public void operate() {
        device.turnOn();
    }
}

Now the Switch can work with any device that implements SwitchableDevice — No changes needed in the Switch class. That’s the power of decoupling via abstraction.


SOLID Principles Recap

PrincipleWhat It MeansReal-Life Analogy
S – Single ResponsibilityA class should do one thing onlyA restaurant employee with one job: waiter, chef, or cleaner
O – Open/ClosedOpen for extension, closed for modificationA switchboard where you can add a new device without rewiring
L – Liskov SubstitutionSubclasses must honor the base class behaviorA non-flying bird (like an Ostrich) shouldn’t replace a flying one
I – Interface SegregationDon't force classes to implement unused interfacesA printer that doesn’t need to scan or fax
D – Dependency InversionDepend on abstractions, not concrete implementationsA universal remote that works with any device using a standard interface

Thank you for reading! If you found this helpful, please leave a like. 😊
I'll be covering all topics related to Low-Level Design (LLD) and High-Level Design (HLD) in upcoming articles, so make sure to subscribe to my Newsletter to stay updated.
You can also connect with me on Twitter and LinkedIn. 🤠

0
Subscribe to my newsletter

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

Written by

Umesh Maurya
Umesh Maurya

I've 2.5+ years of experience in software development and am willing to share my knowledge while working on real-time IT solutions.