DEV Community

Saurabh Mahajan
Saurabh Mahajan

Posted on • Originally published at blog.saurabhmahajan.com on

S.O.L.I.D Principles - The Practical Guide

Welcome to our exploration of SOLID principlesa foundational set of design principles in software engineering that can elevate the quality and maintainability of your code. In the ever-evolving landscape of software development, writing code that is not only functional but also adaptable and maintainable is crucial. This is where SOLID principles come into play, offering a framework to guide developers in creating clean, flexible, and scalable software designs.

In this blog, we'll delve into the essence of each SOLID principle, understanding what they entail and why they matter. Whether you're a seasoned developer looking to refine your skills or a newcomer eager to grasp fundamental principles, this journey through SOLID principles promises to provide valuable insights and actionable knowledge. Let's embark on this exploration together and unlock the power of SOLID principles to elevate your software craftsmanship.


Introduction

SOLID principles are a set of design principles that aim to make software designs more understandable, flexible, and maintainable.

The SOLID acronym stands for:

  1. S - Single Responsibility Principle (SRP)

  2. O - Open/Closed Principle (OCP)

  3. L - Liskov Substitution Principle (LSP)

  4. I - Interface Segregation Principle (ISP)

  5. D - Dependency Inversion Principle (DIP)

These principles provide guidelines for writing clean, maintainable, and scalable code. By adhering to them, developers can create software systems that are easier to understand, extend, and modify.


History

SOLID principles were introduced by Robert C. Martin (also known as Uncle Bob) in the early 2000s and have since become a cornerstone of object-oriented design and programming. Robert C. Martin introduced these principles in his paper "Design Principles and Design Patterns," presented at the OOPSLA conference in 1995. They were further popularized in his book "Agile Software Development, Principles, Patterns, and Practices," published in 2002. Since then, they have become foundational concepts in software engineering and are often taught in computer science courses and used in software development practices worldwide.


S - Single Responsibility Principle (SRP)

Definition

A class should have only one reason to change.

Explanation

This principle suggests that a class should have only one responsibility or job. It should encapsulate one and only one aspect of functionality within the software. This principle promotes a modular approach to software development by encouraging developers to break down complex systems into smaller, more manageable components.

To understand SRP more thoroughly, let's delve into its key aspects:

  1. Responsibility : A responsibility in the context of SRP refers to a reason for a class to change. It can be defined as a cohesive set of tasks or functionalities that the class is responsible for.

  2. Reason to Change : A reason to change is any requirement or feature that might necessitate modifications to the code. By adhering to SRP, we aim to minimize the number of reasons for a class to change, thus reducing the likelihood of unintended side effects when modifications are made.

  3. Encapsulation : SRP is closely related to the concept of encapsulation, which is one of the core principles of object-oriented programming. Encapsulation involves bundling data and the methods that operate on that data into a single unit (i.e., a class). SRP suggests that this unit should have a single, well-defined responsibility.

  4. Cohesion : SRP encourages high cohesion within classes. Cohesion refers to the degree to which the elements of a module (e.g., a class) belong together. A class with high cohesion focuses on a single task or responsibility, making it easier to understand, maintain, and test.

  5. Separation of Concerns : SRP promotes the separation of concerns, which is a design principle aimed at separating a computer program into distinct sections, each addressing a separate concern. By separating concerns, we can manage complexity more effectively and improve maintainability.

  6. Benefits :

Example - Violation

Let's consider an example where the Single Responsibility Principle is violated in an Account controller that handles deposit, withdrawal, and money transfer operations. We'll combine authorization logic, validation logic, and database transaction logic within the controller.

