Last Updated on August 23, 2024

Introduced by Robert C. Martin in the early 2000s, SOLID is an acronym representing five fundamental principles of object-oriented programming and design. These principles have become a cornerstone of modern software development, offering a roadmap to creating high-quality, robust code that stands the test of time.

As applications grow in complexity, developers need guiding principles to ensure their code remains manageable and adaptable to change.

Importance of SOLID Principles

Software development is inherently complex, with projects often growing beyond their initial scope and lasting for years. As codebases expand, they can become increasingly difficult to maintain, understand, and modify. Without proper guidelines, developers may find themselves grappling with:

  • Rigid code that resists change
  • Fragile systems where modifications in one area break functionality in others
  • Immobile components that are too interdependent to be reused or replaced

By adhering to SOLID principles, developers can significantly improve the overall quality of their code.

In team environments, SOLID principles provide a common language and set of best practices Facilitating Collaboration.

Developers can create software that is better equipped to handle future changes and additions without requiring extensive rewrites.

SOLID principles are important because they provide a robust framework for addressing the inherent challenges of software development. They guide developers in creating systems that are not only functional for current needs but are also prepared for future growth and change. By improving code quality, enhancing collaboration, and reducing long-term costs, SOLID principles have become an indispensable tool in the modern software developer’s toolkit.

What are the SOLID Principles?

The SOLID acronym represents five fundamental principles of object-oriented programming and design. Each principle addresses a specific aspect of software design and together they form a comprehensive guide for creating maintainable and scalable software.

Single Responsibility Principle (SRP)

The Single Responsibility Principle states that a class should have only one reason to change. In other words, a class should have only one job or responsibility.

For example, instead of having a class that handles both user authentication and user profile management, split these into two separate classes. This way, changes to the authentication process won’t affect profile management and vice versa.

Open-Closed Principle (OCP)

The Open-Closed Principle asserts that software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.

Example:
Instead of modifying an existing payment processing class to add a new payment method, create a new class that implements a common payment interface. This allows for easy addition of new payment methods without altering existing code.

Liskov Substitution Principle (LSP)

The Liskov Substitution Principle states that objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program.

Example:
If you have a class Rectangle and a subclass Square, the Square should be able to be used anywhere a Rectangle is expected without causing unexpected behavior. This might mean rethinking the inheritance hierarchy if Square can’t fulfill all of Rectangle’s contracts.

Interface Segregation Principle (ISP)

The Interface Segregation Principle suggests that many client-specific interfaces are better than one general-purpose interface.

Example:
Instead of having a large Worker interface with methods for all possible worker actions, create separate interfaces like Eatable, Workable, and Sleepable. Classes can then implement only the interfaces relevant to their functionality.

Dependency Inversion Principle (DIP)

The Dependency Inversion Principle states that high-level modules should not depend on low-level modules; both should depend on abstractions. Additionally, abstractions should not depend on details; details should depend on abstractions.

Example:
Instead of having a high-level module directly instantiate a low-level module (e.g., a DataAnalyzer class creating a specific DatabaseConnection), both should depend on an abstraction (e.g., an IDataSource interface). This allows for easy swapping of data sources without modifying the DataAnalyzer.

Java SOLID Examples

Let’s give an example violating the SRP

public class User {
    private String name;
    private String email;

    // User properties and methods

    public void saveUser() {
        // Logic to save user to database
    }

    public void sendEmail(String message) {
        // Logic to send email
    }
}

The User class is responsible for:

  1. Storing user data (name and email)
  2. Saving user data to a database
  3. Sending emails

To adhere to SRP principle we can separate above code to three classes with separate responsibilities:

  1. User class:
    • Responsibility: Represent and manage user data
    • Reason to change: Changes in user attributes or business rules related to user data
  2. UserRepository class:
    • Responsibility: Handle database operations for users
    • Reason to change: Changes in database schema, switching to a different database, or modifying data access patterns
  3. EmailService class:
    • Responsibility: Handle email-related operations
    • Reason to change: Changes in email service provider, email sending logic, or email templates
public class User {
    private String name;
    private String email;

    // User properties and methods
}
public class UserRepository {
    public void saveUser(User user) {
        // Logic to save user to database
    }
}
public class EmailService {
    public void sendEmail(String to, String message) {
        // Logic to send email
    }
}

An Example violation the OCP principle would be:

public class Rectangle {
    protected double width;
    protected double height;

    // getters and setters
}

public class AreaCalculator {
    public double calculateArea(Object shape) {
        if (shape instanceof Rectangle) {
            Rectangle rectangle = (Rectangle) shape;
            return rectangle.getWidth() * rectangle.getHeight();
        }
        // Add more if statements for other shapes
        return 0;
    }
}

The AreaCalculator class violates the OCP because it needs to be modified every time we want to add a new shape.

We can change above code to adhere to OCP principle:

We define a Shape interface with a calculateArea method.

