SOLID Principles

SRP
SRP is part of SOLID design principles. With the help of SRP, we can maintain clean and robust source code.
SRP states that only one actor can change the module. In most of the scenarios, the actor corresponds business stakeholder.
The module can be a class, set of functions, package or source code.
Example: To illustrate, let's delve into a common scenario within an e-commerce company concerning order processing. In this context, the company is tasked with order processing for customers, warehouses, and accounting functions. The following pseudo-code outlines the process of order handling:
Copy
class OrderProcessor {
public void processOrderForCustomer(...) { // Required for Customer Service
// ...
validateOrder(...);
// ...
}
public void processOrderForWarehouse(...) { // Required for Warehouse Management
// ...
validateOrder(...);
// ...
}
public void processOrderForAccounting(...) { // Required for Accounting
// ...
validateOrder(...);
// ...
}
private boolean validateOrder(...) {
// Validation logic common to all stakeholders
// ...
return isValid;
}
}
In OrderProcessor
class, we have a single class responsible for processing orders for three different business stakeholders: Customer Service, Warehouse Management, and Accounting. Each stakeholder's order processing method involves a shared private method called validateOrder
. While the class seems to have a cohesive responsibility for processing orders, it's potentially violating SRP because changes requested by different stakeholders might affect the overall class. For example, if there are changes to the validated order for warehouse management, these modifications might disrupt validation for the customer and account processing section
Solution - Refactoring with SRP: To address the SRP violation, we break down the OrderProcessor
class into 3 separate classes, each catering to a specific business stakeholder:
Copy
class CustomerServiceOrderProcessor {
public void processOrder(...) {
// ...
validateOrder(...);
// ...
}
private boolean validateOrder(...) {
// Validation logic specific to customer service orders
// ...
return isValid;
}
}
Copy
class WarehouseOrderProcessor {
public void processOrder(...) {
// ...
validateOrder(...);
// ...
}
private boolean validateOrder(...) {
// Validation logic specific to warehouse management orders
// ...
return isValid;
}
}
Copy
class AccountingOrderProcessor {
public void processOrder(...) {
// ...
validateOrder(...);
// ...
}
private boolean validateOrder(...) {
// Validation logic specific to accounting orders
// ...
return isValid;
}
}
By partitioning the business logic into distinct classes, each actor now possesses its class. Any alterations made to the validateOrder
no longer affect the logic of other business stakeholders.
Common Misconception
The assertion that "A class should have only 1 reason to change" is not universally accurate.
As there are situations where multiple reasons can prompt method modifications. Adhering to this principle could potentially result in an increased class count, which might impact the overall maintainability and readability of the codebase. Therefore, a balanced approach is required to achieve a suitable trade-off between single responsibility and code simplicity.
Conclusion: A class doesn't have to possess only a single public method or business method. While a class can encompass multiple business methods, it's essential that all these methods collectively serve the singular purpose of a specific business entity.
Open Close Principle
Open for extension but closed for modification.
OCP is part of SOLID design principles. OCP dictates that software entities should be open for extension but closed for modification. This principle ensures that existing, tested, and live classes remain unchanged while allowing new functionality to be added through extension rather than altering the existing codebase.
To achieve this principle, two approaches can be employed: inheritance and interfaces.
Using Inheritance: One way to adhere to the OCP is by utilizing inheritance. When an additional feature is needed, a new class can be created by inheriting from the original class and implementing the required functionalities. If modifications are necessary for the inherited methods in the child class, method overrides can be employed. This approach effectively decouples the parent and child classes, enabling seamless extension without altering the existing code.
However, there are certain disadvantages associated with this approach. In cases where shared methods among child classes require the same implementation as the parent class, maintaining consistency becomes challenging. Future changes may require revisiting the child class code, either by eliminating the override or duplicating the parent class implementation.
Using Interfaces: An alternative method to uphold the OCP is through the use of interfaces. The key advantage of interfaces lies in the absence of predefined method definitions in the parent class. This allows for the creation of diverse classes with distinct implementations. For instance, if the current implementation class becomes unsuitable or business logic evolves, a new class can be created that implements the interface.
By adhering to the Interface approach, changes and extensions can be seamlessly introduced without impacting existing classes. This enables the system to accommodate evolving requirements while maintaining stability and preventing modifications to live code.
Open-Closed Principle in E-commerce:
In the realm of e-commerce, the OCP ensures that the core functionalities of the system remain untouched while allowing for the introduction of new features or adjustments without altering existing, proven code. Let's explore this principle using both inheritance and interface-based approaches in an e-commerce scenario.
Using Inheritance:
Copy
// Original class representing a product in the e-commerce system
class Product {
private String name;
private double price;
public Product(String name, double price) {
this.name = name;
this.price = price;
}
public String getName() {
return name;
}
public double getPrice() {
return price;
}
}
// Extended class introducing a discount feature
class DiscountedProduct extends Product {
private double discount;
public DiscountedProduct(String name, double price, double discount) {
super(name, price);
this.discount = discount;
}
// Overriding the getPrice method to include discount
@Override
public double getPrice() {
return super.getPrice() - (super.getPrice() * discount);
}
}
Using Interfaces:
Copy
// Interface defining the contract for a product
interface Product {
String getName();
double getPrice();
}
// Original class implementing the Product interface
class StandardProduct implements Product {
private String name;
private double price;
public StandardProduct(String name, double price) {
this.name = name;
this.price = price;
}
@Override
public String getName() {
return name;
}
@Override
public double getPrice() {
return price;
}
}
// New class introducing a special offer
class SpecialOfferProduct implements Product {
private String name;
private double price;
private double specialDiscount;
public SpecialOfferProduct(String name, double price, double specialDiscount) {
this.name = name;
this.price = price;
this.specialDiscount = specialDiscount;
}
// Implementing the getPrice method to apply special discount
@Override
public double getPrice() {
return price - (price * specialDiscount);
}
@Override
public String getName() {
return name;
}
}
In this e-commerce scenario, the OCP is demonstrated through both approaches. In the inheritance-based approach, the DiscountedProduct
class extends the Product
class to add a discount feature. In the interface-based approach, the SpecialOfferProduct
class implements the Product
interface to introduce a special offer with a discount.
These examples showcase how new features can be seamlessly added to an e-commerce system without modifying existing, tested classes, by the OCP. This enables the system to adapt to changing requirements while maintaining the stability of existing functionalities.
Conclusion:
In summary, the Open-Closed Principle advocates for an extensible system where new functionalities can be added without modifying existing, tested classes. Both inheritance and interface-based approaches provide strategies for achieving this principle, with interfaces offering a stronger guarantee of flexibility and decoupling, ensuring the system's resilience to changes while facilitating individual class testing.
Liskov Substitution Principle (LSP)
LSP is one of the five SOLID principles of object-oriented programming, introduced by Barbara Liskov in a 1987 paper. The principle emphasizes the importance of maintaining the expected behavior of a program when using inheritance and polymorphism.
In simpler terms, the LSP states that if you have a base class (let's call it Class A) and a derived class (Class B) that inherits from it, you should be able to substitute an object of Class B wherever an object of Class A is expected, without causing any issues or breaking the program's behavior.
The property inherited from the parent class should hold real-world significance for the child class.
Mathematical Definition of LSP:
Let ϕ(x) be a property provable about objects x of type T. Then ϕ(y) should be true for objects y of type S where S is a subtype of T.
Explanation:
Here x is an instance of class T and y is an instance of class S. T is the parent class of S. In the definition, ϕ represents the property of object x. If all objects of class T have ϕ as the property then all the objects of type S should also have this property. Since it is the child class of T. In other words, all the instances of class S should satisfy ϕ
The LSP Analogy: If Animal is the parent class and possesses the talking property, all subclasses should also share the same property, which holds true. However, if we assume a subclass such as Snail, it inherits the talking property. Yet, in reality, Snails cannot communicate. Consequently, substituting an Animal object with a Snail object would fail the talking property. Thus, an LSP failure occurs when the inherited property lacks genuine significance.
Example:
In this example, we have a base class Animal
with a talk()
method, and three subclasses: Dog
, Cat
, and Snail
. Each subclass overrides the talk()
method to provide its specific behavior.
The main
method demonstrates the LSP by creating instances of the derived classes (Dog
, Cat
, and Snail
) and treating them as instances of the base class (Animal
), while also calculating the length of the string returned by the talk()
function.
Copy
class Animal {
public String talk() {
return "Animal is talking";
}
}
class Dog extends Animal {
@Override
public String talk() {
return "Dog is barking";
}
}
class Cat extends Animal {
@Override
public String talk() {
return "Cat is meowing";
}
}
class Snail extends Animal {
// Snails can't talk, so return null or return some Exception
@Override
public String talk() {
return null;
}
}
public class LSPExample {
public static void main(String[] args) {
Animal animal1 = new Dog();
Animal animal2 = new Cat();
Animal animal3 = new Snail();
String talk1 = animal1.talk();
String talk2 = animal2.talk();
String talk3 = animal3.talk();
System.out.println(talk1 + " (Length: " + talk1.length() + ")");
System.out.println(talk2 + " (Length: " + talk2.length() + ")");
System.out.println(talk3 + " (Length: " + talk3.length() + ")");
}
}
Note: The Snail class must compulsorily override the talk()
method since snails cannot talk. Therefore, it should either return null or throw an exception.
According to the LSP, if we replace an object of the base class (Animal
) with an object of the derived class (Snail
), we should still expect the same behavior and behavior-related properties. However, the Snail
class's talk()
method returns null
, which is a departure from the behavior of its parent class (Animal
).
In the LSPExample
class, when instances of Animal
, Dog
, Cat
, and Snail
are created and their talk()
methods are called, it is expected that each instance would return a string indicating the sound it makes. However, when talk()
is called on a Snail
instance, it returns null
, which is unexpected behavior and can potentially lead to errors or unexpected program behavior.
This violation becomes more apparent when we attempt to retrieve the length of the talk strings for each instance. Since the talk()
method of the Snail
class returns null
, attempting to retrieve the length of null
using the .length()
method results in a NullPointerException
. This further demonstrates that the Snail
class's behavior does not conform to the behavior expected from its base class and other derived classes, thereby violating the Liskov Substitution Principle.
Conclusion:
The LSP primarily focuses on the inheritance properties of the parent class. If a particular feature lacks real-world relevance for the child class, then the need arises to restructure the class inheritance.
Interface Segragation Principle (ISP)
Interfaces should be such that clients should not implement unnecessary functions they do not need
The Interface Segregation Principle (ISP) is one of the SOLID principles of object-oriented design that emphasizes the importance of creating focused and cohesive interfaces for classes. ISP states that interfaces should be designed in such a way that the classes that implement those interfaces do not have many unused functions.
Always remember to do the following for ISP
Design multiple smaller interfaces
One interface should not be handling a lot of responsibilities. Instead, multiple interfaces should be handling different responsibilities
Analogy: There are two different kinds of ATM machines – one for cash withdrawal and another for cash deposit. A single ATM can also offer both functionalities – withdrawal and deposit. However, there is an advantage to having two separate machines. People who want to deposit cash will not have to wait in the queue, where most are waiting for cash withdrawal, and vice versa if it is a combined machine. Therefore, having two separate machines ensures that the traffic is directed to the appropriate machine.
The analogy of separate cash withdrawal and deposit ATMs mirrors the Interface Segregation Principle in software design. Just as distinct ATMs cater to specific needs, segregated interfaces ensure classes implement only necessary functions. This segregation prevents unnecessary coupling, enhancing code clarity. Like separate ATMs, focused interfaces promote efficient utilization and minimize unwanted dependencies.
Example:
Imagine you're designing a notification system for a messaging application. You have various types of notifications: email notifications, SMS notifications, and push notifications. Initially, you might consider creating a single, large notification interface like this:
Copy
public interface Notification {
void sendEmail();
void sendSMS();
void sendPushNotification();
}
However, this violates the Interface Segregation Principle because different types of notifications require different functionalities, and not all classes that implement notifications will need to support all three methods. For instance, an SMS notification class wouldn't need to implement the sendEmail()
or sendPushNotification()
methods.
To adhere to the Interface Segregation Principle, you should break down the fat interface into smaller, more focused interfaces, each catering to a specific type of notification:
Copy
public interface EmailNotification {
void sendEmail();
}
public interface SMSNotification {
void sendSMS();
}
public interface PushNotification {
void sendPushNotification();
}
Now, the classes that implement these interfaces can selectively implement only the methods that are relevant to their functionality. For example, the SMSService
class would implement the SMSNotification
interface:
Copy
public class SMSService implements SMSNotification {
@Override
public void sendSMS() {
// Send SMS notification
}
}
Similarities with Single Responsibility Principle (SRP):
ISP and SRP share a common essence. While ISP revolves around interfaces, SRP delves into the realm of classes.
Conclusion:
Always strive to maintain simple and compact interfaces. If classes implementing these interfaces contain methods with empty definitions, it serves as a signal to consider restructuring the interface into smaller, more focused ones.
Dependency Inversion Principle (DIP)
The class should depend on interfaces rather than a concrete class
The Dependency Inversion Principle (DIP) is one of the five SOLID principles of object-oriented programming and design.
Inversion: Assume Class A relies on Class B, and Class B is reliant on Class C. By employing the principle of dependency inversion, the relationship transforms: Class A now relies on Interface B and Interface B is structured to be dependent on Interface C. Simultaneously, Class B's dependence shifts to Interface B, and Class C's reliance aligns with Interface C.
The goal of Dependency Inversion is to invert the direction of dependencies, so high-level modules/classes (like Class A) should not depend on low-level modules/classes (like Class B), but both should depend on abstractions (interfaces or abstract classes).
The subsequent assertions are applicable within the framework of DIP. These four statements convey identical meanings, and reviewing them could prove beneficial.
I. The class should depend on interfaces rather than a concrete class:
This statement emphasizes the importance of programming to interfaces rather than specific implementations. By relying on interfaces, a class becomes less tightly coupled to a particular implementation, making it easier to switch out implementations without affecting the client code.
Example:
Consider a scenario where you have a NotificationService
class that sends notifications to users via various methods (email, SMS, etc.). Instead of directly depending on concrete implementations like EmailNotifier
and SMSNotifier
, NotificationService
should depend on an interface, say INotifier
, which both EmailNotifier
and SMSNotifier
implement. This allows you to add new notification methods without modifying NotificationService
.
Copy
interface INotifier {
void sendNotification(String message);
}
class EmailNotifier implements INotifier {
public void sendNotification(String message) {
// Code to send email notification
}
}
class SMSNotifier implements INotifier {
public void sendNotification(String message) {
// Code to send SMS notification
}
}
class NotificationService {
private INotifier notifier;
public NotificationService(INotifier notifier) {
this.notifier = notifier;
}
public void sendNotification(String message) {
notifier.sendNotification(message);
}
}
II. High-level modules should not depend on low-level modules:
This statement suggests that both high-level and low-level modules should depend on abstractions, rather than depending on each other directly. High-level modules are the ones that deal with higher-level logic, while low-level modules handle implementation details.
Example:
Imagine an e-commerce system where the OrderProcessor
(high-level module) processes orders and needs to calculate the total price. Instead of directly depending on a Product
class (low-level module), should depend on an abstraction like ProductInterface
. This way, changes in the Product
implementation won't directly impact the OrderProcessor
.
Copy
interface ProductInterface {
double getPrice();
}
class Product implements ProductInterface {
private double price;
public Product(double price) {
this.price = price;
}
public double getPrice() {
return price;
}
}
class OrderProcessor {
private ProductInterface product;
public OrderProcessor(ProductInterface product) {
this.product = product;
}
public double calculateTotalPrice(int quantity) {
return product.getPrice() * quantity;
}
}
III. Abstraction should not depend on implementation:
This statement emphasizes that abstractions should not be tied to specific implementations. The definition of an interface or abstraction should be independent of how it's implemented.
Example:
If you define an INotifier
interface for the notification system, shouldn't have any knowledge of the concrete classes like EmailNotifier
or SMSNotifier
. Its purpose is to define the methods and contracts that notifiers should adhere to, leaving the actual implementation details to the concrete classes.
IV. Implementation should depend on abstraction:
This final statement underlines that the concrete implementations of modules or classes should be built upon the abstractions they depend on, rather than the other way around.
Example:
The EmailNotifier
and SMSNotifier
classes should implement the methods defined in the INotifier
interface. This ensures that the implementation is consistent with the contract established by the abstraction, allowing the high-level modules to interact with them interchangeably.
Conclusion: It is prudent to establish interfaces for modules or classes at each level of hierarchy. When a class aims to make use of objects from other classes, it is wise to employ interfaces tailored to those classes. It is essential that interfaces do not include methods that incorporate concrete classes in their signatures or prototypes.
Subscribe to my newsletter
Read articles from ishaq shaik directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
