• Home /
  • DSA /
  • SOLID Principles in Practice: How to Design Code That Doesn’t Break When Requirements Change

SOLID Principles in Practice: How to Design Code That Doesn’t Break When Requirements Change

Picture this.

It is 11:45 PM. Your senior calls you in a panic. A critical bug has hit production. You open the codebase and stare at a class that is 1,200 lines long. It handles user authentication, sends emails, writes to the database, and generates PDFs – all in one place.

You fix one line. Three other features break.

You are afraid to touch anything.

Sounds familiar?

This is what happens when SOLID principles are ignored. And this is exactly what SOLID was designed to prevent.

In this blog, we will break down each of the five SOLID principles with real-world analogies, before/after code, and visual diagrams. By the end, you will not just know what SOLID stands for – you will know when and why to apply each principle.

What Is Low-Level Design - And Why Does It Start With SOLID?

When people hear System Design, they usually think about the big picture: microservices, load balancers, and databases. That is High Level Design (HLD).

Low-Level Design (LLD) zooms in on what happens inside a single service or module. How do you structure your classes? How do they talk to each other? What happens when requirements change next quarter?

SOLID is the answer to those questions. These five principles – each named after a letter – define how to write object-oriented code that is easy to read, extend, test, and hand off to another developer without a two-hour explanation.

NOTE

SOLID principles were introduced by Robert C. Martin (Uncle Bob) and remain the gold standard for object-oriented design – asked in almost every SDE interview at top product companies.

Quick Reference: The Five SOLID Principles

Principle

Full Form

One-Line Rule

S

Single Responsibility

One class = one job

O

Open/Closed

Open to extend, closed to modify

L

Liskov Substitution

Subclass must honour the parent’s contract

I

Interface Segregation

No class should depend on methods it doesn’t use

D

Dependency Inversion

Depend on abstractions, not concrete classes

Let us now go through each one with a real-world story.

S - Single Responsibility Principle (SRP)

“A class should have only one reason to change.”

At first glance, this sounds like:

“One class = one job”

But that’s an oversimplification.

💡 The real idea

A “responsibility” is actually an axis of change.

A class violates SRP if it can change for multiple unrelated reasons.

Think about a chef at a restaurant.

The chef cooks the food. That is their one job.

Now imagine you asked the same chef to also take orders, clean tables, and manage billing. What happens?

Everything slows down. If the billing system changes, you have to retrain the chef. If the menu changes, suddenly your billing person’s job is affected too.

That’s exactly what happens in code when one class does too many things.

The Bad Way: The “God Class”

A UserService that handles registration, sends emails, persists data, and generates reports has four completely different reasons to change. Change the email provider? Touch UserService. Switch databases? Touch UserService. New report format? Touch UserService.

// BAD: One class doing everything
class UserService {
public void registerUser(String email, String password) { … }
public void sendWelcomeEmail(String email) { … }   // email concern
public void saveToDatabase(User user) { … }      // DB concern
public String generateUserReport(User user) { … }  // reporting concern
}

🚨 What goes wrong?

The Right Way: One Class, One Job

Step 1: Identify responsibilities

Step 2: Split by responsibility

// GOOD: Each class has one job
class UserRegistrationService {
public void registerUser(String email, String password) { … }
}

class EmailService {
public void sendWelcomeEmail(String email) { … }
}

class UserRepository {
public void save(User user) { … }
}

class UserReportGenerator {
public String generate(User user) { … }
}

🎯 What improved?

RULE ✅
Ask: "If requirements change in area X, which classes do I need to edit?"

If the answer for a single class spans multiple business concerns – registration logic, notification, persistence – it is time to split.

The goal is not small classes for the sake of it. It is classes with a clear, single owner – so you always know exactly where to go when something needs to change.

O - Open/Closed Principle (OCP)

“Software entities should be open for extension, but closed for modification.”

Think about how a smartphone works. When a new app is released, you download it. You don’t crack open the phone and rewire the hardware to make it work. The phone was designed to accept new behaviour (apps) without its core being changed.

That is exactly the relationship OCP wants between your existing code and new features. You should be able to add new behaviour by writing new code – not by editing code that is already tested, deployed, and trusted.

