SOLID Principles
What is SOLID?
SOLID is a set of 5 design principles for writing clean object-oriented code. Coined by Robert C. Martin (Uncle Bob).
| Letter | Principle | One-line summary |
|---|---|---|
| S | Single Responsibility Principle | One class, one job |
| O | Open / Closed Principle | Open for extension, closed for modification |
| L | Liskov Substitution Principle | Subtypes must be safely replaceable by their parent |
| I | Interface Segregation Principle | Don't force classes to implement what they don't need |
| D | Dependency Inversion Principle | Depend on abstractions, not concrete classes |
S — Single Responsibility Principle (SRP)
Definition
"A class should have only one reason to change." — Robert C. Martin
Every class should do one thing and do it well. If a class has multiple responsibilities, a change in one responsibility can break the other.
The Core Idea
One class = one job = one reason to change
If you find yourself saying "this class handles X AND Y AND Z"
— it is doing too much.
Split it.
❌ Without SRP
class UserService {
// Responsibility 1: User business logic
public User getUserById(String userId) { ... }
public void updateUser(User user) { ... }
// Responsibility 2: Database interaction
public User queryFromDB(String userId) {
// raw SQL here — DB logic inside business class
}
// Responsibility 3: Email notifications
public void sendWelcomeEmail(User user) {
// SMTP connection, email templating here
}
// Responsibility 4: Logging
public void logUserActivity(String userId, String action) {
// writing to log files here
}
}
Problems:
- Change in email provider → touch
UserService - Change in DB schema → touch
UserService - Change in log format → touch
UserService - One class, four reasons to change = fragile
✅ With SRP
// Responsibility 1: User business logic only
class UserService {
private UserRepository userRepo;
private EmailService emailService;
private Logger logger;
public User getUserById(String userId) {
return userRepo.findById(userId);
}
public void registerUser(User user) {
userRepo.save(user);
emailService.sendWelcomeEmail(user);
logger.log("New user registered: " + user.getId());
}
}
// Responsibility 2: DB interaction only
class UserRepository {
public User findById(String userId) { /* DB query */ }
public void save(User user) { /* DB insert */ }
}
// Responsibility 3: Email only
class EmailService {
public void sendWelcomeEmail(User user) { /* SMTP logic */ }
public void sendPasswordReset(User user) { /* SMTP logic */ }
}
// Responsibility 4: Logging only
class Logger {
public void log(String message) { /* file/console write */ }
}
Result: Change email provider → touch only EmailService. DB change → touch only UserRepository. Nothing else breaks.
Real-World System Design Example
❌ Monolith doing everything:
UserService → handles auth, profile, notifications, billing, analytics
✅ Microservices with SRP:
AuthService → only authentication & tokens
UserProfileService → only user data management
NotificationService → only sending emails/SMS/push
BillingService → only payments & invoices
AnalyticsService → only event tracking
Each service has one reason to change. Scale them independently. Deploy them independently.
Interview Talking Point
"SRP means one class, one reason to change. I apply it at both code level and system level. A class that handles business logic, DB calls, and email sending has three reasons to change. I split those into UserService, UserRepository, and EmailService. At the system level, microservices are SRP applied to services — each service owns exactly one domain."
Quick Revision
- One class = one job = one reason to change
- Split classes when you use the word "AND" to describe what it does
- Microservices are SRP at the architecture level
- Easier to test, maintain, and change independently
O — Open / Closed Principle (OCP)
Definition
"Software entities should be open for extension, but closed for modification." — Bertrand Meyer, popularised by Robert C. Martin
Open for extension → you can add new behaviour Closed for modification → without changing existing, tested code
The Core Idea
Add new features by writing NEW code.
Not by editing EXISTING code.
Every time you modify existing code, you risk breaking what already works.
OCP protects working code from change.
❌ Without OCP
class DiscountCalculator {
public double calculate(String customerType, double price) {
if (customerType.equals("REGULAR")) {
return price * 0.95; // 5% off
} else if (customerType.equals("PREMIUM")) {
return price * 0.85; // 15% off
} else if (customerType.equals("VIP")) {
return price * 0.70; // 30% off
}
// Adding a new type = modify this method = risk breaking existing logic
return price;
}
}
Problem: Every new customer type forces you to edit DiscountCalculator. This method grows indefinitely. One typo breaks all discount logic.
✅ With OCP
// Abstract the behaviour
interface DiscountStrategy {
double apply(double price);
}
// One class per type — never touch existing ones to add new
class RegularDiscount implements DiscountStrategy {
public double apply(double price) { return price * 0.95; }
}
class PremiumDiscount implements DiscountStrategy {
public double apply(double price) { return price * 0.85; }
}
class VIPDiscount implements DiscountStrategy {
public double apply(double price) { return price * 0.70; }
}
// New type needed? Just ADD a new class — nothing existing changes
class EmployeeDiscount implements DiscountStrategy {
public double apply(double price) { return price * 0.60; }
}
// Calculator is closed for modification
class DiscountCalculator {
public double calculate(DiscountStrategy strategy, double price) {
return strategy.apply(price); // delegates — never needs to change
}
}
Result: Add 10 more discount types → zero changes to DiscountCalculator. Each strategy is isolated and independently testable.
Real-World System Design Example
Payment Gateway (OCP in architecture)
❌ Without OCP:
PaymentService.processPayment() {
if (provider == "razorpay") { ... }
if (provider == "stripe") { ... }
if (provider == "paytm") { ... }
// adding PhonePe = modify this method
}
✅ With OCP:
interface PaymentGateway { charge(); refund(); }
class RazorpayGateway implements PaymentGateway { ... }
class StripeGateway implements PaymentGateway { ... }
class PaytmGateway implements PaymentGateway { ... }
// Adding PhonePe = new class only. PaymentService untouched.
Notification System (OCP in architecture)
interface NotificationChannel { send(userId, message); }
class EmailChannel implements NotificationChannel { ... }
class SMSChannel implements NotificationChannel { ... }
class PushChannel implements NotificationChannel { ... }
// Add WhatsApp channel = new class only
Interview Talking Point
"OCP means I write code that's open to new features but doesn't need to be modified to add them. When designing a payment service, instead of if/else chains for each provider, I define a
PaymentGatewayinterface and a class per provider. Adding Razorpay tomorrow = new class, zero changes toPaymentService. The existing code stays untouched and stable."
Quick Revision
- Open = add new behaviour by writing new code
- Closed = existing working code is never touched
- Achieved via interfaces + strategy/factory patterns
- Every if/else chain checking a "type" is an OCP violation waiting to happen
- System-level: plugin architectures and gateway patterns follow OCP
L — Liskov Substitution Principle (LSP)
Definition
"If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering the correctness of the program." — Barbara Liskov, 1987
In plain English: If class B extends class A, you should be able to use B anywhere A is used — and everything should still work correctly.
The Core Idea
Child classes must HONOUR the contract of the parent.
A child can do MORE — but must not do LESS or behave differently
in a way that breaks the caller's expectations.
❌ The Classic Violation — Rectangle & Square
class Rectangle {
protected int width;
protected int height;
public void setWidth(int w) { this.width = w; }
public void setHeight(int h) { this.height = h; }
public int area() { return width * height; }
}
// Square IS-A Rectangle mathematically — but this breaks LSP
class Square extends Rectangle {
@Override
public void setWidth(int w) {
this.width = w;
this.height = w; // Square must keep sides equal — forces this override
}
@Override
public void setHeight(int h) {
this.height = h;
this.width = h; // Same problem
}
}
// Caller uses Rectangle — LSP says Square should work here
void resizeRectangle(Rectangle r) {
r.setWidth(5);
r.setHeight(10);
// Expected area: 50
// With Square: area is 100 (because setHeight set width too)
// Behaviour changed — LSP VIOLATED
System.out.println(r.area()); // prints 100, not 50 — bug
}
Fix: Square and Rectangle should NOT be in an inheritance relationship. Both should implement a Shape interface separately.
✅ LSP Compliant Example
interface Shape {
int area();
}
class Rectangle implements Shape {
private int width, height;
public Rectangle(int w, int h) { width = w; height = h; }
public int area() { return width * height; }
}
class Square implements Shape {
private int side;
public Square(int side) { this.side = side; }
public int area() { return side * side; }
}
// Any Shape works here — substitution is safe
void printArea(Shape s) {
System.out.println("Area: " + s.area()); // works correctly for both
}
Another Common Violation — Throwing Unexpected Exceptions
class Bird {
public void fly() {
System.out.println("Flying...");
}
}
// Penguin IS-A Bird — but penguins can't fly
class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("Penguins can't fly!");
// Caller expected fly() to work — LSP VIOLATED
}
}
void makeBirdFly(Bird b) {
b.fly(); // crashes when b is a Penguin — caller had no idea
}
Fix: Split the hierarchy.
interface Bird { void eat(); }
interface FlyingBird extends Bird { void fly(); }
class Eagle implements FlyingBird {
public void eat() { ... }
public void fly() { ... }
}
class Penguin implements Bird {
public void eat() { ... }
// No fly() — correct
}
LSP in System Design
API Contract Violations
❌ LSP violation at service level:
Interface contract: UserRepository.findById() returns a User or null
MySQLUserRepository.findById() → returns User or null ✅
CachedUserRepository.findById() → throws exception when cache is cold ❌
// Caller expected null for missing user, got an exception instead
// Substituting CachedUserRepository for MySQLUserRepository breaks the caller
Rule: When a service or class claims to implement an interface, it must fully and correctly honor the contract — no surprises in behaviour.
Interview Talking Point
"LSP says a subclass must be safely substitutable for its parent without breaking callers. The classic example is Square extending Rectangle — setting the width changes the height too, which breaks any code that treats it as a Rectangle. At the system level, if I define a
UserRepositoryinterface, every implementation — MySQL, Redis cache, mock — must behave identically from the caller's perspective. Unexpected exceptions or changed return values are LSP violations at the service boundary."
Quick Revision
- Subtypes must be swappable for their parent without breaking anything
- Classic violation: Square extends Rectangle — unexpected side effects
- Bird / Penguin — don't inherit what you can't honour
- At system level: all implementations of an interface must honour the same contract
- Fix violations by splitting hierarchies at the right abstraction level
I — Interface Segregation Principle (ISP)
Definition
"Clients should not be forced to depend upon interfaces they do not use." — Robert C. Martin
Break large, fat interfaces into smaller, focused ones. No class should be forced to implement methods it doesn't need.
The Core Idea
A fat interface forces all implementors to deal with ALL methods —
even ones that don't apply to them.
Split the interface so each implementor only sees what it actually needs.
❌ Without ISP — Fat Interface
// One big interface for all workers
interface Worker {
void work();
void eat();
void sleep();
void attendMeeting();
void submitTimesheet();
}
// Human worker — all methods make sense ✅
class HumanWorker implements Worker {
public void work() { ... }
public void eat() { ... }
public void sleep() { ... }
public void attendMeeting() { ... }
public void submitTimesheet() { ... }
}
// Robot worker — robots don't eat, sleep, or submit timesheets ❌
class RobotWorker implements Worker {
public void work() { ... }
public void eat() { throw new UnsupportedOperationException(); } // forced
public void sleep() { throw new UnsupportedOperationException(); } // forced
public void attendMeeting() { throw new UnsupportedOperationException(); }
public void submitTimesheet() { throw new UnsupportedOperationException(); }
}
Problem: RobotWorker is forced to implement methods that don't apply to it. This is an LSP violation waiting to happen too.
✅ With ISP — Segregated Interfaces
interface Workable {
void work();
}
interface Eatable {
void eat();
}
interface Sleepable {
void sleep();
}
interface MeetingAttendee {
void attendMeeting();
}
interface TimesheetSubmitter {
void submitTimesheet();
}
// Human implements only what applies
class HumanWorker implements Workable, Eatable, Sleepable, MeetingAttendee, TimesheetSubmitter {
public void work() { ... }
public void eat() { ... }
public void sleep() { ... }
public void attendMeeting() { ... }
public void submitTimesheet() { ... }
}
// Robot implements only what applies — clean
class RobotWorker implements Workable {
public void work() { ... }
// Nothing else forced — correct
}
Practical Example — File Operations
// ❌ Fat interface — not all implementations support all operations
interface FileManager {
void read(String path);
void write(String path, String content);
void delete(String path);
void compress(String path);
void encrypt(String path);
}
// ReadOnlyFileManager has to implement write(), delete() — makes no sense
class ReadOnlyFileManager implements FileManager {
public void read(String path) { ... }
public void write(String path, String content) {
throw new UnsupportedOperationException(); // forced — wrong
}
// ...
}
// ✅ Segregated
interface FileReader { void read(String path); }
interface FileWriter { void write(String path, String content); }
interface FileDeleter { void delete(String path); }
interface FileCompressor { void compress(String path); }
class ReadOnlyFileManager implements FileReader {
public void read(String path) { ... } // only what it needs
}
class FullFileManager implements FileReader, FileWriter, FileDeleter {
// implements only relevant capabilities
}
Real-World System Design Example
Microservice API Contracts (ISP at scale)
❌ Fat service interface:
UserService API:
- getUser()
- updateUser()
- deleteUser()
- getUserOrders() ← should be in OrderService
- getUserPayments() ← should be in PaymentService
- getUserNotifications() ← should be in NotificationService
✅ Segregated service contracts:
UserService → getUser(), updateUser(), deleteUser()
OrderService → getOrdersByUser()
PaymentService → getPaymentsByUser()
Each service exposes only what it owns. Consumers depend only on what they need.
Repository Interfaces
// Not every service needs all operations
interface ReadableUserRepo { User findById(String id); }
interface WritableUserRepo { void save(User user); void delete(String id); }
// Analytics service — only needs read access
class AnalyticsService {
private ReadableUserRepo userRepo; // doesn't see write methods
}
// Admin service — needs full access
class AdminService {
private ReadableUserRepo readRepo;
private WritableUserRepo writeRepo;
}
Interview Talking Point
"ISP says don't force a class to implement methods it doesn't use. If I have a
Workerinterface withwork(),eat(), andsleep(), aRobotWorkeris forced to implementeat()andsleep()with fake or error responses — that's wrong. I split the interface:Workable,Eatable,Sleepable— each class picks what applies. At the system level, a microservice's API should only expose endpoints relevant to its own domain, not bleed into other services' responsibilities."
Quick Revision
- Fat interfaces force irrelevant methods on implementors — split them
- Clients depend only on the interface methods they actually use
- Robot/Human Worker — classic ISP example
- At system level: each microservice exposes only its own domain operations
- ISP violations are often caught when you see
throw new UnsupportedOperationException()
D — Dependency Inversion Principle (DIP)
Definition
"High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions." — Robert C. Martin
In plain English: Your business logic should depend on interfaces, not on specific database classes, email libraries, or frameworks.
The Core Idea
High-level = business logic (OrderService, PaymentService)
Low-level = infrastructure (MySQL, Redis, Razorpay, SMTP)
Without DIP: OrderService → MySQL (directly)
With DIP: OrderService → Repository interface ← MySQL implements it
The arrow of dependency points to the ABSTRACTION, not the concrete class.
❌ Without DIP — Tight Coupling to Infrastructure
class OrderService {
// Directly depends on concrete MySQL class — low-level detail
private MySQLOrderRepository repo = new MySQLOrderRepository();
private SMTPEmailService emailService = new SMTPEmailService();
private RazorpayPaymentGateway paymentGateway = new RazorpayPaymentGateway();
public void placeOrder(Order order) {
repo.save(order); // coupled to MySQL
emailService.sendConfirmation(order); // coupled to SMTP
paymentGateway.charge(order); // coupled to Razorpay
}
}
Problems:
- Want to switch MySQL → MongoDB? Rewrite
OrderService - Want to switch Razorpay → Stripe? Rewrite
OrderService - Want to test
OrderService? Need a real DB, real SMTP, real Razorpay running - Business logic is polluted with infrastructure concerns
✅ With DIP — Depends on Abstractions
// Abstractions (interfaces) — defined in the business layer
interface OrderRepository {
void save(Order order);
Order findById(String orderId);
}
interface EmailService {
void sendConfirmation(Order order);
}
interface PaymentGateway {
void charge(Order order);
}
// High-level module depends ONLY on interfaces
class OrderService {
private OrderRepository repo;
private EmailService emailService;
private PaymentGateway paymentGateway;
// Dependencies injected from outside (Dependency Injection)
public OrderService(OrderRepository repo,
EmailService emailService,
PaymentGateway paymentGateway) {
this.repo = repo;
this.emailService = emailService;
this.paymentGateway = paymentGateway;
}
public void placeOrder(Order order) {
repo.save(order);
emailService.sendConfirmation(order);
paymentGateway.charge(order);
// OrderService has NO idea about MySQL, SMTP, or Razorpay
}
}
// Low-level modules implement the interfaces
class MySQLOrderRepository implements OrderRepository { ... }
class MongoOrderRepository implements OrderRepository { ... }
class SMTPEmailService implements EmailService { ... }
class SendGridEmailService implements EmailService { ... }
class RazorpayPaymentGateway implements PaymentGateway { ... }
class StripePaymentGateway implements PaymentGateway { ... }
Wiring (done once at startup — in main or IoC container):
OrderService service = new OrderService(
new MySQLOrderRepository(),
new SMTPEmailService(),
new RazorpayPaymentGateway()
);
// To switch: swap the concrete class here only — OrderService untouched
DIP + Dependency Injection (DI) — How They Relate
DIP is the principle (depend on abstractions). Dependency Injection is the technique that implements DIP (inject the dependency from outside).
Without DI: class creates its own dependencies → new MySQLRepo() inside class
With DI: dependencies are passed in → constructor / setter injection
// Constructor Injection (preferred)
class UserService {
private UserRepository repo;
public UserService(UserRepository repo) { this.repo = repo; }
}
// Setter Injection
class UserService {
private UserRepository repo;
public void setRepo(UserRepository repo) { this.repo = repo; }
}
Frameworks like Spring, NestJS, ASP.NET Core handle DI automatically via IoC containers.
Real-World System Design Examples
1. Repository Pattern (DIP in data layer)
Business Layer: UserService → UserRepository (interface)
↑
Infrastructure Layer: MySQLUserRepository implements UserRepository
RedisUserRepository implements UserRepository (for caching)
MockUserRepository implements UserRepository (for tests)
2. Notification System
OrderService → NotificationService (interface)
↑
EmailNotificationService
SMSNotificationService
PushNotificationService
3. Testing Benefit (the real payoff of DIP)
// In tests — inject a mock, no real DB needed
UserRepository mockRepo = mock(UserRepository.class);
when(mockRepo.findById("u1")).thenReturn(new User("u1", "Alice"));
UserService service = new UserService(mockRepo);
service.getUserById("u1"); // tests business logic only — no DB, no network
Interview Talking Point
"DIP means business logic depends on abstractions, not concrete infrastructure.
OrderServiceshouldn't importMySQLOrderRepositorydirectly — it should depend on anOrderRepositoryinterface. The MySQL implementation is injected at startup. This means I can swap MySQL for MongoDB by changing one line in my config, mock the repository in tests without a real database, and keep my business logic completely independent of infrastructure choices. Spring's dependency injection is the most common implementation of this principle."
Quick Revision
- High-level (business logic) must NOT depend directly on low-level (DB, email, payments)
- Both depend on interfaces — direction of dependency is inverted toward abstraction
- DIP is the principle; Dependency Injection is the technique to implement it
- Real payoff: swap infrastructure freely + test business logic with mocks
- Repository pattern, gateway pattern, strategy pattern all implement DIP
How This Connects
- Abstraction / Polymorphism / Dependency Injection — the three tools you use to actually implement SOLID. O and D are basically "put an interface here and inject it".
- Cohesion & Coupling — S is high cohesion by another name; D and I are low coupling by another name.
- Composition vs Inheritance — "favour composition" is how you keep L (Liskov) from breaking: don't build inheritance chains you can't honour.
- Design Patterns — Strategy/Factory implement O; Repository/Gateway implement D; splitting fat interfaces implements I.
- Module 3 — HLD — microservices are S at the service level; "no shared database" and API contracts are D and I at architecture scale.
SOLID — Complete Summary Table
| Principle | Rule | Violation Sign | Fix |
|---|---|---|---|
| S | One class, one reason to change | Class does X AND Y AND Z | Split into separate classes |
| O | Extend without modifying | if/else or switch on "type" everywhere | Interfaces + strategy pattern |
| L | Subtypes replaceable for parent | Child throws UnsupportedOperationException | Redesign hierarchy or use composition |
| I | No forced unused methods | Implementing methods just to throw exceptions | Split fat interface into focused ones |
| D | Depend on abstractions, not concretions | new MySQLRepo() inside business class | Inject via interface + DI |
Combined Interview Answer (If Asked "Explain SOLID")
"SOLID is five principles for writing maintainable OO code.
S — each class has one job.
UserServicehandles user logic; a separateEmailServicehandles email — change in email provider doesn't touchUserService.O — add features by writing new code, not editing existing code. New payment provider = new class implementing
PaymentGatewayinterface, not a newifblock inPaymentService.L — subclasses must behave correctly when used as their parent.
SquareextendingRectangleviolates this — setting width changes height unexpectedly.I — don't force classes to implement unused methods. A
RobotWorkershouldn't be forced to implementeat()just because a fatWorkerinterface demands it.D — business logic depends on interfaces, not concrete classes.
OrderServicedepends onOrderRepositoryinterface — MySQL, MongoDB, or a mock can plug in freely.Together they produce code that's easy to test, change, and extend without cascading breakages."