0% found this document useful (0 votes)
2 views26 pages

SOLID Principles

The document discusses the SOLID principles, which are five key guidelines for software design that enhance maintainability, scalability, and readability. It emphasizes the importance of these principles in object-oriented design, providing examples and explanations for each principle, including the Single Responsibility Principle, Open/Closed Principle, and Liskov Substitution Principle. Adopting these principles leads to cleaner code and more professional software development practices.

Uploaded by

basavarajsamrat
Copyright
© All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
2 views26 pages

SOLID Principles

The document discusses the SOLID principles, which are five key guidelines for software design that enhance maintainability, scalability, and readability. It emphasizes the importance of these principles in object-oriented design, providing examples and explanations for each principle, including the Single Responsibility Principle, Open/Closed Principle, and Liskov Substitution Principle. Adopting these principles leads to cleaner code and more professional software development practices.

Uploaded by

basavarajsamrat
Copyright
© All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd

<codeWithAryan> Search articles, questions or tech-blogs...

Sign In

SOLID Principles
Design Principles | SOLID | DRY | KISS | YAGNI 🔥
codeWithAryan

Watch on

Topic Tags:
Design Patterns System Design LLD

🐈‍⬛ Github Codes Link: [Link]

Software development is a complex field that requires careful planning and design to ensure that applications are
maintainable, scalable, and easy to understand. One of the foundational guidelines for achieving these goals is the
set of SOLID principles. 🛠️📊

SOLID principles are five essential guidelines that enhance software design, making code more maintainable and
scalable. They include:
1. Single Responsibility Principle 🔑
2. Open/Closed Principle 🔓
3. Liskov Substitution Principle 🔄
4. Interface Segregation Principle 📏
5. Dependency Inversion Principle 🔗

Need for SOLID Principles in Object-Oriented Design:


Below are some of the main reasons why SOLID principles are important in object-oriented design:
• Scalability: Adding new features becomes straightforward. 📈
• Maintainability: Changes in one part of the system have minimal impact on others. 🔄
• Testability: Decoupled designs make unit testing easier. 🧪
• Readability: Clear separation of concerns improves code comprehension. 📚

Adopting SOLID principles is a key step toward mastering clean code and professional software development. 💡
While it takes practice to apply these principles effectively, the long-term benefits for both developers and businesses
are undeniable. 🚀

📦Single Responsibility Principle‍


This principle states that A class should have only one reason to change which means every class should have a
single responsibility or single job or single purpose. In other words, a class should have only one job or purpose
within the software system.

🌟Why it matters:
• Improves maintainability: Changes in one class do not affect unrelated parts.
• Enhances readability and reduces complexity.

💭Example:
Imagine a baker who is responsible for baking bread. The baker’s role is to focus on the task of baking bread,
ensuring that the bread is of high quality, properly baked, and meets the bakery’s standards.
However, if the baker is also responsible for managing the inventory, ordering supplies, serving customers, and
cleaning the bakery, this would violate the SRP. Each of these tasks represents a separate responsibility, and by
combining them, the baker’s focus and effectiveness in baking bread could be compromised.

Java

1 // Class with multiple responsibilities


2 class BreadBaker {
3 public
4 void bakeBread() { [Link]("Baking high-quality bread..."); }
5
6 public
7 void manageInventory() { [Link]("Managing inventory..."); }
8
9 public
10 void orderSupplies() { [Link]("Ordering supplies..."); }
11
12 public
13 void serveCustomer() { [Link]("Serving customers..."); }
14
15 public
16 void cleanBakery() { [Link]("Cleaning the bakery..."); }
17
18 public
19 static void main(String[] args) {
20 BreadBaker baker = new BreadBaker();
21 [Link]();
22 [Link]();
23 [Link]();
24 [Link]();
25 [Link]();
26 }
27 }

In the above code example, the BreadBaker class has multiple responsibilities: baking bread, managing inventory,
ordering supplies, serving customers, and cleaning the bakery. This violates the Single Responsibility Principle (SRP)
because the class has more than one reason to change. If any of these responsibilities need to be modified, the
BreadBaker class would need to be changed, making the code harder to maintain and understand. For instance, if
there is a change in the way inventory is managed, the BreadBaker class would need to be updated, even though the
change is unrelated to baking bread. This coupling of unrelated responsibilities increases the risk of introducing bugs
and makes the codebase more difficult to maintain.

