Sitemap

SOLID Principles

11 min readSep 20, 2026

--

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 Principle

A useful high-level picture:

SOLID
|
+-----------+-----------+
| | |
SRP OCP LSP
| | |
Responsibility Extension Substitution

+-----------+-----------+
| |
ISP DIP
| |
Interfaces Dependencies

But don’t memorize those words yet.

Instead, we’ll use one realistic application throughout.

Our Example: Payment System

Imagine you’re building:

Payment Service

It supports:

Credit Card
Debit Card
UPI
Bank Transfer
Wallet

Initially, 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 generation

Now imagine:

Requirement 1

Database changes from PostgreSQL to MongoDB.

You modify:

PaymentService

Requirement 2

Email provider changes from SMTP to SendGrid.

Again:

PaymentService

Requirement 3

Receipt format changes.

Again:

PaymentService

So 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
|
+---- ReceiptGenerator

Each 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
|
+── ReportGenerator

SRP 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
↓
Database

and perhaps:

PaymentService
|
+---- PaymentValidator
|
+---- PaymentRepository
|
+---- EventPublisher

The 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 behavior

Bad 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 Bank

This 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 Rectangle

But 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 = 50

Now:

Rectangle square = new Square();
square.setWidth(5);
square.setHeight(10);

A Square must have:

width == height

So 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:

MySQLPaymentRepository

What if tomorrow you move to PostgreSQL?

You have to modify:

PaymentService

What if you use MongoDB?

Again:

PaymentService

is 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 PostgreSQL

PaymentService 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 implementation

Dependency 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 principle
Dependency Injection
↓
implementation technique

7. 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
PaymentNotification

Step 2 — OCP

Payment types use strategies:

PaymentProcessor
|
+── CardProcessor
+── UpiProcessor
+── WalletProcessor

Adding a new payment type doesn’t require modifying the existing processors.

Step 3 — LSP

Each implementation must genuinely honor the contract:

PaymentProcessor
↑
|
CardProcessor
UpiProcessor
WalletProcessor

No implementation should unexpectedly violate the expectations of PaymentProcessor.

Step 4 — ISP

Instead of:

HugePaymentInterface

use smaller interfaces:

PaymentProcessor
RefundProcessor
PaymentValidator
PaymentRepository

Step 5 — DIP

High-level service depends on abstractions:

PaymentService
↓
PaymentRepository
↑
PostgresPaymentRepository

and:

PaymentService
↓
PaymentProcessor
↑
CardProcessor
UpiProcessor

9. 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 implementation

It 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
|
+---- Audit

You don’t want:

class TransactionService {
    // RabbitMQ
// DB
// validation
// audit
// notification
// retry
// logging
}

Instead:

TransactionService
|
+── TransactionValidate
|
+── TransactionRepository
|
+── EventPublisher
|
+── AuditService

This 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
↓
PaymentRepositoryImpl

just 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 principles

while:

Strategy
Factory
Observer
Adapter
Decorator
Builder
↓
Design patterns

Patterns often help you implement SOLID principles.

For example:

Strategy Pattern
↓
often helps OCP

Dependency Injection:

Dependency Injection
↓
helps implement DIP

Adapter:

Adapter
↓
can help preserve LSP / decouple dependencies

13. 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/else

Everything is tightly coupled.

SOLID gradually transforms it:

PaymentService
/ | \
/ | \
↓ ↓ ↓
Repository Publisher Processor
↑ ↑ ↑
| | +---+---+
Postgres Kafka Card UPI

Now:

  • 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
↓
AdditionProvider

That’s overengineering.

Good engineering is:

Simple problem → simple design
Complex problem → appropriate abstractions

SOLID 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

--

--

Manish Pandey
Manish Pandey

Written by Manish Pandey

Software Engineer at Mastercard