Solid Principles

Table of Contents

Solid Principles

Language: Java
Audience: Developers mastering low-level design

Overview:
SOLID is a mnemonic for five design principles that promote maintainable, scalable, and extensible software.

They are:

  1. Single Responsibility Principle (SRP)
  2. Open/Closed Principle (OCP)
  3. Liskov Substitution Principle (LSP)
  4. Interface Segregation Principle (ISP)
  5. Dependency Inversion Principle (DIP)
Solid Principles

1. Single Responsibility Principle (SRP)

Definition:
A class should have only one reason to change.

  • Each class should only have one reason to change.
  • It does one specific job and only that job.

Why?:

  • Prevents "God Classes" that handle multiple responsibilities.
  • Reduces ripple effects* when requirements change.
  • Improves code maintainability and testability.

A "ripple effect" is a change in one part of a program that causes unintended and cascading consequences in other parts, often requiring more changes to fix

Violation Example:

class Employee {
    // Data management
    public String name;
    public double salary;

    // Database operations
    public void saveToDatabase() {
        // JDBC code to save employee
    }

    // Reporting
    public void generateReport() {
        // PDF generation logic
    }
}

Issues:

  • Changing database logic forces retesting reporting.
  • Violates separation of concerns.

Corrected Example:

// Data Model
class Employee {
    public String name;
    public double salary;
}

// Database Responsibility
class EmployeeRepository {
    public void save(Employee emp) {
        // JDBC code
    }
}

// Reporting Responsibility
class ReportGenerator {
    public void generate(Employee emp) {
        // PDF generation
    }
}

Real-World Use Case:

  • E-Commerce System:
    • Order class holds order data.
    • OrderRepository handles database persistence.
    • InvoicePrinter generates invoices.

In simple terms, if a class is taking too much load or more operation behalf of the entire application, it is better to split it into multiple classes.


2. Open/Closed Principle (OCP)

Definition:
Software entities should be open for extension but closed for modification.

  • Classes should be easy to extend without modifying their existing code.
  • Add new functionality by adding new code, not changing old code.

Why?:

  • Avoids breaking existing functionality when adding new features.
  • Promotes polymorphism and abstraction.

Violation Example:

class AreaCalculator {
    public double calculateArea(Object[] shapes) {
        double area = 0;
        for (Object shape : shapes) {
            if (shape instanceof Rectangle) {
                Rectangle r = (Rectangle) shape;
                area += r.width * r.height;
            } else if (shape instanceof Circle) {
                Circle c = (Circle) shape;
                area += Math.PI * c.radius * c.radius;
            }
        }
        return area;
    }
}

Issues:

  • Adding Triangle requires modifying AreaCalculator.
  • Violates OCP (needs modification for new shapes).

Corrected Example:

// Abstraction
interface Shape {
    double area();
}

// Implementations
class Rectangle implements Shape {
    public double width;
    public double height;
    public double area() { return width * height; }
}

class Circle implements Shape {
    public double radius;
    public double area() { return Math.PI * radius * radius; }
}

// Closed for modification
class AreaCalculator {
    public double calculateArea(Shape[] shapes) {
        double area = 0;
        for (Shape shape : shapes) {
            area += shape.area(); // Polymorphic call
        }
        return area;
    }
}

In the code, the AreaCalculator calculateArea() method iterates over an array of Shape objects and calls shape.area() on each. Because Rectangle and Circle each implement their own version of area(), the call shape.area() invokes the correct method dynamically according to the actual object's class. This is known as runtime polymorphism or dynamic method dispatch. It enables flexibility and adherence to the Open/Closed principle by enabling new shapes to be added without modifying the AreaCalculator code.

Don't over confuse yourself with the complexity of the program, just understand the logic. We have other principles to follow which actually implements on top of this, such as factory method pattern.

Real-World Use Case:

  • Payment Processing:
    • PaymentProcessor uses PaymentMethod interface.
    • New payment types (e.g., CryptoPayment) implement PaymentMethod without modifying PaymentProcessor.

3. Liskov Substitution Principle (LSP)

Definition:
Subtypes must be substitutable for their base types without altering correctness.

  • You can use a subclass anywhere a superclass is expected without breaking the program.
  • Subclasses should behave in a way that doesn’t surprise users of the base class.

Why?:

  • Prevents unexpected behavior when using inheritance.
  • Ensures contracts defined by base classes are honored.

Violation Example:

class Bird {
    public void fly() {
        System.out.println("Flying");
    }
}

class Penguin extends Bird {
    @Override
    public void fly() {
        throw new UnsupportedOperationException("Penguins can't fly");
    }
}

Issues:

  • Penguin breaks the Bird contract.
  • Client code expecting fly() to work crashes.

Corrected Example:

// Base class for birds that can fly
interface FlyingBird {
    void fly();
}

// Base class for all birds
abstract class Bird {
    // Common bird behavior
}

class Sparrow extends Bird implements FlyingBird {
    public void fly() { System.out.println("Flying"); }
}

class Penguin extends Bird {
    // No fly() implementation
}

It uses an interface FlyingBird for birds that can fly, and a base Bird class for common behaviors. Only classes that can actually fly (like Sparrow) implement FlyingBird and provide a fly() method. Penguins don't implement fly() at all, avoiding the contract violation.