Moreover, having multiple responsibilities in a single class reduces readability and increases complexity. It becomes
challenging for developers to understand the purpose of the class and to locate specific functionality within the code.
By combining different responsibilities, the class becomes a monolithic block of code that is harder to test and debug.
This complexity can lead to longer development times and increased chances of errors. Adhering to the SRP by
separating these responsibilities into distinct classes improves code maintainability, readability, and reduces the
likelihood of unintended side effects when making changes.
To adhere to the SRP, the bakery could assign different roles to different individuals or teams. For example, there
could be a separate person or team responsible for managing the inventory, another for ordering supplies, another for
serving customers, and another for cleaning the bakery.

Java

1 // Class for baking bread


2 class BreadBaker {
3 public
4 void bakeBread() { [Link]("Baking high-quality bread..."); }
5 }
6
7 // Class for managing inventory
8 class InventoryManager {
9 public
10 void manageInventory() { [Link]("Managing inventory..."); }
11 }
12
13 // Class for ordering supplies
14 class SupplyOrder {
15 public
16 void orderSupplies() { [Link]("Ordering supplies..."); }
17 }
18
19 // Class for serving customers
20 class CustomerService {
21 public
22 void serveCustomer() { [Link]("Serving customers..."); }
23 }
24
25 // Class for cleaning the bakery
26 class BakeryCleaner {
27 public
28 void cleanBakery() { [Link]("Cleaning the bakery..."); }
29 }
30
31 public class Bakery {
32 public
33 static void main(String[] args) {
34 BreadBaker baker = new BreadBaker();
35 InventoryManager inventoryManager = new InventoryManager();
36 SupplyOrder supplyOrder = new SupplyOrder();
37 CustomerService customerService = new CustomerService();
38 BakeryCleaner cleaner = new BakeryCleaner();
39 // Each class focuses on its specific responsibility
40 [Link]();
41 [Link]();
42 [Link]();
43 [Link]();
44 [Link]();
45 }
46 }

In the above example:


• Bread Baker Class:
Responsible solely for baking bread. This class focuses on ensuring the quality and standards of the bread without
being burdened by other tasks.
• Inventory Manager Class:
Handles inventory management, ensuring that the bakery has the right ingredients and supplies available.

• Supply Order Class:


Manages ordering supplies, ensuring that the bakery is stocked with necessary items.

• Customer Service Class:


Takes care of serving customers, providing a focused approach to customer interactions.

• Bakery Cleaner Class:


Responsible for cleaning the bakery, ensuring a hygienic environment.

By adhering to the SRP, the code becomes more maintainable and easier to understand. Changes in one class do
not affect unrelated parts, enhancing readability and reducing complexity. Each class has a clear and focused
responsibility, making the system more modular and easier to manage.

🚪Open/Closed Principle
This principle states that Software entities (classes, modules, functions, etc.) should be open for extension, but
closed for modification which means you should be able to extend a class behavior, without modifying it.

🌟Why it matters:
• Prevents breaking existing code.
• Encourages reusable components.

💭Example:
Suppose we have a Shape class that calculates the area of different shapes. Initially, it supports only circles and
rectangles. Adding a new shape, like a triangle, would require modifying the existing code.
The Open/Closed Principle states that software entities should be open for extension but closed for modification.
Here since we are modifying the existing code and not extending it, that is why the current approach is problematic:
Java

1 // Incorrect approach
2 class Shape {
3 private
4 String type;
5 public
6 double calculateArea() {
7 if ([Link]("circle")) {
8 // Circle area calculation
9 } else if ([Link]("rectangle")) {
10 // Rectangle area calculation
11 }
12 // Adding a triangle requires modifying this method
13 }
14 }

In the above code example, the Shape class has multiple responsibilities: calculating the area for different shapes.
This violates the Open/Closed Principle because adding a new shape, like a triangle, requires modifying the existing
code. This can lead to potential bugs and makes the code harder to maintain. When a new shape is introduced, the
calculateArea method must be updated to handle the new shape, which increases the risk of errors and makes the
code less flexible. This approach tightly couples the Shape class to the specific shapes it supports, reducing its
extensibility.