Each shape (Rectangle, Circle) implements this interface and provides its own implementation of calculateArea.

The AreaCalculator class now depends on the Shape interface, not on concrete classes.

public interface Shape {
    double calculateArea();
}

public class Rectangle implements Shape {
    private double width;
    private double height;

    @Override
    public double calculateArea() {
        return width * height;
    }
}

public class Circle implements Shape {
    private double radius;

    @Override
    public double calculateArea() {
        return Math.PI * radius * radius;
    }
}

public class AreaCalculator {
    public double calculateArea(Shape shape) {
        return shape.calculateArea();
    }
}

The Liskov Substitution Principle, introduced by Barbara Liskov, states that objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program.

Example violating LSP

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;
    }
}

public class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        super.setWidth(width);
        super.setHeight(width);
    }

    @Override
    public void setHeight(int height) {
        super.setWidth(height);
        super.setHeight(height);
    }
}

The Square class, when used as a Rectangle, doesn’t behave as expected. This violates the principle of substitutability.

Rectangle rectangle = new Square();
rectangle.setWidth(5);
rectangle.setHeight(4);
System.out.println(rectangle.getArea()); // Outputs 16, not 20 as expected

We can rewrite above code to adhere to LSP principle:

public interface Shape {
    int getArea();
}

public class Rectangle implements Shape {
    protected int width;
    protected int height;

    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

    public void setWidth(int width) {
        this.width = width;
    }

    public void setHeight(int height) {
        this.height = height;
    }

    @Override
    public int getArea() {
        return width * height;
    }
}

public class Square implements Shape {
    private int side;

    public Square(int side) {
        this.side = side;
    }

    public void setSide(int side) {
        this.side = side;
    }

    @Override
    public int getArea() {
        return side * side;
    }
}

Both Rectangle and Square implement the Shape interface, but they’re not in an inheritance relationship.

Square doesn’t try to override Rectangle‘s behavior. Instead, it has its own implementation that’s consistent with what a square should do.

This design allows Square and Rectangle to be used interchangeably where a Shape is expected, without causing unexpected behavior.

To illustrate the Interface Segregation Principle (ISP), Imagine a Shape interface with methods for drawing, calculating area, and calculating volume. However, not all shapes have volume (e.g., circles).

interface Shape {
    void draw();
    double calculateArea();
    double calculateVolume();
}

class Circle implements Shape {
    // ...
    public double calculateVolume() {
        throw new UnsupportedOperationException();
    }
}

We revrite the above code to adhere to ISP principle:

interface Drawable {
    void draw();
}

interface Shape extends Drawable {
    double calculateArea();
}

interface ThreeDimensionalShape extends Shape {
    double calculateVolume();
}

class Circle implements Drawable {
    // ...
}

class Sphere implements ThreeDimensionalShape {
    // ...
}

In the above code:

The Shape interface is separated into Drawable and ThreeDimensionalShape interfaces.

Circle only implements Drawable, avoiding the unnecessary calculateVolume() method.

Sphere implements both Drawable and ThreeDimensionalShape, correctly defining the calculateVolume() method.

Dependency Inversion Principle (DIP) states that 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.

public class EmailSender {
    public void sendEmail(String recipient, String message) {
        // ... implementation using a specific email service (e.g., Gmail)
    }
}

public class CustomerService {
    public void sendWelcomeEmail(Customer customer) {
        EmailSender emailSender = new GmailSender();
        emailSender.sendEmail(customer.getEmail(), "Welcome!");
    }
}

In this example, the CustomerService class directly depends on the GmailSender class, violating DIP. This makes it difficult to change the email service without modifying CustomerService.

In the fixed example, CustomerService depends on the EmailService interface, which is an abstraction. This allows for easy substitution of different email services without modifying CustomerService

public interface EmailService {
    void sendEmail(String recipient, String message);
}

public class GmailSender implements EmailService {
    // ... implementation
}

public class CustomerService {
    private final EmailService emailService;

    public CustomerService(EmailService emailService) {
        this.emailService = emailService;
    }

    public void sendWelcomeEmail(Customer customer) {
        emailService.sendEmail(customer.getEmail(), "Welcome!");
    }
}

Conclusion

The SOLID principles – Single Responsibility, Open-Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion – form a powerful set of guidelines for creating robust, maintainable, and scalable software. By addressing common challenges in software development, these principles help developers create systems that are not only functional for current needs but are also prepared for future growth and change.

The benefits of implementing SOLID principles are far-reaching, impacting everything from code quality and team productivity to long-term cost efficiency and risk management. While applying these principles requires initial investment in terms of time and effort, the rewards in terms of improved maintainability, scalability, and overall software quality make them an essential part of modern software development best practices.

As with any set of principles, it’s important to apply SOLID pragmatically, considering the specific context and requirements of each project. When used judiciously, these principles serve as a valuable guide, helping developers navigate the complexities of software design and create solutions that stand the test of time.

Scroll to Top