We can thus create a new interface called SwimmingBird (or similar) focused only on swimming behavior, and have the Penguin class implement that interface. This follows the Interface Segregation Principle (ISP), where we define small, specific interfaces rather than one large interface with all behaviors.

interface SwimmingBird {
    void swim();
}

class Penguin extends Bird implements SwimmingBird {
    public void swim() { System.out.println("Swimming"); }
}

Real-World Use Case:

  • File System:
    • ReadOnlyFile subclass of File doesn’t implement write().
    • Clients using File must check isWritable() before writing.

4. Interface Segregation Principle (ISP)

Definition:

Clients should not be forced to depend on interfaces they don’t use.

  • Don’t force a class to implement methods it doesn’t need.
  • Use many small, specific interfaces instead of one big general interface.

Why It Matters:

  • Prevents "fat interfaces" with unused methods.
  • Reduces coupling and side effects.

Violation Example:

interface Machine {
    void print();
    void scan();
    void fax();
}

class MultiFunctionPrinter implements Machine {
    public void print() { /* ... */ }
    public void scan() { /* ... */ }
    public void fax() { /* ... */ }
}

class OldPrinter implements Machine {
    public void print() { /* ... */ }
    public void scan() { throw new UnsupportedOperationException(); }
    public void fax() { throw new UnsupportedOperationException(); }
}

Issues:

  • OldPrinter is forced to implement scan() and fax().
  • Violates ISP (depends on unused methods).

Corrected Example:

interface Printer {
    void print();
}

interface Scanner {
    void scan();
}

interface Fax {
    void fax();
}

class MultiFunctionPrinter implements Printer, Scanner, Fax {
    // Implement all
}

class OldPrinter implements Printer {
    // Only implements print()
}

This needs no explanation for you. We segregate the interfaces into smaller ones, and the classes implement only the ones they need. So both MultiFunctionPrinter and OldPrinter are now compliant with the Interface Segregation Principle.

Real-World Use Case:

  • E-Commerce Notifications:
    • EmailNotifier implements EmailSender.
    • SMSNotifier implements SMSSender.
    • No class is forced to implement unrelated methods.

5. Dependency Inversion Principle (DIP)

Definition:

High-level modules should not depend on low-level modules. Both should depend on abstractions.

  • High-level modules should not depend on low-level modules.
  • Both should depend on abstractions (interfaces or abstract classes).
  • Don’t depend on concrete implementations; depend on abstract contracts.

Why?:

  • Decouples business logic from implementation details.
  • Enables easier testing and framework swapping.

Violation Example:

class MySQLDatabase {
    public void save(String data) {
        // MySQL-specific save logic
    }
}

class UserService {
    private MySQLDatabase database; // Direct dependency

    public UserService() {
        this.database = new MySQLDatabase();
    }

    public void saveUser(String userData) {
        database.save(userData);
    }
}

Issues:

  • UserService is tightly coupled to MySQLDatabase.
  • Switching to PostgreSQL requires modifying UserService.

Corrected Example:

// Abstraction
interface Database {
    void save(String data);
}

// Low-level module
class MySQLDatabase implements Database {
    public void save(String data) { /* MySQL logic */ }
}

class PostgreSQLDatabase implements Database {
    public void save(String data) { /* PostgreSQL logic */ }
}

// High-level module
class UserService {
    private Database database; // Depends on abstraction

    public UserService(Database database) {
        this.database = database;
    }

    public void saveUser(String userData) {
        database.save(userData);
    }
}

This design enables loose coupling: UserService depends only on the Database abstraction, not on details, making it flexible to support different databases without changing its code.

To save the user data using the MySQL database, we do it by creating an instance of the concrete MySQLDatabase class and passing it to the UserService. Then call the saveUser method on the UserService instance.

public class Main{
    public static void main(String[] args){
        Database database = new MySQLDatabase();
        UserService userService = new UserService(database);
        userServive.saveUser("User data");
    }
}

Real-World Use Case:

  • Logging Framework:
    • PaymentService depends on Logger interface.
    • FileLogger and CloudLogger implement Logger.
    • Switching logging requires no changes to PaymentService.

SOLID Cheat Sheet

Principle Key Idea Java Pattern Anti-Pattern
SRP One responsibility per class Repository + Service God Class
OCP Extend via abstraction Strategy Pattern Modifying existing code
LSP Subtypes honor contracts Template Method Throwing UnsupportedOperationException
ISP Small, focused interfaces Role Interfaces Fat Interfaces
DIP Depend on abstractions Dependency Injection new ConcreteClass()

Key Takeaways

  1. SRP: Split classes when they have >1 reason to change.
  2. OCP: Use interfaces/abstract classes for extensibility.
  3. LSP: Ensure subclasses behave like their parents.
  4. ISP: Prefer many small interfaces over one large interface.
  5. DIP: Inject dependencies via constructors/setters.

When to Apply SOLID:

  • During refactoring (identify violations).
  • When adding new features (design for extensibility).
  • In unit tests (mock abstractions, not concrete classes).

SOLID in Real Projects:

  • Spring Framework: Uses DIP (dependency injection) and OCP (extensible via @Bean).
  • Java Collections: Follows ISP (separate List, Set, Queue interfaces).
  • AWS SDK: Implements DIP (clients depend on AmazonWebServiceClient interface).
← Linux File System Software Design Principles →