Moreover, this design makes it difficult to add new shapes without altering the existing codebase. Each time a new
shape is added, the Shape class must be modified, which can introduce unintended side effects and disrupt the
functionality of the existing shapes. This lack of modularity and extensibility makes the system harder to maintain and
evolve over time. By adhering to the Open/Closed Principle, we can create a more robust and maintainable system
where new shapes can be added without modifying the existing code, thus reducing the risk of bugs and improving
the overall design.

Instead, we can implement the Open/Closed principle correctly by creating and abstract class / Interface of Shape
and all the other classes will extend/inherit the methods of the Shape class efficiently following the Open/Closed
Principle.

Java

1 // Better approach following Open/Closed Principle


2 abstract class Shape {
3 abstract double calculateArea();
4 // We can also use an interface instead of an abstract class
5 }
6
7 class Circle extends Shape {
8 private
9 double radius;
10 @Override
11 public double calculateArea() {
12 return [Link] * radius * radius;
13 }
14 }
15
16 class Rectangle extends Shape {
17 private
18 double width;
19 private
20 double height;
21 @Override
22 public double calculateArea() {
23 return width * height;
24 }
25 }
26
27 // Adding a new shape without modifying existing code
28 class Triangle extends Shape {
29 private
30 double base;
31 private
32 double height;
33 @Override
34 public double calculateArea() {
35 return 0.5 * base * height;
36 }
37 }

📖 Explanation:
In the corrected code example, each shape class has a single responsibility:
• Base Class (Shape): This is an abstract base class with an abstract function calculateArea(). It defines a common
interface for all shapes.
• Circle Class: This class implements the calculateArea() logic for circles.
• Rectangle Class: This class implements the calculateArea() logic for rectangles.
• Triangle Class: This class implements the calculateArea() logic for triangles.

By adhering to the Open/Closed Principle, the code becomes more maintainable and easier to understand. Adding a
new shape, like a triangle, does not require modifying the existing code. Instead, we extend the Shape class by
creating a new class for the triangle. This approach prevents breaking existing code and encourages the creation of
reusable components.

🔄 Liskov Substitution Principle


The principle was introduced by Barbara Liskov in 1987 and according to this principle Derived or child classes must
be substitutable for their base or parent classes. This principle ensures that any class that is the child of a parent
class should be usable in place of its parent without any unexpected behavior.

🌟 Why it matters:
• Ensures reliability when using polymorphism.
• Avoids unexpected behaviors in subclass implementations.

💭 Example:
Consider a Vehicle class and a Car subclass. If the Vehicle class has a startEngine method, a subclass like Car
would work fine but if a subclass Bicycle is created, then it will violate LSP because bicycles do not have engines.
Java

1 // Problematic approach that violates LSP