import org.springframework.stereotype.Controller;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RequestParam;import org.springframework.web.bind.annotation.ResponseBody;@Controllerpublic class AccountController { private AccountService accountService; // Authorization and validation logic combined with business logic @PostMapping("/deposit") @ResponseBody public String deposit(@RequestParam("accountId") String accountId, @RequestParam("amount") double amount) { // Authorization logic if (!authorizeUser()) { return "Unauthorized"; } // Validation logic if (amount <= 0) { return "Invalid amount"; } // Database transaction logic if (accountService.deposit(accountId, amount)) { return "Deposit successful"; } else { return "Deposit failed"; } } // Similar methods for withdrawal and transfer private boolean authorizeUser() { // Authorization logic implementation return true; // Placeholder implementation }}
Enter fullscreen mode Exit fullscreen mode

In this example, the AccountController class is responsible for handling HTTP requests related to account operations. However, it is violating the Single Responsibility Principle by combining authorization logic, validation logic, and database transaction logic within the controller.

To correct this violation and adhere to the Single Responsibility Principle, we can separate these concerns into distinct classes:

  1. AuthorizationService: Responsible for handling authorization logic.

  2. ValidationService: Responsible for handling validation logic.

  3. AccountService: Responsible for handling database transactions related to account operations.

Example - Fix

Here's how we can refactor the code:

import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Service;@Servicepublic class AuthorizationService { public boolean authorizeUser() { // Authorization logic implementation return true; // Placeholder implementation }}

import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Service;@Servicepublic class ValidationService { public boolean validateAmount(double amount) { return amount > 0; }}

import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Service;@Servicepublic class AccountService { @Autowired private AccountRepository accountRepository; public boolean deposit(String accountId, double amount) { // Database transaction logic // Call methods from AccountRepository return true; // Placeholder implementation } // Similar methods for withdrawal and transfer}

import org.springframework.data.jpa.repository.JpaRepository;import org.springframework.stereotype.Repository;@Repositorypublic interface AccountRepository extends JpaRepository<Account, Long> { // Define methods for interacting with the account database}

import org.springframework.stereotype.Controller;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestParam;import org.springframework.web.bind.annotation.ResponseBody;@Controllerpublic class AccountController { @Autowired private AuthorizationService authorizationService; @Autowired private ValidationService validationService; @Autowired private AccountService accountService; @PostMapping("/deposit") @ResponseBody public String deposit(@RequestParam("accountId") String accountId, @RequestParam("amount") double amount) { if (!authorizationService.authorizeUser()) { return "Unauthorized"; } if (!validationService.validateAmount(amount)) { return "Invalid amount"; } if (accountService.deposit(accountId, amount)) { return "Deposit successful"; } else { return "Deposit failed"; } } // Similar methods for withdrawal and transfer}
Enter fullscreen mode Exit fullscreen mode

In this refactored version, we've separated the concerns into distinct classes: AuthorizationService, ValidationService, and AccountService. The AccountController now only orchestrates the flow of control by delegating the authorization and validation tasks to their respective services, and the database transaction logic is handled by the AccountService. This separation of concerns adheres to the Single Responsibility Principle, making the codebase more modular, maintainable, and testable.


O - Open/Closed Principle (OCP)

Definition

Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.

Explanation

OCP emphasizes that software entities should allow for extension without requiring changes to their source code. This is achieved through abstraction and polymorphism.

In simpler terms, this principle suggests that once a class (or module or function) is written and tested, it should not be modified to add new behavior or to alter existing behavior. Instead, it should be designed in a way that allows new functionalities to be added without changing its source code.

Here's a detailed explanation of the Open-Closed Principle:

  1. Open for Extension : This aspect implies that the behavior of a software entity can be extended without modifying its source code. In practical terms, this means that you should be able to add new functionality to a class or module by creating new code (such as subclasses, new modules, or new functions) rather than changing the existing code.

  2. Closed for Modification : This aspect implies that the existing source code of a software entity should not be changed. Once a class or module is developed, tested, and considered stable, it should remain untouched. Modifying existing code can introduce bugs and unintended consequences, especially in large systems.

To achieve the Open-Closed Principle, several design patterns and techniques can be applied:

  • Abstraction : The base class provides a common interface, while subclasses can override or extend specific behaviors.

  • Interfaces : Define interfaces or abstract classes that specify the behavior expected from different implementations. This allows new implementations to be added without modifying existing code.

  • Composition over Inheritance : Instead of using inheritance, use composition to build flexible and extensible systems. This involves creating classes that contain instances of other classes, allowing behavior to be composed dynamically.

  • Strategy Pattern : Define a family of algorithms, encapsulate each one as an object, and make them interchangeable. This allows the behavior of a class to vary independently from the class itself, promoting the open-closed principle.

  • Decorator Pattern : Attach additional responsibilities to an object dynamically. Decorators provide a flexible alternative to subclassing for extending functionality.

  • Dependency Injection : Instead of directly creating dependencies within a class, inject them from the outside. This allows different implementations to be provided at runtime, promoting flexibility and extensibility.

By following the Open-Closed Principle, you can create software that is easier to maintain, extend, and test. It encourages modular, reusable, and loosely coupled designs, which are essential for building scalable and adaptable systems.

Example - Violation

Suppose we have a AccessControl interface and two concrete implementations: BasicAccessControl and AdminAccessControl. They both provide methods to check access and perform some security-related operations.

interface AccessControl { boolean hasAccess(String userId);}class BasicAccessControl implements AccessControl { @Override public boolean hasAccess(String userId) { // Basic access control logic return true; }}class AdminAccessControl implements AccessControl { @Override public boolean hasAccess(String userId) { // Admin access control logic return true; } // New method added violating OCP public void logAccess(String userId) { // Log access details System.out.println("Access logged for user: " + userId); }}
Enter fullscreen mode Exit fullscreen mode

In this example, AdminAccessControl violates the Open-Closed Principle by adding a new method logAccess() to the class. If we continue adding new methods for different purposes, it would lead to modifying existing classes whenever new functionalities are introduced, which is against the principle.

Example - Fix

To adhere to the Open-Closed Principle, we can introduce a new class hierarchy for handling additional functionalities. We'll introduce an AccessControlLogger interface and concrete implementations for logging access details. This way, we can extend the behavior of AccessControl without modifying existing classes.

interface AccessControl { boolean hasAccess(String userId);}class BasicAccessControl implements AccessControl { @Override public boolean hasAccess(String userId) { // Basic access control logic return true; }}interface AccessControlLogger { void logAccess(String userId);}class BasicAccessControlLogger implements AccessControlLogger { @Override public void logAccess(String userId) { // Log basic access details System.out.println("Basic access logged for user: " + userId); }}class AdminAccessControl implements AccessControl, AccessControlLogger { @Override public boolean hasAccess(String userId) { // Admin access control logic return true; } @Override public void logAccess(String userId) { // Log admin access details System.out.println("Admin access logged for user: " + userId); }}
Enter fullscreen mode Exit fullscreen mode

Now, AdminAccessControl implements both AccessControl and AccessControlLogger, allowing it to provide access control and logging functionalities separately. This approach follows the Open-Closed Principle because we can add new functionalities (like logging) without modifying existing classes.


L - Liskov Substitution Principle (LSP)

Definition

A subclass should be substitutable for its superclass without causing errors or unexpected behavior.

Explanation

Key Points:

  1. Inheritance Hierarchy : The principle primarily deals with inheritance relationships, where a subclass inherits from a superclass. The subclass should extend or specialize the behavior of the superclass but not alter its fundamental contract or assumptions.

  2. Behavior Preservation : When substituting a subclass instance for a superclass instance, the client code should continue to function correctly. This means that the subclass must honor the same contracts, adhere to the same preconditions and postconditions, and respect the same invariants as the superclass.

  3. Type Contracts : The LSP emphasizes the importance of adhering to the contracts defined by the superclass. This includes method signatures, behavior expectations, and any constraints specified by the superclass. Subclasses can strengthen (but not weaken) these contracts.

  4. Design by Contract : The principle aligns with the broader concept of "design by contract," where classes explicitly state their preconditions, postconditions, and invariants. Subclasses are expected to adhere to and possibly extend these contracts, ensuring that they maintain the same level of reliability and predictability.

  5. Robustness and Extensibility : Adhering to the LSP enhances the robustness and extensibility of object-oriented systems. It allows for easy substitution of components, facilitates code reuse, and simplifies maintenance, as changes to subclasses don't break client code relying on superclass behavior.

Benefits:

  • Maintainability : Eases maintenance by allowing for easy substitution of objects.

  • Robustness : Ensures that changes to subclasses don't inadvertently break client code.

  • Code Reusability : Encourages code reuse through inheritance.

  • Design Clarity : Promotes clear interfaces and contracts between classes.

By adhering to the Liskov Substitution Principle, developers can create more flexible, robust, and maintainable object-oriented systems.

Example - Violation

Let's consider a scenario where we have a caching system with two implementations: a LocalCache and a NetworkCache. We'll demonstrate a violation of the Liskov Substitution Principle and then correct it.

class Cache { // Common cache methods public void put(String key, Object value) { // implementation } public Object get(String key) { // implementation return null; }}class LocalCache extends Cache { // Local cache specific methods and implementations}class NetworkCache extends Cache { // Network cache specific methods and implementations}
Enter fullscreen mode Exit fullscreen mode

Now, let's say the NetworkCache has to make a network call to fetch the data, but the get method in the Cache class is synchronous. To handle the network call, NetworkCache might need an asynchronous get method. In this case, NetworkCache cannot be substituted for Cache without affecting the behavior, violating LSP.

Example - Fix

interface Cache { void put(String key, Object value); Object get(String key);}class LocalCache implements Cache { @Override public void put(String key, Object value) { // Local cache specific implementation } @Override public Object get(String key) { // Local cache specific implementation return null; }}class NetworkCache implements Cache { @Override public void put(String key, Object value) { // Network cache specific implementation } @Override public Object get(String key) { // Asynchronous network call implementation // This might involve using callbacks, CompletableFuture, or similar mechanisms // This method might not return the actual value immediately but instead returns a Future or uses a callback return fetchDataAsynchronously(key); } private CompletableFuture<Object> fetchDataAsynchronously(String key) { // Simulated asynchronous network call return CompletableFuture.supplyAsync(() -> { // Actual network call to fetch data return fetchDataFromNetwork(key); }); } private Object fetchDataFromNetwork(String key) { // Simulated network data fetch return null; }}
Enter fullscreen mode Exit fullscreen mode

In this corrected version, both LocalCache and NetworkCache implement the Cache interface. The get method in NetworkCache returns a CompletableFuture representing the asynchronous result of the network call. This allows NetworkCache to be substituted for Cache without altering the behavior expected by client code, thus satisfying the Liskov Substitution Principle.


I - Interface Segregation Principle (ISP)

Definition

Clients should not be forced to depend on interfaces they do not use.

Explanation

The principle emphasizes the importance of designing software interfaces in a way that clients should not be forced to depend on methods they do not use. In simpler terms, ISP advocates for smaller, cohesive interfaces tailored to specific client requirements rather than large, monolithic ones.

At its core, ISP encourages developers to create specialized interfaces that contain only the necessary methods relevant to a specific client or group of clients. This prevents clients from being burdened with methods they don't need, which in turn promotes cleaner, more maintainable code.

Example:

Consider a scenario where you have an interface called Worker which has methods such as work(), eat(), and sleep(). Now, let's say you have two types of clients: Robot and Human. Robots don't eat or sleep, they only work. Humans, on the other hand, do all three tasks.

With a single Worker interface, both Robot and Human classes would have to implement all three methods. This violates the Interface Segregation Principle because the Robot class would be forced to implement methods it doesn't need.

By segregating the interfaces, you could have an Workable interface containing only the work() method, and a Eatable interface containing only the eat() method. Both Robot and Human classes would then implement only the interfaces relevant to them.

Benefits:

  1. Reduced Dependency : Clients are not forced to depend on methods they don't use, reducing unnecessary dependencies.

  2. Improved Cohesion : Interfaces become more focused and cohesive, leading to clearer and more maintainable code.

  3. Easier Testing : Smaller interfaces are easier to test because there are fewer methods to consider in each test case.

  4. Flexibility and Scalability : It facilitates easier modifications and extensions as new clients can be accommodated without affecting existing ones.

Implementation Guidelines:

  1. Identify Clients : Understand the different types of clients that will interact with your system.

  2. Group Methods : Group methods based on client requirements and create smaller, focused interfaces.

  3. Prefer Composition over Inheritance : Sometimes, ISP can be achieved more effectively through composition rather than inheritance.

  4. Refactor Existing Code : If necessary, refactor existing interfaces to adhere to the ISP.

The Interface Segregation Principle is a fundamental concept in OOP design that promotes flexibility, maintainability, and scalability in software systems. By focusing on the specific needs of clients and avoiding unnecessary dependencies, developers can create more robust and adaptable codebases. Following ISP, along with other SOLID principles, can lead to better-designed, more maintainable, and easier-to-understand software architectures.

Example - Violation

// MediaPlayer.javapublic interface MediaPlayer { String fetchAudioUrl(); String fetchVideoUrl();}// WindowsMediaPlayer.javapublic class WindowsMediaPlayer implements MediaPlayer { @Override public String fetchAudioUrl() { return "Fetch audio URL for Windows Media Player"; } @Override public String fetchVideoUrl() { return "Fetch video URL for Windows Media Player"; }}// VlcMediaPlayer.javapublic class VlcMediaPlayer implements MediaPlayer { @Override public String fetchAudioUrl() { return "Fetch audio URL for VLC Media Player"; } @Override public String fetchVideoUrl() { return "Fetch video URL for VLC Media Player"; }}// SpotifyMediaPlayer.javapublic class SpotifyMediaPlayer implements MediaPlayer { @Override public String fetchAudioUrl() { return "Fetch audio URL for Spotify Media Player"; } @Override public String fetchVideoUrl() { // Spotify doesn't support direct fetching of video URLs throw new UnsupportedOperationException("Spotify does not support fetching video URLs"); }}
Enter fullscreen mode Exit fullscreen mode

The issue here is that all implementations of the MediaPlayer interface are forced to implement both fetchAudioUrl() and fetchVideoUrl() methods, even if some players like SpotifyMediaPlayer don't support fetching audio URLs.

Example - Fix

To adhere to the Interface Segregation Principle, we should split the MediaPlayer interface into two separate interfaces, one for audio-related functionalities and another for video-related functionalities.

// AudioPlayer.javapublic interface AudioPlayer { String fetchAudioUrl();}// VideoPlayer.javapublic interface VideoPlayer { String fetchVideoUrl();}// WindowsMediaPlayer.javapublic class WindowsMediaPlayer implements AudioPlayer, VideoPlayer { @Override public String fetchAudioUrl() { return "Fetch audio URL for Windows Media Player"; } @Override public String fetchVideoUrl() { return "Fetch video URL for Windows Media Player"; }}// VlcMediaPlayer.javapublic class VlcMediaPlayer implements AudioPlayer, VideoPlayer { @Override public String fetchAudioUrl() { return "Fetch audio URL for VLC Media Player"; } @Override public String fetchVideoUrl() { return "Fetch video URL for VLC Media Player"; }}// SpotifyMediaPlayer.javapublic class SpotifyMediaPlayer implements VideoPlayer { @Override public String fetchVideoUrl() { return "Fetch video URL for Spotify Media Player"; }}
Enter fullscreen mode Exit fullscreen mode

By splitting the MediaPlayer interface into AudioPlayer and VideoPlayer, each class is now only forced to implement the methods that are relevant to it. This adheres to the Interface Segregation Principle, as clients are not forced to depend on interfaces they do not use. Now, SpotifyMediaPlayer doesn't need to implement the fetchAudioUrl() method, thus preventing it from having to throw an UnsupportedOperationException.


D - Dependency Inversion Principle (DIP)

Definition

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.

Explanation

The principle suggests that high-level modules (which contain the more abstract and policy-related aspects of the application) should not depend on low-level modules (which contain the more detailed implementation details). Instead, both should depend on abstractions, which can be interfaces or abstract classes. This decouples modules and makes the code more flexible and easier to maintain.

At its core, the Dependency Inversion Principle suggests two main things:

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

  2. Abstractions should not depend on details. Details should depend on abstractions.

Let's break down these concepts:

  1. High-level modules should not depend on low-level modules : This means that classes or modules that implement high-level policy or logic should not depend on the details of low-level components, such as specific implementations or concrete classes. Instead, both high-level and low-level modules should depend on abstractions. For example, rather than a high-level module directly instantiating or depending on a specific database class, it should depend on an interface or an abstract class that defines the behavior expected from the database component. This promotes decoupling and allows for easier substitution of components.

  2. Abstractions should not depend on details : This suggests that interfaces or abstract classes (abstractions) should define the behavior or contract that components adhere to, without depending on the specific implementation details of those components. In other words, the high-level policy or logic should not be affected by changes to the low-level details. This allows for greater flexibility and extensibility in the system. For example, if you have an interface for a logging service, it should define methods like logInfo, logError, etc., without specifying how those methods are implemented. Concrete logging implementations can then adhere to this interface, allowing for easy swapping between different logging mechanisms without affecting the rest of the codebase.

By adhering to the Dependency Inversion Principle, you create a more modular and flexible design where components can be easily replaced or extended without impacting other parts of the system. It also facilitates easier testing since components can be easily mocked or replaced with stubs or fakes. Overall, DIP promotes a design that is easier to maintain, extend, and understand.

Example - Violation

Initially, the NotificationService directly depends on the concrete implementation of the EmailService.

// High-level moduleclass NotificationService { private EmailService emailService; public NotificationService() { this.emailService = new EmailService(); } public void sendNotification(String message) { emailService.sendEmail("user@example.com", message); }}// Low-level moduleclass EmailService { public void sendEmail(String recipient, String message) { // Logic to send email System.out.println("Email sent to " + recipient + ": " + message); }}public class Main { public static void main(String[] args) { NotificationService notificationService = new NotificationService(); notificationService.sendNotification("Hello, this is a notification!"); }}
Enter fullscreen mode Exit fullscreen mode

Example - Fix

To adhere to the Dependency Inversion Principle, we'll introduce an abstraction (interface) NotificationProvider that both NotificationService, EmailService, and SMSService will depend on. This way, NotificationService won't depend directly on the concrete implementations of these services.

// Abstractioninterface NotificationProvider { void sendNotification(String recipient, String message);}// Low-level moduleclass EmailService implements NotificationProvider { public void sendNotification(String recipient, String message) { // Logic to send email System.out.println("Email sent to " + recipient + ": " + message); }}// Low-level moduleclass SMSService implements NotificationProvider { public void sendNotification(String recipient, String message) { // Logic to send SMS System.out.println("SMS sent to " + recipient + ": " + message); }}// High-level moduleclass NotificationService { private NotificationProvider notificationProvider; public NotificationService(NotificationProvider notificationProvider) { this.notificationProvider = notificationProvider; } public void sendNotification(String recipient, String message) { notificationProvider.sendNotification(recipient, message); }}public class Main { public static void main(String[] args) { NotificationProvider emailService = new EmailService(); // Dependency injection NotificationProvider smsService = new SMSService(); // Dependency injection NotificationService emailNotificationService = new NotificationService(emailService); emailNotificationService.sendNotification("user@example.com", "Hello, this is an email notification!"); NotificationService smsNotificationService = new NotificationService(smsService); smsNotificationService.sendNotification("1234567890", "Hello, this is an SMS notification!"); }}
Enter fullscreen mode Exit fullscreen mode

In this refactored version, both EmailService and SMSService implement the NotificationProvider interface. This allows the NotificationService to be decoupled from specific implementations of notification providers and to depend on abstractions. This design adheres to the Dependency Inversion Principle, promoting flexibility and easier maintenance of the codebase.


Conclusion

In conclusion, SOLID principles serve as guiding beacons in the vast sea of software development, illuminating the path to creating robust, maintainable, and adaptable codebases. By adhering to these principlesSingle Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversiondevelopers can foster codebases that are easier to understand, extend, and modify.

As we wrap up our exploration of SOLID principles, it's essential to recognize their significance in shaping not only individual code modules but entire software ecosystems. By embracing SOLID principles, developers empower themselves to write code that not only solves immediate problems but also stands the test of time, evolving gracefully as requirements change and technologies advance.

So, whether you're embarking on a new project or refactoring existing code, remember the SOLID principles as your guiding compass. Let them steer you towards software designs that are not only elegant but also resilient in the face of change. Together, let's build a future where SOLID principles are more than just principlesthey're the foundation upon which exceptional software is built.

Top comments (0)