SOLID Principles — Complete Guide with Java Examples
Master all five SOLID principles with clear explanations, real-world analogies, before/after Java code, class diagrams, Spring Boot integration, and interview Q&A. Covers SRP, OCP, LSP, ISP, and DIP in depth.
30-Minute Interview Roadmap
| Time | What to Cover |
|---|---|
| 0–3 min | What is SOLID? Why does it matter? Name all five principles. |
| 3–8 min | SRP — definition, bad example, good example, one-sentence rule |
| 8–13 min | OCP — strategy pattern, extension without modification |
| 13–18 min | LSP — Rectangle/Square problem, substitution rule |
| 18–23 min | ISP — fat interface vs focused interfaces, real-world example |
| 23–28 min | DIP — inversion, constructor injection, Spring Boot connection |
| 28–30 min | How all five work together, common mistakes, key insight |
Introduction
Writing code that works is the easy part.
Writing code that continues to work after years of changes, new features, and multiple developers — that is the real challenge.
Every software system grows over time. Requirements change. Teams grow. Bugs appear in places you never expected. The root cause of most long-term problems is poor design — classes doing too much, tight coupling between modules, fragile inheritance hierarchies, and rigid code that is expensive to extend.
SOLID is a set of five object-oriented design principles that guide you toward software that is:
- Easy to understand — each class has a clear purpose
- Easy to change — modifying one thing doesn't break another
- Easy to test — classes can be tested in isolation
- Easy to extend — new features don't require rewriting old code
These principles were formalised by Robert C. Martin (Uncle Bob) in the early 2000s and have become the industry standard for clean object-oriented design.
What Is SOLID?
SOLID is an acronym for five design principles:
| Letter | Principle | One-Line Rule |
|---|---|---|
| S | Single Responsibility Principle | A class should have only one reason to change |
| O | Open Closed Principle | Open for extension, closed for modification |
| L | Liskov Substitution Principle | Subtypes must be safely replaceable for their base types |
| I | Interface Segregation Principle | Clients should not be forced to depend on methods they don't use |
| D | Dependency Inversion Principle | Depend on abstractions, not on concrete classes |
These five principles are not independent rules — they reinforce each other. Applying one typically makes it easier to apply the others.
Why SOLID Matters
Consider a banking application that has grown without design discipline. After a few years of feature additions, a class might look like this:
// ❌ Violates every SOLID principle at once
public class CustomerService {
public void createCustomer(Customer customer) { /* save to DB */ }
public void sendWelcomeEmail(Customer customer) { /* send email */ }
public void generateAccountReport(Customer customer) { /* build PDF */ }
public void logAuditEvent(String event) { /* write to log */ }
public void processPayment(Customer customer, double amount) { /* call payment API */ }
public void sendSMS(Customer customer, String message) { /* call SMS provider */ }
}
Problems with this design:
- Six reasons to change — email, payment, PDF, SMS, audit, and DB logic all live together
- Impossible to test — you cannot test email logic without touching payment logic
- Tight coupling — changing the SMS provider forces changes here
- No extensibility — adding a new notification channel means editing this class
SOLID principles solve exactly these problems by giving each piece of code a single, clear purpose.
The Five SOLID Principles
mindmap
root((SOLID))
S
Single Responsibility
One reason to change
O
Open Closed
Extend without modifying
L
Liskov Substitution
Subtypes are substitutable
I
Interface Segregation
Small focused interfaces
D
Dependency Inversion
Depend on abstractions
S — Single Responsibility Principle (SRP)
Definition
A class should have only one reason to change.
"One reason to change" means one actor or concern drives changes to that class. If multiple unrelated stakeholders can cause you to open and modify the same class, it has more than one responsibility.
Memory tip: Ask yourself — "Who could ask me to change this class?" If the answer involves more than one type of person (database admin, email team, business rules owner), the class has too many responsibilities.
Real-World Analogy
Think of a restaurant:
- The chef cooks food (one responsibility)
- The waiter serves customers (one responsibility)
- The cashier handles payments (one responsibility)
You would never hire one person to cook, serve, and manage payments. The same thinking applies to classes.
The Problem — Before SRP
// ❌ Bad Design — four responsibilities in one class
public class OrderService {
public void placeOrder(Order order) {
// Responsibility 1: Business validation
if (order.getItems().isEmpty()) {
throw new IllegalArgumentException("Order cannot be empty");
}
// Responsibility 2: Persistence
String sql = "INSERT INTO orders VALUES (...)";
// ... JDBC code ...
// Responsibility 3: Email notification
String emailBody = "Dear " + order.getCustomerName() + ", your order is confirmed.";
// ... SMTP code ...
// Responsibility 4: Audit logging
System.out.println("[AUDIT] Order placed: " + order.getId());
}
}
This class has four reasons to change:
- Business validation rules change → edit
OrderService - Database schema or ORM changes → edit
OrderService - Email template or provider changes → edit
OrderService - Audit log format changes → edit
OrderService
Changing the email provider now forces you to retest the entire order placement flow — including payment and persistence logic that was never touched.
The Solution — After SRP
Split into focused classes, each with one responsibility:
// ✅ Good Design — each class has exactly one reason to change
// Responsibility: Business validation only
public class OrderValidator {
public void validate(Order order) {
if (order.getItems().isEmpty()) {
throw new IllegalArgumentException("Order cannot be empty");
}
if (order.getCustomerId() == null) {
throw new IllegalArgumentException("Customer ID is required");
}
}
}
// Responsibility: Database persistence only
public class OrderRepository {
public void save(Order order) {
// Only JPA/JDBC code here — nothing else
}
}
// Responsibility: Email communication only
public class EmailService {
public void sendOrderConfirmation(Order order) {
// Only email-sending logic here — nothing else
}
}
// Responsibility: Audit trail only
public class AuditService {
public void logOrderPlaced(Order order) {
// Only audit log writing here — nothing else
}
}
// Responsibility: Orchestrate the workflow only
public class OrderService {
private final OrderValidator validator;
private final OrderRepository repository;
private final EmailService emailService;
private final AuditService auditService;
public OrderService(OrderValidator validator, OrderRepository repository,
EmailService emailService, AuditService auditService) {
this.validator = validator;
this.repository = repository;
this.emailService = emailService;
this.auditService = auditService;
}
public void placeOrder(Order order) {
validator.validate(order); // delegates — does not implement
repository.save(order); // delegates — does not implement
emailService.sendOrderConfirmation(order);
auditService.logOrderPlaced(order);
}
}
Now each class changes for exactly one reason:
- Email provider changes → only
EmailServicechanges - Validation rules change → only
OrderValidatorchanges - Database schema changes → only
OrderRepositorychanges
SRP Class Diagram
classDiagram
class OrderService {
+placeOrder(order)
}
class OrderValidator {
+validate(order)
}
class OrderRepository {
+save(order)
}
class EmailService {
+sendOrderConfirmation(order)
}
class AuditService {
+logOrderPlaced(order)
}
OrderService --> OrderValidator
OrderService --> OrderRepository
OrderService --> EmailService
OrderService --> AuditService
SRP in Spring Boot
Spring Boot's layered architecture enforces SRP by convention:
// Controller — responsible only for HTTP request/response
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
public ResponseEntity<OrderResponse> placeOrder(@RequestBody OrderRequest request) {
OrderResponse response = orderService.placeOrder(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
}
// Service — responsible only for business logic
@Service
public class OrderService {
// Orchestrates: validate → save → notify → audit
}
// Repository — responsible only for database access
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
// Data access only — no business logic
}
Each annotation (@RestController, @Service, @Repository) signals a different responsibility layer.
How to Detect SRP Violations
| Warning Sign | What It Tells You |
|---|---|
Class name contains And or Manager |
Likely doing more than one thing |
| Class has 500+ lines | Almost certainly multiple responsibilities |
| Imports include DB, HTTP, email, and logging all at once | Too many concerns in one place |
| Changing one feature requires modifying this class for unrelated reasons | Multiple actors own this class |
| Unit test requires 5+ mocks to set up | Class has too many dependencies |
O — Open Closed Principle (OCP)
Definition
Software entities should be open for extension, but closed for modification.
When requirements change, you should be able to add new behaviour without editing existing, tested code. Touching working code always risks introducing regressions.
OCP is achieved by programming to abstractions — interfaces or abstract classes that define the contract, with concrete implementations that can be swapped or extended.
Memory tip: "Add a new class, don't edit an old one."
Real-World Analogy
A power strip is open for extension (you can plug in any device) but closed for modification (you don't rewire the strip itself for each new device). The socket is the abstraction; each device is an extension.
The Problem — Before OCP
// ❌ Bad Design — adding a new payment method means editing this class
public class PaymentProcessor {
public void processPayment(String paymentType, double amount) {
if (paymentType.equals("CREDIT_CARD")) {
System.out.println("Processing credit card: " + amount);
// Credit card logic
} else if (paymentType.equals("PAYPAL")) {
System.out.println("Processing PayPal: " + amount);
// PayPal logic
} else if (paymentType.equals("UPI")) {
System.out.println("Processing UPI: " + amount);
// UPI logic
}
// Adding Google Pay requires editing this class — every time, forever
}
}
Every new payment method:
- Requires modifying a production class that already handles other methods
- Risks breaking credit card processing when adding UPI logic
- Violates the "closed for modification" part of OCP
The Solution — After OCP
// ✅ Good Design — new payment types extend the system without touching existing code
// The abstraction — defines the contract
public interface PaymentProcessor {
void processPayment(double amount);
boolean supports(String paymentType);
}
// Existing implementations — never need to change
public class CreditCardProcessor implements PaymentProcessor {
@Override
public void processPayment(double amount) {
System.out.println("Processing credit card: " + amount);
// Tokenisation, gateway call, etc.
}
@Override
public boolean supports(String paymentType) {
return "CREDIT_CARD".equals(paymentType);
}
}
public class PayPalProcessor implements PaymentProcessor {
@Override
public void processPayment(double amount) {
System.out.println("Processing PayPal: " + amount);
}
@Override
public boolean supports(String paymentType) {
return "PAYPAL".equals(paymentType);
}
}
// ✅ Adding Google Pay = new class only — zero changes to existing code
public class GooglePayProcessor implements PaymentProcessor {
@Override
public void processPayment(double amount) {
System.out.println("Processing Google Pay: " + amount);
}
@Override
public boolean supports(String paymentType) {
return "GOOGLE_PAY".equals(paymentType);
}
}
// Orchestrator — also never changes when new types are added
public class PaymentService {
private final List<PaymentProcessor> processors;
public PaymentService(List<PaymentProcessor> processors) {
this.processors = processors;
}
public void pay(String paymentType, double amount) {
processors.stream()
.filter(p -> p.supports(paymentType))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Unknown payment type: " + paymentType))
.processPayment(amount);
}
}
In Spring Boot, all PaymentProcessor beans are automatically collected into the list by dependency injection. Adding a new payment method means only creating a new @Component class — no other file changes.
OCP Class Diagram
classDiagram
class PaymentProcessor {
<<interface>>
+processPayment(amount)
+supports(paymentType) bool
}
class CreditCardProcessor {
+processPayment(amount)
+supports(paymentType) bool
}
class PayPalProcessor {
+processPayment(amount)
+supports(paymentType) bool
}
class GooglePayProcessor {
+processPayment(amount)
+supports(paymentType) bool
}
class PaymentService {
-processors List
+pay(paymentType, amount)
}
PaymentProcessor <|.. CreditCardProcessor
PaymentProcessor <|.. PayPalProcessor
PaymentProcessor <|.. GooglePayProcessor
PaymentService --> PaymentProcessor
How to Detect OCP Violations
| Warning Sign | What It Tells You |
|---|---|
if/else or switch on a type string |
Each new type forces a code edit |
Method grows by adding more else if branches |
Not closed for modification |
| Feature request always touches the same central class | That class is not closed |
| Tests break in unrelated areas when you add a feature | Missing abstraction layer |
L — Liskov Substitution Principle (LSP)
Definition
Objects of a subclass should be replaceable with objects of their parent class without breaking the program.
If class B extends class A, every place you use an A reference should work correctly with a B object — without the caller having to know the difference. The subclass must honour the contract of the parent class, including:
- Same method signatures and return types
- Same pre-conditions (inputs it accepts)
- Same post-conditions (output guarantees)
- Same invariants (what remains true throughout)
Memory tip: "If it walks like a duck and quacks like a duck but needs batteries to do it — you have an LSP violation."
Real-World Analogy
A debit card can substitute for a payment card anywhere a payment is accepted — you swipe it, enter a PIN, and the transaction completes. A gift card that can only be used at one store is not a proper substitute — substituting it breaks the expected behaviour for the caller.
The Problem — LSP Violation
The classic example is the Rectangle / Square problem:
// ❌ Classic LSP violation
public class Rectangle {
protected int width;
protected int height;
public void setWidth(int width) { this.width = width; }
public void setHeight(int height) { this.height = height; }
public int getArea() { return width * height; }
}
// A Square is a special Rectangle — seems natural to extend it
public class Square extends Rectangle {
// A square must keep width == height, so both are changed together
@Override
public void setWidth(int width) {
this.width = width;
this.height = width; // ← silently breaks Rectangle's contract
}
@Override
public void setHeight(int height) {
this.width = height;
this.height = height; // ← silently breaks Rectangle's contract
}
}
// This method works with Rectangle but BREAKS silently with Square
public void testRectangle(Rectangle r) {
r.setWidth(5);
r.setHeight(10);
// Expected: 50. With Square: 100. ❌ LSP violated.
assert r.getArea() == 50;
}
Square overrides inherited behaviour in a way that violates what callers of Rectangle expect. Passing a Square where a Rectangle is expected produces wrong results — silently and without any exception.
The Solution — Correct Hierarchy
// ✅ Fix: Use a shared abstraction instead of inheritance
public interface Shape {
int getArea();
}
public class Rectangle implements Shape {
private final int width;
private final int height;
public Rectangle(int width, int height) {
this.width = width;
this.height = height;
}
@Override
public int getArea() {
return width * height;
}
}
public class Square implements Shape {
private final int side;
public Square(int side) {
this.side = side;
}
@Override
public int getArea() {
return side * side;
}
}
// Works correctly with both — true substitution
public void printArea(Shape shape) {
System.out.println("Area: " + shape.getArea()); // correct for any Shape
}
Both Rectangle and Square implement Shape. Neither one changes the other's behaviour. Any caller that expects a Shape gets consistent, predictable results.
Banking Example — Payment Hierarchy
// ✅ Every payment type is a true substitute for the base interface
public interface Payment {
void validate(); // verify inputs are correct
void process(); // execute the transfer
void complete(); // confirm and notify
}
public class CardPayment implements Payment {
@Override public void validate() { /* validate card number, CVV, expiry */ }
@Override public void process() { /* charge the card via gateway */ }
@Override public void complete() { /* send card receipt */ }
}
public class UPIPayment implements Payment {
@Override public void validate() { /* validate UPI ID format */ }
@Override public void process() { /* initiate UPI transfer */ }
@Override public void complete() { /* send UPI confirmation */ }
}
public class WalletPayment implements Payment {
@Override public void validate() { /* check wallet balance */ }
@Override public void process() { /* debit the wallet */ }
@Override public void complete() { /* update wallet ledger */ }
}
// Works with ANY Payment implementation — fully substitutable
public class PaymentEngine {
public void execute(Payment payment) {
payment.validate();
payment.process();
payment.complete();
}
}
Every implementation:
- Honours the full three-step contract — validate → process → complete
- Does not throw
UnsupportedOperationExceptionfor any method - Does not silently skip any step
LSP Class Diagram
classDiagram
class Payment {
<<interface>>
+validate()
+process()
+complete()
}
class CardPayment {
+validate()
+process()
+complete()
}
class UPIPayment {
+validate()
+process()
+complete()
}
class WalletPayment {
+validate()
+process()
+complete()
}
class PaymentEngine {
+execute(payment)
}
Payment <|.. CardPayment
Payment <|.. UPIPayment
Payment <|.. WalletPayment
PaymentEngine --> Payment
How to Detect LSP Violations
| Warning Sign | What It Tells You |
|---|---|
Subclass throws UnsupportedOperationException |
It cannot honour the parent's contract |
Subclass overrides a method to do nothing ({}) |
Silently breaks the expected behaviour |
Caller code has instanceof checks before calling a method |
The hierarchy is not truly substitutable |
| Unit test for base class fails when a subclass is substituted | Direct LSP violation |
| Subclass strengthens pre-conditions (accepts fewer inputs) | Violates the contract |
I — Interface Segregation Principle (ISP)
Definition
Clients should not be forced to depend on methods they do not use.
A large, bloated interface forces implementing classes to provide methods that make no sense for them, leading to empty stub implementations or exceptions. These are clear signs that the interface is trying to do too much.
Keep interfaces small, focused, and role-specific.
Memory tip: "Many small interfaces are better than one fat interface."
Real-World Analogy
A TV remote has power, volume, and channel buttons. A universal remote adds DVD, Blu-ray, and AC controls. If every device had to respond to every button on the universal remote, your air conditioner would need a "volume up" implementation — which is absurd. ISP says: give each device only the interface it needs.
The Problem — Before ISP
// ❌ Fat interface — forces every implementor to implement everything
public interface WorkerOperations {
void work();
void eat();
void sleep();
void attendMeeting();
void submitTimesheet();
}
// A robot can work and attend meetings — but it does not eat or sleep
public class Robot implements WorkerOperations {
@Override
public void work() { System.out.println("Robot working"); }
@Override
public void eat() {
throw new UnsupportedOperationException("Robots don't eat"); // ❌ ISP violation
}
@Override
public void sleep() {
throw new UnsupportedOperationException("Robots don't sleep"); // ❌ ISP violation
}
@Override
public void attendMeeting() { System.out.println("Robot attending meeting"); }
@Override
public void submitTimesheet() { System.out.println("Robot submitting timesheet"); }
}
Robot is forced to implement eat() and sleep() even though those methods are conceptually meaningless for it. This is an ISP violation.
The Solution — After ISP
// ✅ Split into small, role-specific interfaces
public interface Workable {
void work();
}
public interface Feedable {
void eat();
void sleep();
}
public interface Meetable {
void attendMeeting();
void submitTimesheet();
}
// Human naturally implements all three roles
public class HumanWorker implements Workable, Feedable, Meetable {
@Override public void work() { System.out.println("Human working"); }
@Override public void eat() { System.out.println("Human eating"); }
@Override public void sleep() { System.out.println("Human sleeping"); }
@Override public void attendMeeting() { System.out.println("Human in meeting"); }
@Override public void submitTimesheet() { System.out.println("Human submitting timesheet"); }
}
// Robot implements only what makes sense for it — no dummy methods
public class Robot implements Workable, Meetable {
@Override public void work() { System.out.println("Robot working"); }
@Override public void attendMeeting() { System.out.println("Robot in meeting"); }
@Override public void submitTimesheet() { System.out.println("Robot submitting timesheet"); }
}
Now each class implements only the interfaces it truly supports. No stubs, no exceptions, no misleading no-ops.
Payment System — ISP Applied
// ✅ Segregated payment-domain interfaces
public interface Payable {
void pay(double amount);
}
public interface Refundable {
void refund(String transactionId, double amount);
}
public interface Invoiceable {
void generateInvoice(String transactionId);
}
// Cash only supports payment — no refund, no invoice
public class CashPayment implements Payable {
@Override
public void pay(double amount) {
System.out.println("Cash payment accepted: " + amount);
}
}
// Credit card supports payment, refund, and invoice
public class CreditCardService implements Payable, Refundable, Invoiceable {
@Override
public void pay(double amount) { /* charge card */ }
@Override
public void refund(String transactionId, double amount) { /* reverse charge */ }
@Override
public void generateInvoice(String transactionId) { /* produce PDF receipt */ }
}
// Wallet supports payment and refund but not invoice
public class WalletService implements Payable, Refundable {
@Override
public void pay(double amount) { /* debit wallet */ }
@Override
public void refund(String transactionId, double amount) { /* credit wallet back */ }
}
Each class only implements what it genuinely supports. CashPayment is never forced to stub out refund() or generateInvoice().
ISP Class Diagram
classDiagram
class Payable {
<<interface>>
+pay(amount)
}
class Refundable {
<<interface>>
+refund(txnId, amount)
}
class Invoiceable {
<<interface>>
+generateInvoice(txnId)
}
class CashPayment {
+pay(amount)
}
class CreditCardService {
+pay(amount)
+refund(txnId, amount)
+generateInvoice(txnId)
}
class WalletService {
+pay(amount)
+refund(txnId, amount)
}
Payable <|.. CashPayment
Payable <|.. CreditCardService
Payable <|.. WalletService
Refundable <|.. CreditCardService
Refundable <|.. WalletService
Invoiceable <|.. CreditCardService
How to Detect ISP Violations
| Warning Sign | What It Tells You |
|---|---|
| Interface has 10+ methods | Almost certainly mixing multiple roles |
| Classes implement the interface but leave methods empty | They don't actually support those operations |
UnsupportedOperationException thrown in an interface method |
The implementor is forced to have a method it cannot honour |
| Changing one part of an interface requires touching many unrelated classes | The interface is too broad |
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.
When a high-level class directly creates or references a concrete low-level class, the two are tightly coupled. Any change to the low-level class forces a change in the high-level class.
DIP breaks this coupling by introducing an interface between the two. The high-level class depends on the interface. The low-level class implements the interface. Neither knows about the other directly.
Memory tip: "Point your arrows at interfaces, not at concrete classes."
Real-World Analogy
A lamp does not depend on a specific power plant. It depends on the electrical socket (the abstraction). The power plant (concrete) plugs into the socket. You can switch from coal to solar without rewiring the lamp — the socket contract never changes.
The Problem — Before DIP
// ❌ High-level class is tightly coupled to specific low-level implementations
public class OrderService {
// Direct creation of concrete classes — tight coupling
private final MySQLOrderRepository repository = new MySQLOrderRepository();
private final SmtpEmailService emailService = new SmtpEmailService();
public void placeOrder(Order order) {
repository.save(order);
emailService.send(order.getCustomerEmail(), "Your order is confirmed!");
}
}
Problems:
- Switching from MySQL to PostgreSQL requires editing
OrderService - Switching from SMTP to SendGrid requires editing
OrderService - Unit testing is impossible without a real database and real email server
OrderServiceknows the exact namesMySQLOrderRepositoryandSmtpEmailService— tightly coupled
The Solution — After DIP
// Step 1: Define abstractions (interfaces)
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(Long id);
}
public interface NotificationService {
void sendConfirmation(String recipient, String message);
}
// Step 2: High-level class depends ONLY on abstractions
public class OrderService {
private final OrderRepository repository;
private final NotificationService notificationService;
// Dependencies are injected from outside — OrderService never names concrete types
public OrderService(OrderRepository repository, NotificationService notificationService) {
this.repository = repository;
this.notificationService = notificationService;
}
public void placeOrder(Order order) {
repository.save(order);
notificationService.sendConfirmation(
order.getCustomerEmail(), "Your order is confirmed!"
);
}
}
// Step 3: Low-level classes implement the abstractions
@Repository
public class MySQLOrderRepository implements OrderRepository {
@Override
public void save(Order order) { /* MySQL-specific JPA code */ }
@Override
public Optional<Order> findById(Long id) { /* MySQL query */ return Optional.empty(); }
}
@Repository
public class PostgreSQLOrderRepository implements OrderRepository {
@Override
public void save(Order order) { /* PostgreSQL-specific code */ }
@Override
public Optional<Order> findById(Long id) { return Optional.empty(); }
}
@Service
public class EmailNotificationService implements NotificationService {
@Override
public void sendConfirmation(String recipient, String message) {
// SMTP or SendGrid — implementation detail hidden from OrderService
}
}
@Service
public class SMSNotificationService implements NotificationService {
@Override
public void sendConfirmation(String recipient, String message) {
// Twilio or AWS SNS — implementation detail hidden from OrderService
}
}
Now you can:
- Switch from MySQL to PostgreSQL → provide a different
OrderRepositorybean —OrderServiceis untouched - Add SMS alongside email → inject a different
NotificationService—OrderServiceis untouched - Write fast unit tests by injecting mock implementations of both interfaces
DIP Class Diagram
classDiagram
class OrderService {
+placeOrder(order)
}
class OrderRepository {
<<interface>>
+save(order)
+findById(id) Optional
}
class NotificationService {
<<interface>>
+sendConfirmation(recipient, message)
}
class MySQLOrderRepository {
+save(order)
+findById(id) Optional
}
class PostgreSQLOrderRepository {
+save(order)
+findById(id) Optional
}
class EmailNotificationService {
+sendConfirmation(recipient, message)
}
class SMSNotificationService {
+sendConfirmation(recipient, message)
}
OrderService --> OrderRepository
OrderService --> NotificationService
OrderRepository <|.. MySQLOrderRepository
OrderRepository <|.. PostgreSQLOrderRepository
NotificationService <|.. EmailNotificationService
NotificationService <|.. SMSNotificationService
DIP with Spring Boot
Spring Boot's dependency injection is a direct, practical implementation of DIP:
@Service
public class OrderService {
private final OrderRepository repository;
private final NotificationService notificationService;
// Spring resolves which concrete beans to inject at startup
// OrderService never names MySQLOrderRepository or EmailNotificationService
@Autowired
public OrderService(OrderRepository repository, NotificationService notificationService) {
this.repository = repository;
this.notificationService = notificationService;
}
}
Spring decides at runtime which implementation to inject based on:
- Which bean is registered in the context
@Primaryannotation to prefer one implementation@Qualifier("emailNotification")to select a specific named bean@Profile("production")to swap implementations per environment
OrderService never knows or cares which concrete implementation is used.
How to Detect DIP Violations
| Warning Sign | What It Tells You |
|---|---|
new ConcreteClass() inside a service class |
Tightly coupled — no abstraction |
| Import of a low-level class in a high-level class | Direct dependency on implementation detail |
Cannot test OrderService without a real database |
Dependency on concrete infrastructure |
| Switching a database requires changes in business logic classes | No abstraction boundary |
@Autowired on a field (field injection) |
Hides dependencies, harder to test |
All Five Principles Working Together
A well-designed service applies all five principles simultaneously. Here is a TransferService that demonstrates all of them:
// SRP: TransferService handles only the transfer workflow — not fraud, fees, or notifications
// OCP: New fee strategies extend FeeCalculator without changing TransferService
// LSP: Any FeeCalculator implementation is safely substitutable
// ISP: FraudDetectionService, FeeCalculator, NotificationService are small focused interfaces
// DIP: TransferService depends on abstractions — no concrete class names inside
@Service
public class TransferService {
private final AccountRepository accountRepository; // DIP: interface
private final FeeCalculator feeCalculator; // DIP + OCP: interface
private final FraudDetectionService fraudService; // DIP + ISP: focused interface
private final NotificationService notificationService; // DIP + ISP: focused interface
private final AuditService auditService; // SRP: separate concern
@Autowired
public TransferService(AccountRepository accountRepository,
FeeCalculator feeCalculator,
FraudDetectionService fraudService,
NotificationService notificationService,
AuditService auditService) {
this.accountRepository = accountRepository;
this.feeCalculator = feeCalculator;
this.fraudService = fraudService;
this.notificationService = notificationService;
this.auditService = auditService;
}
public TransferResult transfer(TransferRequest request) {
// DIP: accountRepository is an interface — MySQL, PostgreSQL, or in-memory for tests
Account source = accountRepository.findById(request.getSourceAccountId())
.orElseThrow(() -> new AccountNotFoundException("Source account not found"));
Account destination = accountRepository.findById(request.getDestinationAccountId())
.orElseThrow(() -> new AccountNotFoundException("Destination account not found"));
// SRP: fraud detection is delegated — not implemented here
// LSP: any FraudDetectionService implementation honours the full check contract
fraudService.checkTransfer(request);
// OCP: adding a new fee strategy = new FeeCalculator class, zero changes here
double fee = feeCalculator.calculate(request);
source.debit(request.getAmount() + fee);
destination.credit(request.getAmount());
accountRepository.save(source);
accountRepository.save(destination);
// ISP: NotificationService is focused — only sendConfirmation, nothing else
notificationService.sendConfirmation(source.getEmail(), "Transfer of " + request.getAmount() + " completed");
// SRP: audit is a separate responsibility — not mixed into the transfer logic
auditService.logTransfer(request);
return TransferResult.success();
}
}
SOLID Principles Comparison
| Principle | Problem It Solves | Key Technique |
|---|---|---|
| SRP | Class changes for too many reasons | Split into focused classes |
| OCP | Adding features requires editing existing code | Program to abstractions + add new classes |
| LSP | Subclass breaks the parent's contract | Verify substitutability, prefer interfaces over inheritance |
| ISP | Implementors forced to stub unused methods | Split fat interfaces into role-specific ones |
| DIP | High-level code coupled to low-level details | Inject abstractions via constructor |
Without SOLID vs With SOLID
| Without SOLID | With SOLID |
|---|---|
| Large classes doing everything | Small, focused classes |
| Hard to test — too many dependencies | Easy to test with mocks |
| One change breaks many things | Changes are isolated |
| Copy-pasted logic everywhere | Reusable, composable components |
| Adding a feature = risky surgery | Adding a feature = new class |
instanceof checks scattered everywhere |
Clean polymorphism |
| Tightly coupled to databases, APIs, providers | Depend on interfaces — swap freely |
| Onboarding new developers is slow | Clear structure, easy to navigate |
Spring Boot and SOLID
Spring Boot's architecture naturally aligns with SOLID at every layer:
| SOLID Principle | Spring Boot Feature |
|---|---|
| SRP | @RestController (HTTP) / @Service (business) / @Repository (data) layers |
| OCP | Strategy beans, @Conditional, @Profile for environment-specific implementations |
| LSP | Interface-based beans — Spring injects any compatible implementation |
| ISP | Small, focused @Repository and @Service interfaces |
| DIP | Constructor injection via @Autowired — high-level classes depend on interfaces |
// A clean Spring Boot design applying all five principles
@RestController // SRP: HTTP concerns only
class OrderController { }
@Service // SRP: business logic only
class OrderService { } // DIP: depends only on interfaces
@Repository // SRP: data access only
interface OrderRepository // ISP: focused interface
extends JpaRepository<Order, Long> { }
interface NotificationService { } // DIP abstraction
@Service class EmailNotificationService // OCP extension point
implements NotificationService { }
@Service class SMSNotificationService // OCP extension point
implements NotificationService { }
Quick Reference — SOLID Cheat Sheet
flowchart TD
SRP["S — SRP\nOne class\nOne responsibility\nOne reason to change"]
OCP["O — OCP\nAdd new classes\nDon't edit old ones\nUse interfaces"]
LSP["L — LSP\nSubclasses must\nhonour parent contract\nNo UnsupportedOperationException"]
ISP["I — ISP\nSmall focused interfaces\nNo forced empty methods\nRole-based contracts"]
DIP["D — DIP\nDepend on interfaces\nInject dependencies\nNever new ConcreteClass()"]
SRP --> OCP --> LSP --> ISP --> DIP
style SRP fill:#e8f0fe,stroke:#3b82d4
style OCP fill:#fef9e7,stroke:#f59e0b
style LSP fill:#f0fdf4,stroke:#16a34a
style ISP fill:#fdf4ff,stroke:#7c5cd8
style DIP fill:#fff1f2,stroke:#e11d48
Common Mistakes to Avoid
| Mistake | Principle Violated | Fix |
|---|---|---|
| One class handles validation + DB + email | SRP | Split into focused service classes |
if/else or switch chain on type strings |
OCP | Use interface + Strategy pattern |
Subclass throws UnsupportedOperationException |
LSP | Redesign the hierarchy or use composition |
| One interface with 15 methods | ISP | Split into role-specific interfaces |
new ConcreteClass() inside a service |
DIP | Inject via constructor with an interface |
@Autowired on a field (field injection) |
DIP best practice | Use constructor injection — makes dependencies explicit |
| Applying SOLID mechanically to every small class | Over-engineering | Apply where complexity justifies it |
Best Practices
- Keep classes small. If a class has more than 200–300 lines, look for responsibilities to extract.
- Name classes to reveal their single purpose.
OrderValidator,OrderRepository,OrderNotifier— the name should tell you exactly what the class owns. - Program to interfaces. Declare all dependencies as interface types, not concrete types.
- Use constructor injection. It makes all dependencies visible and enables easy mocking in tests.
- Prefer composition over inheritance. Inheritance creates tight coupling between classes; favour interfaces and delegation instead.
- Test each class in isolation. If you cannot write a fast unit test without setting up half the system, you have a design problem.
- Extend by adding, not by modifying. When adding a new feature, aim to write a new class — not edit an existing one.
- Don't over-apply SOLID. For a 20-line utility class, splitting it for SRP is over-engineering. Apply the principles where the complexity and change frequency justify it.
Interview Questions and Model Answers
Q1. What does SOLID stand for? Explain each principle in one sentence.
| Principle | One-Sentence Answer |
|---|---|
| S | A class should have only one reason to change |
| O | Software should be open for extension but closed for modification |
| L | Objects of a subclass should safely replace objects of their parent class |
| I | Clients should not be forced to depend on methods they do not use |
| D | High-level modules should depend on abstractions, not on concrete low-level classes |
Q2. How does SRP differ from "make classes small"?
SRP is about reasons to change, not size. A 500-line class that only handles one concern is fine from an SRP perspective. A 50-line class that mixes database access and email is an SRP violation. The question to ask is: how many different stakeholders could ask you to change this class?
Q3. Give a real-world example of the Open Closed Principle.
A payment system using an interface PaymentProcessor with implementations CreditCardProcessor, PayPalProcessor, and UPIProcessor. When you need to add Google Pay, you write a new GooglePayProcessor class. The PaymentService orchestrator and all existing processors are untouched — the system is open for extension (new class) and closed for modification (no edits to existing code).
Q4. What is an LSP violation and how do you detect one?
An LSP violation occurs when a subclass changes the observable behaviour that callers expect from the parent class. The classic example is Square extends Rectangle — setting the width silently changes the height, which breaks code written against Rectangle. Detection signs: instanceof checks in caller code, UnsupportedOperationException in subclasses, and unit tests passing with the parent but failing with the subclass.
Q5. What is the difference between SRP and ISP?
SRP applies to classes — each class should have one responsibility. ISP applies to interfaces — each interface should represent a single capability role. They are complementary: SRP prevents classes from growing too large, ISP prevents interfaces from forcing unnecessary method implementations on classes.
Q6. How does Spring Boot's dependency injection support DIP?
Spring Boot resolves dependencies at runtime. You declare @Service, @Repository, and other beans that implement interfaces. When a class like OrderService declares constructor parameters of type OrderRepository (an interface), Spring injects the registered implementation. OrderService never references MySQLOrderRepository directly — it depends only on the OrderRepository interface, exactly as DIP prescribes.
Q7. Why is constructor injection preferred over field injection?
Constructor injection (@Autowired on constructor) makes all dependencies explicit and visible in the constructor signature. It enables:
- Easy unit testing — just call
new OrderService(mockRepo, mockNotifier)with no Spring context needed - Immutable fields — injected once, never reassigned
- Clear indication of required dependencies — the class cannot be instantiated without them
Field injection (@Autowired on a private field) hides dependencies, requires reflection to inject in tests, and allows partial initialisation.
Q8. How do SOLID principles improve testability?
- SRP: classes have fewer dependencies → simpler mocks
- OCP: new implementations can be swapped in for testing
- LSP: test implementations are valid substitutes for production ones
- ISP: small interfaces are easier to mock with only relevant methods
- DIP: injected interfaces can be replaced with mocks or stubs via constructor
The combined result: every class can be tested in complete isolation with fast, lightweight unit tests.
Q9. Can you over-apply SOLID principles?
Yes. Splitting every 20-line method into separate strategy classes and interfaces is over-engineering. SOLID principles are most valuable when:
- The code is expected to change often
- Multiple developers work on the same area
- The codebase is large enough that coupling costs become visible
For small scripts, utilities, or prototypes, strict SOLID application adds more complexity than it removes.
Q10. Which SOLID principle is most commonly violated in enterprise applications?
SRP and DIP are the most commonly violated. Service classes in enterprise applications tend to accumulate business logic, database calls, external API calls, email, logging, and validation — all in one place. The root cause is usually deadline pressure causing developers to add new logic to the nearest existing class rather than creating a properly focused new class.
Summary
| Principle | Key Question to Ask | Red Flag |
|---|---|---|
| SRP | Does this class have more than one reason to change? | Class name with And, Manager, or 500+ lines |
| OCP | Can I add this feature without editing existing code? | if/else on type strings that keeps growing |
| LSP | Can every subtype safely replace its parent? | UnsupportedOperationException or instanceof checks |
| ISP | Is any implementor forced to stub unused methods? | Interface with 10+ methods |
| DIP | Is my high-level code referencing a concrete class? | new ConcreteClass() inside a service |
SOLID is not a mechanical checklist — it is a way of thinking about responsibility, extensibility, and coupling. Each principle addresses a specific category of long-term pain:
- SRP prevents classes that are hard to reason about and retest
- OCP prevents fragile code that breaks when you add features
- LSP prevents surprising bugs caused by subtypes that violate parent contracts
- ISP prevents unnecessary coupling between unrelated capabilities
- DIP prevents tight coupling to infrastructure that makes testing and swapping hard
Applied thoughtfully, SOLID produces software that is easy to read, easy to test, easy to extend, and built to last through years of change.