SOLID Principles
The goal is to understand why SOLID exists, what problem each principle solves, how bad code evolves into good code, and how these principles apply in real Spring Boot systems.
SOLID Principles in Java — Deep Dive
SOLID stands for:
S → Single Responsibility Principle
O → Open/Closed Principle
L → Liskov Substitution Principle
I → Interface Segregation Principle
D → Dependency Inversion PrincipleA useful high-level picture:
SOLID
|
+-----------+-----------+
| | |
SRP OCP LSP
| | |
Responsibility Extension Substitution
+-----------+-----------+
| |
ISP DIP
| |
Interfaces DependenciesBut don’t memorize those words yet.
Instead, we’ll use one realistic application throughout.
Our Example: Payment System
Imagine you’re building:
Payment ServiceIt supports:
Credit Card
Debit Card
UPI
Bank Transfer
WalletInitially, someone might write:
class PaymentService {public void processPayment(String type, double amount) { if (type.equals("CARD")) {
// card payment
} else if (type.equals("UPI")) {
// UPI payment
} else if (type.equals("BANK")) {
// bank payment
}
}
}
This works.
But as the application grows:
CARD
UPI
BANK
WALLET
PAYPAL
APPLE_PAY
GOOGLE_PAY
CRYPTO
...The class becomes huge.
SOLID helps us prevent this kind of design from becoming difficult to maintain.
1. S — Single Responsibility Principle
Definition
A class should have one reason to change.
This is better than saying:
A class should do only one thing.
Because “one thing” can be vague.
The important question is:
“How many different reasons can cause this class to change?”
Bad example
Suppose:
class PaymentService {public void processPayment() {
// process payment
} public void validateCard() {
// validate card
} public void savePayment() {
// save to DB
} public void sendEmail() {
// send email
} public void generateReceipt() {
// generate receipt
}
}
Looks convenient.
But this class has many responsibilities:
PaymentService
|
+-- Payment processing
|
+-- Validation
|
+-- Database
|
+-- Email
|
+-- Receipt generationNow imagine:
Requirement 1
Database changes from PostgreSQL to MongoDB.
You modify:
PaymentServiceRequirement 2
Email provider changes from SMTP to SendGrid.
Again:
PaymentServiceRequirement 3
Receipt format changes.
Again:
PaymentServiceSo the class has multiple reasons to change.
That’s an SRP violation.
Better design
Separate responsibilities:
class PaymentService {private final PaymentValidator validator;
private final PaymentRepository repository;
private final NotificationService notification;
private final ReceiptGenerator receiptGenerator; public void processPayment(Payment payment) { validator.validate(payment); repository.save(payment); notification.send(payment); receiptGenerator.generate(payment);
}
}
Now:
PaymentService
|
+---- PaymentValidator
|
+---- PaymentRepository
|
+---- NotificationService
|
+---- ReceiptGeneratorEach class has a focused responsibility.
SRP is not “one method per class”
This is a common misunderstanding.
This would be ridiculous:
class ValidateCard {
void validate() {}
}class SaveCard {
void save() {}
}class SendCard {
void send() {}
}
SRP doesn’t mean:
Every class must contain exactly one method.
It means:
A class should represent one cohesive responsibility and have one primary axis of change.
Another useful example
Bad:
class Employee {String name; void calculateSalary() {} void saveToDatabase() {} void sendEmail() {} void generateReport() {}
}
Better:
Employee
|
+── SalaryCalculator
|
+── EmployeeRepository
|
+── EmailService
|
+── ReportGeneratorSRP in Spring Boot
You might have:
@RestController
class PaymentController { @PostMapping("/payments")
public PaymentResponse pay(...) {
// validation
// business logic
// DB
// Kafka
// email
// response
}
}That’s a smell.
A better architecture:
Controller
↓
PaymentService
↓
PaymentRepository
↓
Databaseand perhaps:
PaymentService
|
+---- PaymentValidator
|
+---- PaymentRepository
|
+---- EventPublisherThe controller handles HTTP concerns.
The service handles business logic.
The repository handles persistence.
2. O — Open/Closed Principle
This one is extremely important.
Definition
Software entities should be open for extension but closed for modification.
Meaning:
Existing code
↓
should not constantly change
↓
when adding new behaviorBad payment example
class PaymentService {public void pay(String type, double amount) { if (type.equals("CARD")) {
payByCard(amount);
} else if (type.equals("UPI")) {
payByUPI(amount);
} else if (type.equals("BANK")) {
payByBank(amount);
}
}
}
Now business says:
Add Wallet.
You modify:
else if (type.equals("WALLET"))Then:
Add PayPal.
Modify again.
Then:
Add Apple Pay.
Modify again.
The class constantly changes.
That’s a sign of OCP violation.
Better approach
Create an abstraction:
interface PaymentProcessor { void pay(double amount);
}Implement it:
class CardPaymentProcessor
implements PaymentProcessor { @Override
public void pay(double amount) {
System.out.println("Card payment");
}
}UPI:
class UpiPaymentProcessor
implements PaymentProcessor { @Override
public void pay(double amount) {
System.out.println("UPI payment");
}
}Bank:
class BankPaymentProcessor
implements PaymentProcessor { @Override
public void pay(double amount) {
System.out.println("Bank payment");
}
}Now:
class PaymentService {private final PaymentProcessor processor; PaymentService(PaymentProcessor processor) {
this.processor = processor;
} public void pay(double amount) {
processor.pay(amount);
}
}
Adding Wallet:
class WalletPaymentProcessor
implements PaymentProcessor { @Override
public void pay(double amount) {
System.out.println("Wallet payment");
}
}We don’t modify the existing payment processing algorithm.
We extend it.
That’s the essence of OCP.
Strategy Pattern
What we just created is essentially the Strategy Pattern.
PaymentProcessor
/ | \
/ | \
Card UPI BankThis is extremely common in enterprise Java.
OCP in Spring Boot
You might have:
public interface PaymentProcessor {
PaymentResponse process(PaymentRequest request);
}Implementations:
@Component
class CardPaymentProcessor
implements PaymentProcessor {
}@Component
class UpiPaymentProcessor
implements PaymentProcessor {
}@Component
class WalletPaymentProcessor
implements PaymentProcessor {
}Then your service can select the strategy.
This is far more scalable than:
if (...)
else if (...)
else if (...)
else if (...)3. L — Liskov Substitution Principle
This is probably the most misunderstood SOLID principle.
Definition
Objects of a subtype should be replaceable with objects of the supertype without breaking the correctness of the program.
In simpler terms:
If B is a subtype of A, using B where A is expected should not produce surprising behavior.
Classic example: Rectangle and Square
You might think:
class Rectangle {
int width;
int height;
}and:
class Square extends Rectangle {
}Mathematically:
Square IS-A RectangleBut in object-oriented design, inheritance can cause problems.
Suppose:
class Rectangle {void setWidth(int width) {
this.width = width;
} void setHeight(int height) {
this.height = height;
}
}
Now:
Rectangle rectangle = new Rectangle();rectangle.setWidth(5);
rectangle.setHeight(10);Expected:
Area = 50Now:
Rectangle square = new Square();square.setWidth(5);
square.setHeight(10);A Square must have:
width == heightSo setting height to 10 changes width too.
The caller expected Rectangle behavior but got surprising Square behavior.
That’s an LSP violation.
Another practical example
Suppose:
interface PaymentProcessor {
void pay();
void refund();
}Now:
class CreditCardProcessor
implements PaymentProcessor {public void pay() {
// works
} public void refund() {
// works
}
}
But suppose:
class CashPaymentProcessor
implements PaymentProcessor {public void pay() {
// works
} public void refund() {
throw new UnsupportedOperationException();
}
}
Now:
PaymentProcessor processor =
new CashPaymentProcessor();processor.refund();The abstraction says:
PaymentProcessor supports refund()but the subtype says:
Actually, I don't.That’s a design smell and potentially an LSP violation.
Better design
Separate capabilities:
interface PaymentProcessor {
void pay();
}Then:
interface Refundable {
void refund();
}Card:
class CardProcessor
implements PaymentProcessor, Refundable {public void pay() {} public void refund() {}
}
Cash:
class CashProcessor
implements PaymentProcessor { public void pay() {}
}Now the type system accurately represents capabilities.
LSP test
Whenever you’re using inheritance, ask:
“Can I replace the parent with this child everywhere without breaking the assumptions of the caller?”
If the answer is no, reconsider the inheritance hierarchy.
4. I — Interface Segregation Principle
Definition
Clients should not be forced to depend on methods they do not use.
In simple language:
Don’t create giant interfaces.
Bad example
interface Employee {void work(); void eat(); void sleep(); void code(); void manageTeam(); void conductInterview();
}
Now:
class Developer implements Employee {
}Developer may not:
manageTeam()
conductInterview()but must implement them.
Get Manish Pandey’s stories in your inbox
Join Medium for free to get updates from this writer.
You may end up with:
@Override
public void manageTeam() {
throw new UnsupportedOperationException();
}That’s a smell.
Better design
Split interfaces.
interface Worker {
void work();
}interface Coder {
void code();
}interface Manager {
void manageTeam();
}interface Interviewer {
void conductInterview();
}Developer:
class Developer implements Worker, Coder {public void work() {} public void code() {}
}
Manager:
class EngineeringManager
implements Worker, Manager, Interviewer {public void work() {} public void manageTeam() {} public void conductInterview() {}
}
Now each class depends only on the capabilities it needs.
Real Spring example
Bad:
interface UserService {User createUser(); User getUser(); void deleteUser(); void sendEmail(); void exportReport(); void generateInvoice(); void processPayment();
}
That’s a giant interface.
Better:
interface UserReader {
User getUser();
}interface UserWriter {
User createUser();
void deleteUser();
}interface EmailService {
void sendEmail();
}interface PaymentService {
void processPayment();
}Now clients depend only on what they need.
5. D — Dependency Inversion Principle
This is perhaps the most important SOLID principle for Spring developers.
Definition
High-level modules should not depend directly on low-level modules. Both should depend on abstractions.
And:
Abstractions should not depend on details. Details should depend on abstractions.
That sounds complicated.
Let’s simplify.
Bad example
Suppose:
class PaymentService {private MySQLPaymentRepository repository =
new MySQLPaymentRepository(); public void save(Payment payment) {
repository.save(payment);
}
}
PaymentService directly depends on:
MySQLPaymentRepositoryWhat if tomorrow you move to PostgreSQL?
You have to modify:
PaymentServiceWhat if you use MongoDB?
Again:
PaymentServiceis affected.
Introduce abstraction
interface PaymentRepository { void save(Payment payment);
}MySQL:
class MySQLPaymentRepository
implements PaymentRepository { public void save(Payment payment) {
// MySQL logic
}
}PostgreSQL:
class PostgreSQLPaymentRepository
implements PaymentRepository { public void save(Payment payment) {
// PostgreSQL logic
}
}Now:
class PaymentService {private final PaymentRepository repository; PaymentService(PaymentRepository repository) {
this.repository = repository;
} public void save(Payment payment) {
repository.save(payment);
}
}
Now:
PaymentService
|
↓
PaymentRepository
↑
|
+------+-------+
| |
MySQL PostgreSQLPaymentService doesn’t care which database implementation is used.
That’s Dependency Inversion.
6. Dependency Injection vs Dependency Inversion
These are related but not the same thing.
This is important in Spring interviews.
Dependency Inversion Principle
A design principle:
High-level code
↓
abstraction
↑
low-level implementationDependency Injection
A technique for providing dependencies.
For example:
@Service
class PaymentService {private final PaymentRepository repository; @Autowired
PaymentService(PaymentRepository repository) {
this.repository = repository;
}
}
Spring performs dependency injection.
So:
DIP
↓
design principleDependency Injection
↓
implementation technique7. Constructor Injection
This is generally the cleanest way to express dependencies.
@Service
public class PaymentService {private final PaymentRepository repository; public PaymentService(PaymentRepository repository) {
this.repository = repository;
}
}
The class cannot exist without its required dependency.
That’s good design.
8. Putting all five together
Let’s design a payment system.
Step 1 — SRP
Separate responsibilities:
PaymentService
PaymentValidator
PaymentRepository
PaymentNotificationStep 2 — OCP
Payment types use strategies:
PaymentProcessor
|
+── CardProcessor
+── UpiProcessor
+── WalletProcessorAdding a new payment type doesn’t require modifying the existing processors.
Step 3 — LSP
Each implementation must genuinely honor the contract:
PaymentProcessor
↑
|
CardProcessor
UpiProcessor
WalletProcessorNo implementation should unexpectedly violate the expectations of PaymentProcessor.
Step 4 — ISP
Instead of:
HugePaymentInterfaceuse smaller interfaces:
PaymentProcessor
RefundProcessor
PaymentValidator
PaymentRepositoryStep 5 — DIP
High-level service depends on abstractions:
PaymentService
↓
PaymentRepository
↑
PostgresPaymentRepositoryand:
PaymentService
↓
PaymentProcessor
↑
CardProcessor
UpiProcessor9. Complete example
Let’s put this into code.
PaymentProcessor
public interface PaymentProcessor { PaymentResult process(Payment payment);
}Card
@Component
public class CardPaymentProcessor
implements PaymentProcessor {@Override
public PaymentResult process(Payment payment) { System.out.println("Processing card payment"); return PaymentResult.success();
}
}
UPI
@Component
public class UpiPaymentProcessor
implements PaymentProcessor {@Override
public PaymentResult process(Payment payment) { System.out.println("Processing UPI payment"); return PaymentResult.success();
}
}
Repository
public interface PaymentRepository { void save(Payment payment);
}Implementation:
@Repository
public class PaymentRepositoryImpl
implements PaymentRepository { @Override
public void save(Payment payment) {
// database operation
}
}Service
@Service
public class PaymentService {private final PaymentRepository repository;
private final PaymentProcessor processor; public PaymentService(
PaymentRepository repository,
PaymentProcessor processor) { this.repository = repository;
this.processor = processor;
} public PaymentResult process(Payment payment) { PaymentResult result =
processor.process(payment); repository.save(payment); return result;
}
}
Now our high-level business logic doesn’t care about:
PostgreSQL
MySQL
UPI implementation
Card implementationIt depends on abstractions.
10. SOLID and your microservices
This becomes even more interesting in the kind of systems you work with.
Imagine:
Transaction Service
|
↓
RabbitMQ
|
↓
Backoffice Service
|
+---- Encryption
|
+---- Database
|
+---- AuditYou don’t want:
class TransactionService { // RabbitMQ
// DB
// validation
// audit
// notification
// retry
// logging
}Instead:
TransactionService
|
+── TransactionValidate
|
+── TransactionRepository
|
+── EventPublisher
|
+── AuditServiceThis is SRP.
This is DIP + OCP.
class KmsEncryptionProvider
implements EncryptionProvider {
}without rewriting the business service.
That’s OCP + DIP working together.
11. SOLID does NOT mean “more interfaces”
This is a very important warning.
You should not do this:
PaymentService
↓
IPaymentService
↓
PaymentServiceImpl
↓
IPaymentRepository
↓
PaymentRepositoryImpljust because you think SOLID requires an interface for every class.
That’s not what SOLID means.
An abstraction is valuable when you have:
- multiple implementations
- a meaningful boundary
- external dependencies
- testing/seams
- independently changing responsibilities
Don’t create abstractions mechanically.
12. SOLID vs Design Patterns
These are different concepts.
SOLID
↓
Design principleswhile:
Strategy
Factory
Observer
Adapter
Decorator
Builder
↓
Design patternsPatterns often help you implement SOLID principles.
For example:
Strategy Pattern
↓
often helps OCPDependency Injection:
Dependency Injection
↓
helps implement DIPAdapter:
Adapter
↓
can help preserve LSP / decouple dependencies13. Common interview questions
Q1. What is SOLID?
SOLID is a collection of five object-oriented design principles intended to improve maintainability, extensibility, flexibility, and testability of software.
Q2. What is SRP?
A class should have one primary responsibility and one reason to change.
Q3. What is OCP?
Software should be open for extension but closed for modification. New behavior should preferably be added through new implementations or composition rather than repeatedly modifying stable code.
Q4. What is LSP?
Subtypes should be substitutable for their base types without violating the expectations or correctness of the client code.
Q5. What is ISP?
Clients should not be forced to depend on methods they don’t need. Prefer focused interfaces over large interfaces.
Q6. What is DIP?
High-level modules should depend on abstractions rather than concrete low-level implementations, and both should depend on abstractions.
14. How to identify SOLID violations in real code
When reviewing code, ask these questions.
SRP
Does this class have multiple unrelated reasons to change?OCP
Do I keep modifying the same class every time a new type is added?LSP
Can every implementation genuinely honor the contract of its parent/interface?ISP
Are implementations forced to implement methods they don't actually support?DIP
Does business logic directly instantiate infrastructure/concrete dependencies?15. The deeper connection between all five
This is the part I want you to really understand.
Imagine bad code:
PaymentService
|
+-----------+-----------+
| | |
DB MQ Payment
| | |
concrete concrete if/elseEverything is tightly coupled.
SOLID gradually transforms it:
PaymentService
/ | \
/ | \
↓ ↓ ↓
Repository Publisher Processor
↑ ↑ ↑
| | +---+---+
Postgres Kafka Card UPINow:
- SRP separates responsibilities.
- OCP allows new implementations.
- LSP ensures implementations honor their contracts.
- ISP keeps contracts focused.
- DIP keeps business logic independent from infrastructure.
The ultimate goal isn’t:
“Use five principles because SOLID says so.”
The goal is:
Keep change localized.
That’s the real value of SOLID.
If adding a new payment method requires changing 15 unrelated classes, your design has high coupling.
If adding it mostly means:
class WalletPaymentProcessor
implements PaymentProcessor {
}and registering/configuring it, your architecture is much more resilient.
16. One important caution: SOLID can be overused
SOLID is not a law.
For example, this:
class Calculator { public int add(int a, int b) {
return a + b;
}
}doesn’t need:
ICalculator
↓
Calculator
↓
AdditionStrategy
↓
AdditionFactory
↓
AdditionProviderThat’s overengineering.
Good engineering is:
Simple problem → simple design
Complex problem → appropriate abstractionsSOLID helps you recognize where abstractions are valuable, not where to add abstractions everywhere.
The learning path
Step 1
Build bad PaymentService
↓
Step 2
Fix SRP
↓
Step 3
Add payment strategies → OCP
↓
Step 4
Break an inheritance hierarchy → LSP
↓
Step 5
Split large interfaces → ISP
↓
Step 6
Introduce repository/provider abstractions → DIP
↓
Step 7
Add Spring Dependency Injection
↓
Step 8
Add unit tests with Mockito
↓
Step 9
Refactor toward production architecture




