DEV Community

Cover image for Understanding SOLID Principles in Software Design
Bellamer
Bellamer

Posted on

Understanding SOLID Principles in Software Design

The SOLID principles are a set of guidelines that help software developers design robust, scalable, and maintainable systems. These principles were introduced by Robert C. Martin (Uncle Bob) and are essential in object-oriented programming to create flexible and reusable code.

In this post, we’ll dive into each SOLID principle, explain its purpose, and provide examples in Java to demonstrate their application.

1. Single Responsibility Principle (SRP)

Definition: A class should have only one reason to change. This means a class should have only one job or responsibility.

Why SRP Matters

When a class has multiple responsibilities, changes to one responsibility may affect or break other parts of the code. By adhering to SRP, we ensure better maintainability and testability.

Example

// Violating SRP: A class that handles both user authentication and database operations.
class UserManager {
    public void authenticateUser(String username, String password) {
        // Authentication logic
    }

    public void saveUserToDatabase(User user) {
        // Database logic
    }
}

// Following SRP: Separate responsibilities into distinct classes.
class AuthService {
    public void authenticateUser(String username, String password) {
        // Authentication logic
    }
}

class UserRepository {
    public void saveUserToDatabase(User user) {
        // Database logic
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example, AuthService handles authentication, and UserRepository manages database operations. Each class has a single responsibility, making the code cleaner and more modular.

2. Open/Closed Principle (OCP)

Definition: Classes should be open for extension but closed for modification. This means you should be able to add new functionality without altering existing code.

Why OCP Matters

When you modify existing code, you risk introducing bugs. OCP promotes extending functionality through inheritance or composition rather than altering the original implementation.

Example

// Violating OCP: Adding a new discount type requires modifying the existing code.
class DiscountCalculator {
    public double calculateDiscount(String discountType, double amount) {
        if ("NEWYEAR".equals(discountType)) {
            return amount * 0.10;
        } else if ("BLACKFRIDAY".equals(discountType)) {
            return amount * 0.20;
        }
        return 0;
    }
}

// Following OCP: Use polymorphism to add new discount types without changing existing code.
interface Discount {
    double apply(double amount);
}

class NewYearDiscount implements Discount {
    public double apply(double amount) {
        return amount * 0.10;
    }
}

class BlackFridayDiscount implements Discount {
    public double apply(double amount) {
        return amount * 0.20;
    }
}

class DiscountCalculator {
    public double calculateDiscount(Discount discount, double amount) {
        return discount.apply(amount);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, adding a new discount type simply requires creating a new class implementing the Discount interface.

3. Liskov Substitution Principle (LSP)

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

Why LSP Matters

Violating LSP can lead to unexpected behavior and errors when using polymorphism. Derived classes must honor the contract defined by their base classes.

Example

// Violating LSP: A subclass changes the behavior of the parent class in an unexpected way.
class Bird {
    public void fly() {
        System.out.println("Flying...");
    }
}

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

// Following LSP: Refactor the hierarchy to honor substitutability.
abstract class Bird {
    public abstract void move();
}

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

class Penguin extends Bird {
    public void move() {
        System.out.println("Swimming...");
    }
}
Enter fullscreen mode Exit fullscreen mode

By redesigning the hierarchy, both FlyingBird and Penguin behave correctly when substituted for Bird.

4. Interface Segregation Principle (ISP)

Definition: Clients should not be forced to implement interfaces they don’t use. Instead, create smaller, more specific interfaces.

Why ISP Matters

Large interfaces force implementing classes to include methods they don’t need. This results in bloated code and unnecessary dependencies.

Example

// Violating ISP: A large interface with unrelated methods.
interface Worker {
    void work();
    void eat();
}

class Robot implements Worker {
    public void work() {
        System.out.println("Working...");
    }

    public void eat() {
        // Robots don't eat, but they're forced to implement this method.
        throw new UnsupportedOperationException("Robots don't eat!");
    }
}

// Following ISP: Split the interface into smaller, focused interfaces.
interface Workable {
    void work();
}

interface Eatable {
    void eat();
}

class Robot implements Workable {
    public void work() {
        System.out.println("Working...");
    }
}

class Human implements Workable, Eatable {
    public void work() {
        System.out.println("Working...");
    }

    public void eat() {
        System.out.println("Eating...");
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, Robot only implements the Workable interface, avoiding unnecessary methods.

5. Dependency Inversion Principle (DIP)

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

Why DIP Matters

Direct dependencies on concrete implementations make code rigid and hard to test. DIP promotes the use of abstractions (interfaces) to decouple components.

Example

// Violating DIP: High-level class depends on a low-level implementation.
class MySQLDatabase {
    public void connect() {
        System.out.println("Connecting to MySQL...");
    }
}

class UserService {
    private MySQLDatabase database;

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

    public void performDatabaseOperation() {
        database.connect();
    }
}

// Following DIP: High-level class depends on an abstraction.
interface Database {
    void connect();
}

class MySQLDatabase implements Database {
    public void connect() {
        System.out.println("Connecting to MySQL...");
    }
}

class UserService {
    private Database database;

    public UserService(Database database) {
        this.database = database; // Depend on abstraction
    }

    public void performDatabaseOperation() {
        database.connect();
    }
}

// Usage
Database db = new MySQLDatabase();
UserService userService = new UserService(db);
userService.performDatabaseOperation();
Enter fullscreen mode Exit fullscreen mode

With this design, you can easily swap the Database implementation (e.g., PostgreSQL, MongoDB) without modifying the UserService class.

Conclusion

The SOLID principles are powerful tools for creating maintainable, scalable, and robust software. Here’s a quick recap:

  1. SRP: One class, one responsibility.
  2. OCP: Extend functionality without modifying existing code.
  3. LSP: Subtypes must be substitutable for their base types.
  4. ISP: Prefer smaller, focused interfaces.
  5. DIP: Depend on abstractions, not concrete implementations.

By applying these principles, your code will be easier to understand, test, and adapt to changing requirements. Start small, refactor as needed, and gradually incorporate these principles into your development process!

Top comments (0)