2 class Vehicle {
3 public
4 void startEngine() {
5 // Engine starting logic
6 }
7 }
8
9 class Car extends Vehicle {
10 @Override public void startEngine() {
11 // Car-specific engine starting logic
12 }
13 }
14
15 class Bicycle extends Vehicle {
16 @Override public void startEngine() {
17 // Problem: Bicycles don't have engines!
18 throw new UnsupportedOperationException("Bicycles don't have engines");
19 }
20 }
21
22 public class Main {
23 public
24 static void main(String[] args) {
25 // Creating objects of different subclasses
26 Vehicle car = new Car();
27 Vehicle bicycle = new Bicycle();
28 // Using polymorphism
29 [Link]("Car:");
30 [Link](); // Output: Car engine started.
31 [Link]("
32 Bicycle:");
33 try {
34 [Link](); // Throws UnsupportedOperationException
35 } catch (UnsupportedOperationException e) {
36 [Link]("Error: " + [Link]());
37 }
38 }
39 }
In this problematic approach, the Vehicle class has a startEngine() method, which is appropriate for Car but not for
Bicycle. This violates the Liskov Substitution Principle (LSP) because the Bicycle class cannot be substituted / used
in place of the Vehicle class without causing unexpected behavior (i.e., throwing an exception). When a subclass
cannot fulfill the contract of its parent class, it leads to a breakdown in polymorphism, making the code less reliable
and predictable. The Bicycle class, when forced to implement the startEngine() method, must either provide a
meaningless implementation or throw an exception, both of which are undesirable outcomes.

This design flaw also makes the code less flexible and harder to extend. If new vehicle types are added that do not
have engines, they too would be forced to implement the startEngine() method, leading to further violations of the
LSP. This tight coupling between the Vehicle class and its subclasses reduces the modularity and reusability of the
code. By adhering to the LSP, we can create a more robust and maintainable system where subclasses can be used
interchangeably with their parent classes without causing unexpected behavior, ensuring that the code remains
flexible and easy to extend.

To properly implement the Liskov Substitution Principle, we can restructure the vehicle hierarchy through a more
refined abstraction. The base Vehicle class serves as the foundation, with two specialized abstract classes:
EngineVehicle and NonEngineVehicle. This segregation creates a clear distinction between motorized and non-
motorized transport.
Java

1 // Better approach following LSP


2 abstract class Vehicle {
3 // Common vehicle behaviors
4 public
5 void move() {
6 // Movement logic
7 }
8 }
9
10 abstract class EngineVehicle extends Vehicle {
11 public
12 void startEngine() {
13 // Engine starting logic
14 }
15 }
16
17 abstract class NonEngineVehicle extends Vehicle {
18 // No engine-related methods
19 }
20
21 class Car extends EngineVehicle {
22 @Override public void startEngine() {
23 // Car-specific engine starting logic
24 }
25 }
26
27 class Bicycle extends NonEngineVehicle {
28 // Bicycle-specific methods
29 // No need to implement engine-related methods
30 }
31
32 public class Main {
33 public
34 static void main(String[] args) {
35 // Using EngineVehicle
36 EngineVehicle car = new Car();
37 [Link](); // Output: Car-specific engine starting logic
38 [Link](); // Output: Movement logic
39
40
41 // Using NonEngineVehicle
42 NonEngineVehicle bicycle = new Bicycle();
43 [Link](); // Output: Movement logic
44 }
45 }

📖 Explanation:
In the corrected code example, the vehicle hierarchy is refined to distinguish between motorized and non-motorized
vehicles:
• Base Class (Vehicle): This is an abstract base class with common vehicle behaviors.
• EngineVehicle Class: This abstract class extends Vehicle and includes engine-related methods.
• NonEngineVehicle Class: This abstract class extends Vehicle and excludes engine-related methods.
• Car Class: This class extends EngineVehicle and implements the startEngine method.
• Bicycle Class: This class extends NonEngineVehicle and does not need to implement engine-related methods.

💥This design ensures that:


1. Each subtype fully satisfies the behavioral contract of its parent type
2. Client code can interact with either vehicle type without unexpected behavior

The inheritance hierarchy accurately models the real-world domain

🎛️ Interface Segregation Principle


This principle is the first principle that applies to Interfaces instead of classes in SOLID and it is similar to the Single
Responsibility principle. It states that do not force any client to implement an interface which is irrelevant to them.
Here your main goal is to focus on avoiding fat interface and give preference to many small client-specific interfaces.
You should prefer many client interfaces rather than one general interface and each interface should have a specific
responsibility.

🌟Why it matters:
• Reduces unnecessary dependencies.
• Simplifies implementation for specific use cases.

💭Example :
Consider a software system modeling various office equipment through a Machine interface that encompasses
multiple functionalities: printing, scanning, and faxing. This design presents a violation of the Interface Segregation
Principle when implementing a basic printer device.

The fundamental issue arises when a BasicPrinter class, which is designed solely for printing operations, must
implement the complete Machine interface. This forces the class to provide implementations for scan() and fax()
methods, despite these capabilities being outside its core functionality.

Java

1 // Problematic approach that violates ISP


2 interface Machine {
3 void print();
4 void scan();
5 void fax();
6 }
7
8 class AllInOnePrinter implements Machine {
9 @Override public void print() {
10 // Printing functionality
11 }
12 @Override public void scan() {
13 // Scanning functionality
14 }
15 @Override public void fax() {
16 // Fax functionality
17 }
18 }
19
20 class BasicPrinter implements Machine {
21 @Override public void print() {
22 // Printing functionality
23 }
24 @Override public void scan() {
25 // Problem: Basic printer can't scan!
26 throw new UnsupportedOperationException("Cannot scan");
27 }
28 @Override public void fax() {
29 // Problem: Basic printer can't fax!
30 throw new UnsupportedOperationException("Cannot fax");
31 }
32 }

In the above example, the BasicPrinter class is forced to implement methods for scanning and faxing, which it cannot
support. This violates the Interface Segregation Principle because the class is burdened with irrelevant methods,
leading to unnecessary complexity and potential runtime errors. When a class is required to implement methods that
it does not need, it results in a bloated interface, making the class more difficult to understand and maintain.
Additionally, the presence of unsupported methods can lead to runtime exceptions, as seen with the
UnsupportedOperationException thrown by the scan() and fax() methods in the BasicPrinter class.
This design flaw also makes the code less flexible and harder to extend. If new functionalities are added to the
Machine interface, all implementing classes, including those that do not require the new functionalities, must be
updated. This creates unnecessary dependencies and increases the risk of introducing bugs. By forcing classes to
implement irrelevant methods, the code becomes tightly coupled, reducing its modularity and reusability. Adhering to
the Interface Segregation Principle by creating smaller, more focused interfaces ensures that classes only implement
the methods they need, leading to a more robust and maintainable system.

To adhere to the Interface Segregation Principle, we can split the interface into smaller, more focused interfaces:
Java

1 // Better approach following ISP


2 interface Printer {
3 void print();
4 }
5
6 interface Scanner {
7 void scan();
8 }
9
10 interface FaxMachine {
11 void fax();
12 }
13
14 class BasicPrinter implements Printer {
15 @Override
16 public void print() {
17 // Printing functionality
18 }
19 }
20
21 class AllInOnePrinter implements Printer, Scanner, FaxMachine {
22 @Override
23 public void print() {
24 // Printing functionality
25 }
26 @Override
27 public void scan() {
28 // Scanning functionality
29 }
30 @Override
31 public void fax() {
32 // Fax functionality
33 }
34 }

Explanation:
In the corrected code example, the interfaces are segregated into smaller, more focused interfaces:
• Printer Interface: Defines the print() method.
• Scanner Interface: Defines the scan() method.
• FaxMachine Interface: Defines the fax() method.

By segregating the interfaces, we ensure that classes only implement methods that are relevant to their functionality.
The BasicPrinter class now only implements the Printer interface, avoiding unnecessary methods. The
AllInOnePrinter class implements all three interfaces, providing the full range of functionalities.

💥This design ensures that:


• Allows classes to implement only the interfaces they need: Classes are not burdened with irrelevant methods.
• Prevents classes from being forced to provide dummy implementations: Avoids unnecessary complexity and
potential runtime errors.
• Makes the system more flexible and maintainable: Smaller, focused interfaces lead to a more modular and
maintainable codebase.
• Follows the principle of 'interface cohesion': Each interface has a specific responsibility, leading to better cohesion
and separation of concerns.

By segregating interfaces, we ensure that classes only implement methods that are relevant to their functionality,
leading to a more robust and maintainable system.

🔌Dependency Inversion Principle


The Dependency Inversion Principle (DIP) is a principle in object-oriented design that 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.

In simpler terms, the DIP suggests that classes should rely on abstractions (e.g., interfaces or abstract classes)
rather than concrete implementations. This allows for more flexible and decoupled code, making it easier to change
implementations without affecting other parts of the codebase.

🌟Why it matters:
• Promotes decoupled architecture.
• Facilitates testing and maintainability.

💭Example :
Consider an enterprise e-commerce system where order processing requires various types of notifications to be sent
to customers, administrators, and inventory systems
Java
1 // Problematic approach that violates DIP
2 class EmailNotifier {
3 public void sendEmail(String message) {
4 // Configure SMTP
5 // Set up email templates
6 // Send email implementation
7 }
8 }
9
10 class OrderService {
11 private EmailNotifier emailNotifier;
12 private DatabaseLogger logger;
13 private InventorySystem inventory;
14 public OrderService() {
15 // Direct dependencies on concrete implementations
16 [Link] = new EmailNotifier();
17 [Link] = new DatabaseLogger();
18 [Link] = new InventorySystem();
19 }
20 public void placeOrder(Order order) {
21 // Process order
22 [Link](order);
23 [Link]("Order #" + [Link]() + " placed successfully");
24 [Link]("Order placed: " + [Link]());
25 }
26 }
📖Explanation :
The OrderService class has direct dependencies on concrete implementations like EmailNotifier, DatabaseLogger,
and InventorySystem. This design creates several challenges:

1. Tight coupling to specific implementations: The OrderService class is tightly coupled to the specific
implementations, making it difficult to change or replace them.

2. Difficulty in unit testing due to concrete dependencies: Testing the OrderService class becomes challenging
because it relies on concrete implementations.

3. Inflexibility when business requirements change: Any change in the notification method or logging mechanism
requires modifying the OrderService class.

4. Challenges in maintaining and modifying the system: The code becomes harder to maintain and modify due to the
tight coupling.

5. Difficulty in implementing different notification strategies for different markets or customer segments: The system
lacks flexibility to accommodate different notification methods.

Here's the improved design following DIP:


To adhere to the Dependency Inversion Principle, we can introduce abstractions and use dependency injection to
decouple the OrderService class from specific implementations.

Java

1 // Better approach following DIP


2 interface NotificationService {
3 void sendNotification(String message);
4 }
5
6 interface LoggingService {
7 void logMessage(String message);
8 void logError(String error);
9 }
10
11 interface InventoryService {
12 void updateStock(Order order);
13 boolean checkAvailability(Product product);
14 }
15
16 class EmailNotifier implements NotificationService {
17 @Override
18 public void sendNotification(String message) {
19 // Email specific implementation
20 }
21 }
22
23 class SMSNotifier implements NotificationService {
24 @Override
25 public void sendNotification(String message) {
26 // SMS specific implementation
27 }
28 }
29
30 class PushNotifier implements NotificationService {
31 @Override
32 public void sendNotification(String message) {
33 // Push notification specific implementation
34 }
35 }
36
37 class DatabaseLogger implements LoggingService {
38 @Override
39 public void logMessage(String message) {
40 // Database logging implementation
41 }
42 @Override
43 public void logError(String error) {
44 // Error logging implementation
45 }
46 }
47
48 class OrderService {
49 private final NotificationService notificationService;
50 private final LoggingService loggingService;
51 private final InventoryService inventoryService;
52 // Constructor injection of dependencies
53 public OrderService(NotificationService notificationService,
54 LoggingService loggingService, InventoryService inventoryService) {
55 [Link] = notificationService;
56 [Link] = loggingService;
57 [Link] = inventoryService;
58 }
59
60 public void placeOrder(Order order) {
61 try {
62 // Check inventory
63 if ([Link]([Link]())) {
64 // Process order
65 [Link](order);
66 // Send notification
67 [Link](
68 "Order #" + [Link]() + " placed successfully");
69 // Log success
70 [Link](
71 "Order processed successfully: " + [Link]());
72 }
73 } catch (Exception e) {
74 [Link](
75 "Error processing order: " + [Link]() + " - " + [Link]());
76 throw e;
77 }
78 }
79 }
80
81 // Usage with dependency injection
82 NotificationService emailNotifier = new EmailNotifier();
83 LoggingService logger = new DatabaseLogger();
84 InventoryService inventory = new WarehouseInventoryService();
85 OrderService orderService = new OrderService(emailNotifier, logger, inventory);

📖 Explanation:
In the improved design, we introduce interfaces for the notification, logging, and inventory services:
1. NotificationService Interface: Defines the sendNotification() method.
2. LoggingService Interface: Defines the logMessage() and logError() methods.
3. InventoryService Interface: Defines the updateStock() and checkAvailability() methods.

By using these interfaces, the OrderService class depends on abstractions rather than concrete implementations.
💥This design offers several benefits:
1. Flexibility: The system can easily accommodate new notification methods, logging mechanisms, or inventory
systems without modifying existing code.

2. Testability: Dependencies can be easily mocked for unit testing.

3. Maintainability: Each component has a single responsibility and can be modified independently.
4. Scalability: New implementations can be added without affecting existing code.
5. Configuration Flexibility: Different implementations can be injected based on runtime conditions or configuration.

You might also like