Why does this matter in practice? Every time you edit existing production code, you risk introducing regressions. If adding a new payment method means opening PaymentProcessor.java and adding another else-if branch, you are one typo away from breaking all existing payment methods.

The Bad Way: The if-else Trap

This pattern is extremely common in early-stage codebases. It works fine when there are two payment types. By the time there are seven, it is a dense chain of conditions that terrifies everyone on the team.

// ❌ BAD: Every new payment method forces you to edit (and risk breaking) this class
class PaymentProcessor {
public void process(String type, double amount) {
if (type.equals(“CREDIT_CARD”)) {

// credit card processing logic}

 

else if (type.equals(“UPI”)) {
// UPI processing logic
} else if (type.equals(“NET_BANKING”)) {
// net banking logic
}
// Need to add PayPal? Edit this file.
// Need to add crypto? Edit this file.
// Each edit risks breaking all existing paths.
}
}

The Right Way: Code to an Interface

Define a PaymentMethod interface. Each method is its own class. Adding PayPal means creating a new file – no existing file is touched. The processing logic that drives the payment can simply call method.pay(amount) and never needs to know which implementation it is working with.

// ✅ GOOD: New payment methods are added as new classes — existing code untouched
interface PaymentMethod {
void pay(double amount);
boolean validate();
}

class CreditCardPayment implements PaymentMethod {
public void pay(double amount) {
// Stripe / credit card gateway logic
}
public boolean validate() { … }
}

class UpiPayment implements PaymentMethod {
public void pay(double amount) {
// UPI gateway logic
}
public boolean validate() { … }
}

// Adding PayPal tomorrow? Create a new class. Zero risk to existing code.
class PayPalPayment implements PaymentMethod {
public void pay(double amount) { … }
public boolean validate() { … }
}

// The caller never changes, regardless of how many methods you add:
class PaymentProcessor {
public void process(PaymentMethod method, double amount) {
if (method.validate()) {
method.pay(amount); // works with any PaymentMethod implementation
}
}
}

o-Open/Closed Principle: Add new Behaviour via new classes – never by editing existing ones

This is not a theoretical pattern. It is exactly how Razorpay, Stripe, and PhonePe manage their payment integrations — each gateway is an isolated implementation of a shared contract. Product teams add new gateways without touching the payment orchestration engine.

Closed for modification doesn’t mean never changing code — it means avoiding changes to stable, trusted logic just to add new features.

The goal is not to eliminate if-else statements entirely, but to prevent them from growing in parts of the code that change frequently.

L — Liskov Substitution Principle (LSP)

“Subclasses should be replaceable with their parent class without breaking the program.”

LSP was formulated by Barbara Liskov in 1987 and is arguably the most conceptually subtle of the five principles – because it is the one that inheritance can silently violate.

Here is the intuition: if your code works with a parent class, it should work equally well with any subclass – without surprises, without exceptions, without silent behaviour changes.

The classic counterexample is the Square-Rectangle problem. In geometry, a square IS a rectangle. So it feels natural to write class Square extends Rectangle. But in code, this creates a contract violation.

LSP is fundamentally about preserving the contract defined by the parent class-what methods do, not just their signatures.

The Bad Way: Square Extends Rectangle

Rectangle has setWidth() and setHeight() as independent operations. Square forces them to always be equal. Any code that expects to independently set width and height and then compute the area will get the wrong answer when handed a Square.

class Rectangle {
protected int width, height;
public void setWidth(int w)  { this.width = w; }
public void setHeight(int h) { this.height = h; }
public int area()        { return width * height; }
}

// ❌ LSP VIOLATION: Square silently overrides the contract
class Square extends Rectangle {
@Override
public void setWidth(int w)  { this.width = this.height = w; }  // forces equality!
@Override
public void setHeight(int h) { this.width = this.height = h; }  // forces equality!
}

// Caller — written against Rectangle’s contract:
void printExpectedArea(Rectangle r) {
r.setWidth(5);
r.setHeight(10);
System.out.println(r.area());
// Expected: 50  |  Actual if r is a Square: 100  ← silent, dangerous bug
}

The Right Way: Model Reality, Not Geometry

The fix is not to patch the Square. The fix is to recognise that Square and Rectangle behave differently and should not be in a parent-child relationship. Instead, give them a common abstract base that makes no assumptions about independent dimensions.

// ✅ GOOD: Common abstract base with no conflicting contracts
abstract class Shape {
abstract double area();
abstract double perimeter();
}

class Rectangle extends Shape {
private final int width, height;
public Rectangle(int w, int h) { this.width = w; this.height = h; }

@Override public double area()  { return width * height; }
@Override public double perimeter() { return 2 * (width + height); }
}

class Square extends Shape {
private final int side;
public Square(int s) { this.side = s; }

@Override public double area()  { return side * side; }
@Override public double perimeter() { return 4 * side; }
}

// Now any caller using Shape works perfectly with both — no surprises
void printArea(Shape s) {
System.out.println(s.area());   // always correct, regardless of subtype
}

KEY INSIGHT ⚠

LSP violations are the hardest to spot because the code compiles perfectly. The bug only appears at runtime, often in a completely different part of the system from where the violation was introduced.

A quick LSP test: if your subclass throws UnsupportedOperationException for a method the parent defines, or silently changes what a method does, you have a Liskov violation.

Inheritance should model IS-A relationships in behaviour, not just in vocabulary. A Square IS-A Rectangle in English. But a Square does NOT behave like a Rectangle in code.

I - Interface Segregation Principle (ISP)

“No client should be forced to depend on methods it does not use.”

Imagine using a music streaming app like Spotify.

You just want to play songs.

But the app forces you to also manage artist profiles, upload tracks, configure ads, and analyze listener data – all before you can hit play.

That’s frustrating, because you’re being forced to interact with features you don’t need.

This is exactly what a fat interface does to a class.

ISP says: Give each client only the methods it actually needs — nothing more.

ISP is about designing interfaces from the perspective of the client that uses them, not the class that implements them.

The Bad Way: The Fat Interface

A classic example is a Worker interface that defines work(), eat(), and sleep(). Human workers need all three. Robots only work. But if both implement Worker, the robot is forced to provide implementations of eat() and sleep() — methods that are conceptually meaningless for it.

// ❌ BAD: Fat interface forces unrelated methods on every implementor
interface Worker {
void work();
void eat();    // makes no sense for a machine
void sleep();  // makes no sense for a machine
void takeLunch();  // makes no sense for a machine
}

class RobotWorker implements Worker {
public void work()  { /* actual robot logic */ }
public void eat()    { throw new UnsupportedOperationException(); }  // forced!
public void sleep() { throw new UnsupportedOperationException(); }  // forced!
public void takeLunch() { throw new UnsupportedOperationException(); }  // forced!
// Three landmines hiding in this class
}

The Right Way: Lean, Focused Interfaces

Split the fat interface into three slim ones. HumanWorker implements all three because humans genuinely do all three things. RobotWorker implements only Workable – nothing extra, nothing forced.

// ✅ GOOD: Each interface represents one cohesive capability
interface Workable {
void work();
}
 
interface Feedable {
void eat();
void takeLunch();
}
 
interface Restable {
void sleep();
}
 
// Human: opts into all three naturally
class HumanWorker implements Workable, Feedable, Restable {
public void work()  { /* coding, designing, testing */ }
public void eat()    { /* lunch break */ }
public void takeLunch() { /* steps away from desk */ }
public void sleep() { /* overnight rest */ }
}
 
// Robot: opts into only what is relevant
class RobotWorker implements Workable {
public void work() { /* 24/7 automation loop */ }
// No forced exceptions. No dead methods. Clean.
}

In production systems, ISP matters enormously in notification services. An EmailNotifier should implement an Emailable interface. An SMSNotifier implements Textable. Neither should be forced into a single monolithic Notifier interface that defines push notifications, in-app alerts, and WhatsApp messages in one place.

The payoff is testability. When your interface is narrow and focused, you can mock it with one line in a unit test. When your interface has 12 methods, you need to stub all 12 even if the thing under test only calls two of them.

D - Dependency Inversion Principle (DIP)

“High-level modules should not depend on low-level modules. Both should depend on abstractions.”

When you plug your laptop charger into a socket, you do not think about whether the power is coming from a hydroelectric dam, a solar farm, or a coal plant. The socket is the abstraction. Your charger depends on the socket interface, not on any specific power source.

DIP asks you to build your code the same way. Your business logic (the high-level module) should not have a direct dependency on the specific database implementation, the specific email provider, or the specific logging library. It should depend on an interface. The concrete implementation is supplied from outside.

This is the principle that makes Dependency Injection (DI) frameworks like Spring possible. When you see @Autowired in a Spring application, you are looking at DIP in action.

💡 “DIP = Depend on abstractions, not concrete implementations.”

The Bad Way: Hardwired Dependencies

OrderService directly instantiates MySQLDatabase. They are tightly coupled. Switching to MongoDB for a specific use case means rewriting OrderService. Adding a test database means rewriting OrderService. Every infrastructure decision forces you into business logic.

// ❌ BAD: Business logic is wired directly to infrastructure
class OrderService {
// This line makes OrderService impossible to test without a real MySQL instance
private MySQLDatabase db = new MySQLDatabase(“jdbc:mysql://localhost/orders”);
 
public void placeOrder(Order order) {
       db.save(order);
       // What if tomorrow the team decides: MongoDB for orders, MySQL for users?
       // You must rewrite this class. And anything that calls it.
}
}

The Right Way: Inject the Abstraction

OrderService now depends on a Database interface. It has no idea whether the concrete implementation is MySQL, MongoDB, or an in-memory test double. That decision is made outside — by the caller, or by a DI framework. Swapping databases becomes a one-line config change.

// ✅ GOOD: OrderService depends on an abstraction — not a specific DB
interface Database {
void save(Order order);
Optional<Order> findById(Long id);
}

class MySQLDatabase implements Database {
public void save(Order order) { /* MySQL-specific JDBC logic */ }
public Optional<Order> findById(Long id) { /* JDBC query */ }
}

class MongoDatabase implements Database {
public void save(Order order) { /* MongoDB driver logic */ }
public Optional<Order> findById(Long id) { /* Mongo query */ }
}

// In-memory implementation for unit tests — no real DB needed
class InMemoryDatabase implements Database {
private final Map<Long, Order> store = new HashMap<>();
public void save(Order order) { store.put(order.getId(), order); }
public Optional<Order> findById(Long id) { return Optional.ofNullable(store.get(id)); }
}

class OrderService {
private final Database db; // depends on the interface

// The concrete implementation is injected — by Spring, or by the caller
public OrderService(Database db) { this.db = db; }

public void placeOrder(Order order) {
db.save(order); // works identically with MySQL, Mongo, or InMemory
}
}

The practical impact of DIP is enormous. At Zomato’s scale, the order processing service cannot be restarted every time the team wants to swap from one database flavour to another. DIP is what lets infrastructure decisions be made independently of business logic, and what makes unit testing possible without spinning up real infrastructure in a CI pipeline.

SPRING NOTE 🎯

In Spring Boot, when you annotate a class with @Service and inject it via @Autowired, Spring is resolving the concrete implementation at runtime based on your configuration.

The class receiving the injection only knows the interface – it does not know or care which bean was wired in. That is DIP at the framework level, and it is one of the primary reasons Spring became the dominant Java framework.

A Practical Note: When Not to Over-Engineer

SOLID is a guide, not a mandate. Applied rigidly to every situation, it leads to over-engineering: dozens of tiny classes and interfaces for code that will never change.

The right heuristic: apply SOLID when you are designing code that is expected to grow or be extended. For a one-off script, a proof of concept, or a 30-line utility function, the overhead of full SOLID compliance is not worth it.

Robert Martin himself said the goal is to tolerate change, not to anticipate every possible change. Design for the changes you can see coming, not for every permutation of the future.

Final Thoughts

SOLID is not a checklist you memorise the night before an interview. It is a mindset you build over time by writing code, reading other people’s code, and noticing what is easy to change and what is painful to change.

Every time you write a class, run these five questions through your mind:

Over time, these questions become automatic. That is when you move from writing code that works to writing code that lasts.

Ready to apply SOLID in real interview scenarios? Explore our DSA + System Design course built for top product company prep.

Scroll to Top