DEV Community

Cover image for SOLID Principles in object-oriented design
Muzammil
Muzammil

Posted on

SOLID Principles in object-oriented design

SOLID is an acronym that represents five design principles aimed at enhancing the understanding, flexibility, and maintainability of object-oriented designs. While these principles were initially applied to object-oriented design, they can also serve as a foundational philosophy for other methodologies, such as agile development or adaptive software development. By following SOLID principles, developers can create more modular, changeable, and error-resistant code.

Adapted from source: Wikipedia.

The SOLID principles are a set of five guidelines that help software developers create more understandable, flexible, and maintainable object-oriented systems. They were introduced by Robert C. Martin, also known as "Uncle Bob," and have since become a core foundation for writing clean and scalable code. Here are the 5 principles we’ll cover:

  1. Single-responsibility Principle
  2. Open-closed Principle
  3. Liskov Substitution Principle
  4. Interface Segregation Principle
  5. Dependency Inversion Principle

Next, we will explore how to implement the SOLID principles and compare code before and after applying these principles.

Single Responsibility Principle (SRP)

The Single Responsibility Principle (SRP) is one of the five SOLID principles of object-oriented design and arguably one of the most fundamental. It states that a class should have only one reason to change, meaning it should have only one job or responsibility. This principle encourages the creation of classes that are focused and concise, making the system easier to understand, maintain, and extend.

Why SRP Matters ?
The core idea behind the Single Responsibility Principle (SRP) is to keep classes focused and cohesive. By ensuring that each class is responsible for just one aspect of the functionality, we can reduce its complexity. This approach offers several benefits:

  • Easier Maintenance: A class with a single responsibility is easier to understand and modify. Changes are less likely to inadvertently affect other parts of the system since the class has a clear and narrow focus.
  • Improved Reusability: When classes are designed to do one thing well, they become more versatile. You can reuse these focused classes in different parts of your application or even in entirely different projects.
  • Enhanced Testability: Testing becomes simpler with small, single-purpose classes. They have fewer dependencies and less complex behavior, making them easier to set up and verify.

Spotting SRP Violations
Sometimes, classes can become overburdened, trying to handle too many things at once. Here are a few signs that a class might be violating SRP:

  • Multiple Reasons to Change: If a class has more than one reason to change, it's likely taking on too many responsibilities.
  • Unrelated Methods or Properties: When a class contains methods or properties that aren't directly related to each other, it might be trying to do too much.
  • Vague or Overly Descriptive Names: If you need multiple words to describe what a class does (e.g., UserManager handling user data, authentication, and logging), it's a clue that the class might be violating SRP.

Image description

Example Without SRP
Let's consider a class that manages user data and also handles sending notifications:

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

    // Methods to get and set user data
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }

    // Method to send an email notification
    public void sendEmailNotification(String message) {
        // Code to send an email
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example, the User class has two responsibilities:

  • Managing user data.
  • Sending email notifications.

If we need to change the way email notifications are sent, we have to modify the User class, which isn't related to managing user data.

Applying SRP
To adhere to SRP, we should split this class into two separate classes, each with a single responsibility:

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

    // Methods to get and set user data
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
}

