5 SOLID Principles Every Developer Should Know


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
Principle | What It Means | Real-Life Analogy |
S – Single Responsibility | A class should do one thing only | A restaurant employee with one job: waiter, chef, or cleaner |
O – Open/Closed | Open for extension, closed for modification | A switchboard where you can add a new device without rewiring |
L – Liskov Substitution | Subclasses must honor the base class behavior | A non-flying bird (like an Ostrich) shouldn’t replace a flying one |
I – Interface Segregation | Don't force classes to implement unused interfaces | A printer that doesn’t need to scan or fax |
D – Dependency Inversion | Depend on abstractions, not concrete implementations | A 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. 🤠
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.