Full Stack • Java • System Design • Cloud • AI Engineering

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:

  1. Business validation rules change → edit OrderService
  2. Database schema or ORM changes → edit OrderService
  3. Email template or provider changes → edit OrderService
  4. 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 EmailService changes
  • Validation rules change → only OrderValidator changes
  • Database schema changes → only OrderRepository changes

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 UnsupportedOperationException for 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
  • OrderService knows the exact names MySQLOrderRepository and SmtpEmailService — 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 OrderRepository bean — OrderService is untouched
  • Add SMS alongside email → inject a different NotificationServiceOrderService is 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
  • @Primary annotation 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.