🚀AdvancedDesign Patterns

SOLID Principles

Apply the five SOLID principles to write maintainable, extensible, and testable Java code.

Overview

SOLID is an acronym for five object-oriented design principles that, when applied together, produce code that is easier to maintain, extend, and test. - S: Single Responsibility Principle (SRP) - a class should have only one reason to change - O: Open/Closed Principle (OCP) - open for extension, closed for modification - L: Liskov Substitution Principle (LSP) - subclasses must be substitutable for their base classes - I: Interface Segregation Principle (ISP) - no client should be forced to depend on methods it does not use - D: Dependency Inversion Principle (DIP) - depend on abstractions, not concrete implementations These principles guide design decisions rather than dictate specific implementations.

Key Points

  • •SRP: if a class has multiple reasons to change, split it into separate classes
  • •OCP: add new behavior by adding new classes/methods, not by modifying existing ones
  • •OCP is typically achieved through polymorphism and the Strategy/Template patterns
  • •LSP: a subclass must honor the contract of its base class (preconditions no stronger, postconditions no weaker)
  • •LSP violations: subclasses that throw exceptions for inherited methods, or produce unexpected results
  • •ISP: prefer many small, specific interfaces over one large general-purpose interface
  • •DIP: high-level modules should not depend on low-level modules; both should depend on abstractions
  • •DIP is implemented via constructor injection, method injection, or interface-based programming

Examples

SRP: separating user persistence from email logic

// Violation: UserService does too many things
class UserServiceBad {
    public void save(User user) { /* writes to DB */ }
    public void sendWelcomeEmail(User user) { /* sends email */ }
    public String generateReport() { /* builds report */ }
}

// Correct: each class has one responsibility
class UserRepository {
    public void save(User user) { /* writes to DB */ }
}

class EmailService {
    public void sendWelcomeEmail(User user) { /* sends email */ }
}

class UserReportService {
    public String generateReport() { /* builds report */ }
}

record User(String name, String email) {}

public class Main {
    public static void main(String[] args) {
        User user = new User("Alice", "alice@example.com");
        UserRepository repo = new UserRepository();
        EmailService email = new EmailService();
        repo.save(user);
        email.sendWelcomeEmail(user);
    }
}

If the email provider changes, only EmailService needs to change. If the DB schema changes, only UserRepository is affected. Classes with one responsibility are smaller and easier to test.

OCP and DIP: extending discount logic without modification

// Abstraction
interface DiscountPolicy {
    double apply(double price);
}

// Concrete implementations (extensions)
class NoDiscount implements DiscountPolicy {
    public double apply(double price) { return price; }
}

class PercentageDiscount implements DiscountPolicy {
    private final double rate;
    PercentageDiscount(double rate) { this.rate = rate; }
    public double apply(double price) { return price * (1 - rate); }
}

class SeasonalDiscount implements DiscountPolicy {
    public double apply(double price) { return price - 10; }
}

// High-level module depends on the abstraction, not concrete classes
class OrderProcessor {
    private final DiscountPolicy policy; // DIP: injected, not hard-coded

    OrderProcessor(DiscountPolicy policy) { this.policy = policy; }

    public double process(double price) {
        return policy.apply(price);
    }
}

public class Main {
    public static void main(String[] args) {
        // Adding a new discount type never modifies OrderProcessor (OCP)
        OrderProcessor vip = new OrderProcessor(new PercentageDiscount(0.20));
        System.out.println(vip.process(100)); // 80.0

        OrderProcessor holiday = new OrderProcessor(new SeasonalDiscount());
        System.out.println(holiday.process(100)); // 90.0
    }
}

OrderProcessor is closed for modification. New discount types are added by creating new classes. OrderProcessor depends on the DiscountPolicy abstraction (DIP), so it does not care which concrete policy is injected.

ISP: splitting a large interface into focused ones

// Violation: forces printers that cannot scan to implement scan()
interface AllInOne {
    void print(String doc);
    void scan(String doc);
    void fax(String doc);
}

// Correct: segregated interfaces
interface Printer   { void print(String doc); }
interface Scanner   { void scan(String doc); }
interface FaxMachine{ void fax(String doc); }

class SimplePrinter implements Printer {
    public void print(String doc) {
        System.out.println("Printing: " + doc);
    }
}

// A full-featured device can implement all three
class FullPrinter implements Printer, Scanner, FaxMachine {
    public void print(String doc) { System.out.println("Print: " + doc); }
    public void scan(String doc)  { System.out.println("Scan: " + doc);  }
    public void fax(String doc)   { System.out.println("Fax: " + doc);   }
}

public class Main {
    public static void main(String[] args) {
        Printer p = new SimplePrinter();
        p.print("Invoice");
    }
}

SimplePrinter implements only what it supports. Clients that only need printing depend on Printer, not on methods they never use. This reduces coupling and prevents UnsupportedOperationException-style violations.

Common Mistakes

  • Treating SOLID as strict rules rather than guidelines. Over-applying them leads to excessive abstraction and boilerplate. Apply them where they solve real problems.
  • Creating one interface per class for the sake of DIP. DIP does not mean every class needs an interface. Use interfaces when you need to vary the implementation (testing, multiple providers, extension).
  • LSP violations through exception throwing in overrides. If a subclass override throws UnsupportedOperationException for a method defined in the base class, it violates LSP. Use a narrower interface instead.
  • ISP over-segregation: splitting interfaces so finely that clients must import dozens of interfaces. Balance cohesion and segregation.

Note

The dependency inversion principle is the foundation of dependency injection (DI) frameworks like Spring. When you annotate a constructor with @Autowired, Spring injects the concrete implementation at runtime, keeping your class dependent only on the abstraction.

Exercises

Write Java code and click Run to execute it instantly. When your output looks correct, mark as done.

Loading editor…

Completed: 0 / 2 exercises

Knowledge Check

Question 1 of 4

What does the Single Responsibility Principle state?