public class EmailService {
    public void sendEmail(String emailAddress, String message) {
        // Code to send an email
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, the User class is solely responsible for managing user data, and the EmailService class handles sending email notifications. This separation makes the code more maintainable and testable.

When to Apply SRP
While SRP is a powerful principle, it's important not to take it to extremes. Over-separating responsibilities can lead to an excessive number of tiny classes, which might make the codebase harder to navigate. The goal is to find a balance where each class has a clear, single responsibility that makes sense within the context of the application.

Benefits of SRP

  • Reduced Complexity: Each class is smaller and more focused.
  • Better Organization: Classes are grouped logically based on their responsibilities.
  • Simpler Changes: Modifying one responsibility in the system is less likely to impact unrelated parts.
  • Improved Collaboration: Different team members can work on different aspects of the system without interfering with each other's work.

Open-Closed Principle (OCP)

The Open-Closed Principle (OCP) is the second principle in the SOLID framework of object-oriented design. It states that a software module (such as a class, module, or function) should be "open for extension, but closed for modification." In simpler terms, this means that the behavior of a module can be extended without modifying its existing code. This principle aims to make the codebase more flexible, scalable, and resilient to changes.

Why is OCP Important?
The Open-Closed Principle (OCP) is crucial because it allows software to adapt and evolve without risking the stability of existing, well-tested code. When you follow OCP, you can introduce new features or tweak existing behavior without having to alter the core logic of a class or module. This results in several key benefits:

  • Reduced Risk of Bugs: By not modifying existing code, you significantly lower the risk of introducing new bugs or breaking existing functionality.
  • Easier Maintenance: When your code is designed with extension in mind, adding new features or making changes becomes much simpler and more intuitive.
  • Enhanced Flexibility: Systems that adhere to OCP are more flexible and can easily accommodate new requirements without major rewrites or restructuring.

How to Implement OCP
Implementing the Open-Closed Principle often involves using abstraction and polymorphism. Here are a few ways to do it effectively:

  • Abstract Classes and Interfaces: Start by defining abstract classes or interfaces that outline general behaviors. Then, create concrete implementations that extend or implement these abstractions. This allows you to add new functionality simply by adding new subclasses.
  • Composition Over Inheritance: Instead of always relying on inheritance to extend functionality, use composition. This means building new features by combining objects rather than altering existing classes.
  • Design Patterns: Many design patterns are designed to facilitate OCP, like Strategy, Decorator, and Factory patterns. These patterns provide well-established ways to make your code more extensible.

Example Without OCP
Let's consider a simple example where a Shape class calculates the area of different shapes. Here's how it might look without adhering to OCP:

public class Shape {
    public enum ShapeType { CIRCLE, RECTANGLE }

    private ShapeType shapeType;
    private double radius;
    private double width;
    private double height;

    public Shape(ShapeType shapeType) {
        this.shapeType = shapeType;
    }

    public double calculateArea() {
        switch (shapeType) {
            case CIRCLE:
                return Math.PI * radius * radius;
            case RECTANGLE:
                return width * height;
            default:
                throw new IllegalArgumentException("Unknown shape type");
        }
    }

    // Getters and setters for radius, width, and height
}
Enter fullscreen mode Exit fullscreen mode

In this example, if you need to add a new shape (e.g., a triangle), you have to modify the Shape class. This violates OCP because the class isn't closed for modification.

Applying OCP
To adhere to the Open-Closed Principle, we can refactor this code to use an abstract class or interface:

public abstract class Shape {
    public abstract double calculateArea();
}

public class Circle extends Shape {
    private double radius;

    public Circle(double radius) {
        this.radius = radius;
    }

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

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

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

    @Override
    public double calculateArea() {
        return width * height;
    }
}
Enter fullscreen mode Exit fullscreen mode

With this refactoring:

The Shape class is now open for extension. You can add new shapes (e.g., Triangle, Square) by creating new subclasses that implement the calculateArea method.
The Shape class is closed for modification. You don't need to modify existing classes to add new shapes.

Benefits of OCP

  • Scalability: Easily add new features without changing existing code, making the software more scalable.
  • Maintainability: By reducing the need to alter existing code, you lower the risk of introducing bugs and make the codebase more maintainable.
  • Reusability: Abstract classes and interfaces encourage the reuse of code, as they define a contract that different classes can implement.

Liskov Substitution Principle (LSP)

The Liskov Substitution Principle (LSP) is the third principle in the SOLID design principles and was introduced by computer scientist Barbara Liskov in 1987. The principle states that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. In other words, if a class S is a subclass of T, then objects of type T should be replaceable with objects of type S without altering the desirable properties of the program, such as correctness and task completion.

Why LSP is Important
LSP is crucial for ensuring that a system remains flexible and maintainable. It ensures that a subclass can stand in for its superclass without causing unexpected behavior. Here are some key reasons why LSP is important:

  • Code Reliability: LSP ensures that derived classes extend the behavior of a base class without changing its fundamental behavior. This reliability makes your code more predictable and reduces bugs.
  • Ease of Maintenance: If subclasses can be used interchangeably with their base classes, it's easier to add new subclasses or modify existing ones without worrying about breaking the system.
  • Improved Reusability: Proper adherence to LSP allows classes to be more reusable since they can be swapped with other classes without side effects.

Understanding LSP
To understand LSP better, consider the following points:

  • Behavioral Substitutability: Subclasses should be substitutable for their base classes. This means that a subclass should be able to fulfill the expectations of its superclass without altering its core functionality.
  • Method Consistency: Subclasses should override methods in a way that doesn't violate the expectations of the superclass. This includes not throwing unexpected exceptions or altering return values in a way that would be unexpected to the users of the superclass.
  • No Strengthened Preconditions: A subclass should not strengthen the preconditions of a method. In other words, it shouldn't require more specific conditions than the superclass method.
  • No Weakened Postconditions: Conversely, a subclass should not weaken the postconditions of a method. It should fulfill at least the same guarantees as the superclass method.

Example Without LSP
Let's look at an example that violates LSP. Consider a base class Rectangle and a derived class Square:

public class Rectangle {
    private int width;
    private 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

At first glance, it might seem logical for Square to extend Rectangle, but this actually violates LSP. If we replace Rectangle with Square, the behavior of the setWidth and setHeight methods changes. Here's why:

public static void resize(Rectangle rect) {
    rect.setWidth(5);
    rect.setHeight(10);
    assert(rect.getArea() == 50); // This fails for Square
}
Enter fullscreen mode Exit fullscreen mode

Applying LSP Correctly
To adhere to LSP, we need to design the classes so that the subclass can stand in for the superclass without altering the expected behavior. In this case, a Square isn't really a type of Rectangle because it has different constraints. Instead, they should both inherit from a more general shape class or interface:

public interface Shape {
    int getArea();
}

public class Rectangle implements Shape {
    private int width;
    private int 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 void setSide(int side) {
        this.side = side;
    }

    @Override
    public int getArea() {
        return side * side;
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, both Rectangle and Square can be used interchangeably without violating LSP because they share a common interface that defines the behavior we expect.

Benefits of LSP

  • Consistency: Ensures consistent behavior across base and derived classes.
  • Robust Code: Reduces bugs and unexpected behavior by maintaining the integrity of class hierarchies.
  • Flexible Design: Makes your code more flexible and reusable by allowing polymorphism without side effects.

Interface Segregation Principle (ISP)

The Interface Segregation Principle (ISP) is the fourth principle in the SOLID design principles. It suggests that a client should not be forced to implement interfaces they don't use. In simpler terms, it's better to have many small, specific interfaces rather than a large, general-purpose one. This principle promotes creating more granular and focused interfaces that are tailored to the specific needs of clients.

Why ISP Matters
The Interface Segregation Principle plays a key role in building systems that are both adaptable and maintainable. By adhering to ISP, you can significantly enhance the quality of your codebase. Here's how ISP makes a difference:

  • Simplified Code: Focused interfaces ensure that classes only need to implement the methods they actually require. This reduces complexity and makes the code more understandable.
  • Greater Flexibility: When interfaces are smaller and more specialized, you can modify or extend parts of the system without unintended side effects on unrelated components.
  • Improved Maintainability: When changes occur, having specific contracts in the form of small interfaces ensures that modifications in one area of the system don't cascade into unrelated areas, making the system easier to maintain.
  • Reduced Side Effects: Large interfaces often force classes to include unnecessary methods, which can lead to unexpected behavior and increase the complexity of testing. ISP helps prevent this by promoting minimal and focused interfaces.

Understanding ISP in Practice
To effectively apply ISP, keep the following in mind:

  • Focus on Granularity: Design interfaces with specific client needs in mind. If an interface is too broad, encompassing multiple responsibilities, it's a sign that it should be split into smaller, more focused interfaces.
  • Separation of Concerns: Each interface should encapsulate a distinct set of responsibilities. This ensures that a class only interacts with the parts of an interface that are relevant to its functionality.
  • Avoid "Fat" Interfaces: A "fat" interface is one that has too many methods, many of which might not be relevant to all implementing classes. Instead, aim for "skinny" interfaces that expose only the necessary functionality.

Example Without ISP
Let's look at an example that violates ISP. Consider an interface Printer that has multiple methods for different types of printing devices:

public interface Printer {
    void print(Document document);
    void scan(Document document);
    void fax(Document document);
    void printDuplex(Document document);
}
Enter fullscreen mode Exit fullscreen mode

Now, if we have a SimplePrinter class that only supports printing, it still has to implement all methods of the Printer interface:

public class SimplePrinter implements Printer {
    @Override
    public void print(Document document) {
        // Implementation for printing
    }

    @Override
    public void scan(Document document) {
        throw new UnsupportedOperationException("Scan not supported");
    }

    @Override
    public void fax(Document document) {
        throw new UnsupportedOperationException("Fax not supported");
    }

    @Override
    public void printDuplex(Document document) {
        throw new UnsupportedOperationException("Duplex printing not supported");
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example, SimplePrinter is forced to implement methods it doesn't support, which leads to the use of UnsupportedOperationException. This is a clear violation of ISP as SimplePrinter should not be forced to implement functionalities it doesn't need.

Applying ISP Correctly
To adhere to ISP, we should split the Printer interface into smaller, more specific interfaces:

public interface Printer {
    void print(Document document);
}

public interface Scanner {
    void scan(Document document);
}

public interface Fax {
    void fax(Document document);
}

public interface DuplexPrinter {
    void printDuplex(Document document);
}
Enter fullscreen mode Exit fullscreen mode

Now, the SimplePrinter class only implements the Printer interface, avoiding unnecessary methods:

public class SimplePrinter implements Printer {
    @Override
    public void print(Document document) {
        // Implementation for printing
    }
}
Enter fullscreen mode Exit fullscreen mode

If we have a more advanced device, like a MultiFunctionPrinter, it can implement multiple interfaces:

public class MultiFunctionPrinter implements Printer, Scanner, Fax, DuplexPrinter {
    @Override
    public void print(Document document) {
        // Implementation for printing
    }

    @Override
    public void scan(Document document) {
        // Implementation for scanning
    }

    @Override
    public void fax(Document document) {
        // Implementation for faxing
    }

    @Override
    public void printDuplex(Document document) {
        // Implementation for duplex printing
    }
}
Enter fullscreen mode Exit fullscreen mode

This way, each class only implements the methods it actually needs, adhering to ISP.

Benefits of ISP

  • High Cohesion: By splitting interfaces into smaller, related groups of methods, we ensure high cohesion within the interfaces.
  • Low Coupling: Classes are only coupled to the interfaces they actually use, which reduces dependencies and increases modularity.
  • Easier Refactoring: Since interfaces are more focused, changing one part of the system is less likely to affect other parts.
  • Enhanced Reusability: Small, specific interfaces are more reusable and can be easily combined in different classes.

Dependency Inversion Principle (DIP)

The Dependency Inversion Principle (DIP) is the fifth and final principle in the SOLID design principles. It addresses the issue of tight coupling between different modules in a system, promoting a design that is more flexible, testable, and scalable. According to DIP, high-level modules should not depend on low-level modules. Instead, both should depend on abstractions. Furthermore, these abstractions should not depend on details, but the details should depend on abstractions.

Breaking Down the Dependency Inversion Principle (DIP)
DIP might sound a bit technical at first, but its core idea is simple and powerful. Let's break it down:

  • High-Level Modules: Think of these as the strategic part of your application—the ones that make crucial decisions and drive the main logic.
  • Low-Level Modules: These are the operational parts, handling the specifics like database access, file management, or network communication.
  • Abstractions: Here’s the bridge between the two. Abstractions are like contracts (interfaces or abstract classes) that define how the high-level and low-level modules interact. They ensure that high-level modules aren't directly tied to the specifics of low-level implementations.

Why Should You Care About DIP?
DIP is more than just a fancy principle; it's a key player in making your codebase more robust and adaptable. Here’s why it's worth implementing:

  • Reduced Coupling: When high-level modules depend on abstractions instead of concrete implementations, the system becomes more flexible. This means you can make changes or add features with less risk of breaking things.
  • Enhanced Testability: Abstractions make it easier to replace real components with mock objects during testing. This simplifies unit tests and makes them more reliable since you can isolate parts of your code.
  • Improved Maintainability: By decoupling high-level modules from the nitty-gritty details of low-level modules, you make it easier to maintain and update your code. For example, switching from one database to another won’t require a complete overhaul of your high-level logic.
  • Supports Dependency Injection: DIP goes hand-in-hand with dependency injection, a pattern where dependencies are injected into a class rather than hard-coded. This leads to more modular and loosely-coupled code.

Example Without DIP
Consider a UserService class that directly depends on a UserRepository class to fetch user data:

public class UserRepository {
    public User getUserById(int id) {
        // Code to fetch user from database
    }
}

public class UserService {
    private UserRepository userRepository;

    public UserService() {
        this.userRepository = new UserRepository();
    }

    public User getUser(int id) {
        return userRepository.getUserById(id);
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example, UserService directly depends on the concrete implementation of UserRepository. If we decide to change the way users are stored (e.g., switch from a database to an external API), we have to modify the UserService class, which is tightly coupled to UserRepository.

Refactoring to Apply DIP
To adhere to the Dependency Inversion Principle, we need to introduce an abstraction that both UserService and UserRepository will depend on:

  • Define an Interface (Abstraction)
public interface UserRepository {
    User getUserById(int id);
}
Enter fullscreen mode Exit fullscreen mode
  • Implement the Interface
public class DatabaseUserRepository implements UserRepository {
    @Override
    public User getUserById(int id) {
        // Code to fetch user from database
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Modify UserService to Depend on the Abstraction
public class UserService {
    private UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public User getUser(int id) {
        return userRepository.getUserById(id);
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Using Dependency Injection Now, we can use dependency injection to provide the concrete implementation of UserRepository when creating a UserService instance:
UserRepository userRepository = new DatabaseUserRepository();
UserService userService = new UserService(userRepository);
Enter fullscreen mode Exit fullscreen mode

By following DIP, UserService now depends on the UserRepository interface, not a specific implementation. This makes UserService more flexible and easier to modify or test.

Benefits of DIP

  • Flexibility: High-level modules are not tightly bound to low-level modules, making it easier to change or extend functionality.
  • Scalability: Adding new features or changing implementations becomes more manageable without altering the core business logic.
  • Testability: By depending on abstractions, it's easier to use mock implementations in unit tests, resulting in more reliable and isolated testing.

Top comments (0)