Software engineers can become better by writing good codes and the basis for this is 𝗦𝗢𝗟𝗜𝗗 𝗣𝗿𝗶𝗻𝗰𝗶𝗽𝗹𝗲𝘀. This can unlock many better career opportunities for you as well, so understand and master SOLID Principles with this article.

Software engineers can become better by writing good codes and the basis for this is 𝗦𝗢𝗟𝗜𝗗 𝗣𝗿𝗶𝗻𝗰𝗶𝗽𝗹𝗲𝘀. This can unlock many better career opportunities for you as well, so understand and master SOLID Principles with this article.
SOLID principles for software design were given by Robert J. Martin (Uncle Bob) and Michael Feathers. Promote clean, maintainable, and testable code.
It states that a class should have only one reason to change, meaning it should have a single responsibility.
Suppose we have an Email Sender class responsible for sending emails.
The Email Sender class handles email composition, user authentication, and email sending.
We refactor the class to split responsibilities.
Improved Maintainability: Changes to one responsibility won’t affect the others.
Better Readability: Easier to understand each component’s role.
Efficient Testing: Isolating components simplifies unit testing.
It suggests that software entities (classes, modules) should be open for extension but closed for modification. New functionality should be incorporable without requiring modifications to the existing code within a class.
Modifying existing, well-tested code introduces the risk of introducing bugs, which should be avoided whenever possible.
Imagine you have a system that calculates the area of various shapes, such as rectangles and circles. You want to follow the OCP to allow for easy extension with new shapes without modifying the existing code.
Start with an abstract Shape class representing the common properties and methods of all shapes:
abstract class Shape {
public abstract double area();
}
Implement concrete shapes like Rectangle and Circle by extending the Shape class:
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 area() {
return width * height;
}
}
class Circle extends Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double area() {
return Math.PI * radius * radius;
}
}
Now, if you want to add a new shape, such as a triangle, you can do so without modifying the existing code. You create a new Triangle class that extends Shape:
class Triangle extends Shape {
private double base;
private double height;
public Triangle(double base, double height) {
this.base = base;
this.height = height;
}
@Override
public double area() {
return 0.5 * base * height;
}
}
By following the Open-Closed Principle, you can easily add new shapes to your system without altering the existing code that deals with calculating areas.
Extensibility: Easily add new features without altering existing code.
Reduce Risk: Modifying existing code can introduce bugs, and OCP helps minimize this risk.
Maintainability: Code remains stable and easier to manage over time.
It emphasizes that objects of derived classes must be substitutable for objects of their base classes without affecting the program’s correctness.
When class B inherits from class A as a subclass, it should seamlessly work as a substitute for class A in any method that expects an object of class A.
Let’s consider an example using a classic geometric shape hierarchy:
class Shape {
public double getArea() {
return 0.0;
}
}
class Circle extends Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double getArea() {
return Math.PI * radius * radius;
}
}
class Square extends Shape {
private double side;
public Square(double side) {
this.side = side;
}
@Override
public double getArea() {
return side * side;
}
}
In this example
Here’s how LSP is applied:
Here’s how LSP is applied:
Shape circleShape = new Circle(5.0);
Shape squareShape = new Square(4.0);
double circleArea = circleShape.getArea();
double squareArea = squareShape.getArea();
System.out.println("Circle Area: " + circleArea);
System.out.println("Square Area: " + squareArea);
In this code, we create instances of Circle and Square but store them in variables of type Shape, the base class. The Liskov Substitution Principle ensures that we can use these subclasses wherever a Shape is expected.
Polymorphic Behavior: LSP ensures that derived classes behave as expected when used polymorphically
Consistency: Code relying on base classes can work with derived classes seamlessly
Reduced Bugs: Improved program correctness leads to fewer unexpected issues.
It emphasizes that clients should not be forced to depend on interfaces they do not use.
Let’s consider an example in the context of a software application for a document management system. Suppose we have an interface called Document that initially includes methods for creating, editing, and sharing documents:
public interface Document {
void create();
void edit();
void share();
}
However, in our application, we have two types of documents: TextDocument and SpreadsheetDocument. While the TextDocument class can implement all the methods in the Document interface, the SpreadsheetDocument class doesn’t need the edit method since spreadsheets don’t have a typical “editing” action. This violates the ISP because it forces the SpreadsheetDocument class to implement methods it doesn’t need.
To adhere to the ISP, we should refactor the interface to make it more granular, like this:
public interface Createable {
void create();
}
public interface Editable {
void edit();
}
public interface Shareable {
void share();
}
Now, our classes can implement only the interfaces that are relevant to them:
public class TextDocument implements Createable,
Editable, Shareable {
}
public class SpreadsheetDocument implements Createable,
Shareable {
}
By adhering to the ISP, we’ve created smaller, more focused interfaces that allow classes to implement only what they need, reducing unnecessary dependencies and making the code more maintainable and flexible.
Reduced Dependencies: Clients depend only on the interfaces they need, reducing unnecessary coupling.
Improved Clarity: Code becomes more self-explanatory as clients implement interfaces that align with their specific functionality.
Easier Maintenance: Changes or additions to interfaces impact only relevant clients, reducing ripple effects.
It states that high-level modules should not depend on low-level modules, but both should depend on abstractions. In other words, the details should depend on abstractions, not the other way around.
It states that high-level modules should not depend on low-level modules, but both should depend on abstractions. In other words, the details should depend on abstractions, not the other way around.
Consider a messaging system that sends notifications through various channels: email, SMS, and push notifications.
Violation of DIP Without following DIP, the high-level NotificationService class directly depends on low-level implementations:
class NotificationService {
private EmailSender emailSender;
private SMSSender smsSender;
private PushNotificationSender pushSender;
public NotificationService() {
emailSender = new EmailSender();
smsSender = new SMSSender();
pushSender = new PushNotificationSender();
}
public void sendEmail() {
emailSender.send();
}
public void sendSMS() {
smsSender.send();
}
public void sendPushNotification() {
pushSender.send();
}
}
Applying DIP: Following DIP, we use abstractions and interfaces to invert the dependencies:
interface MessageSender {
void send();
}
class EmailSender implements MessageSender {
@Override
public void send() {
}
}
class SMSSender implements MessageSender {
@Override
public void send() {
}
}
class PushNotificationSender implements MessageSender {
@Override
public void send() {
}
}
class NotificationService {
private MessageSender sender;
public NotificationService(MessageSender sender) {
this.sender = sender;
}
public void send() {
sender.send();
}
}
By following the Dependency Inversion Principle, the NotificationService is no longer tightly coupled to specific sender implementations.
You can easily add new sender types without modifying the service, promoting flexibility and maintainability.
Flexibility: Easily swap or extend components without affecting high-level modules.
Scalability: Allows for the addition of new features or integrations with minimal code changes.
Testability: Simplifies unit testing by enabling the use of mock or stub implementations for testing.
I hope you guys find this article helpful, if you do please share with your friends and for any suggestion or question use comment section below. Keep visiting, keep smiling.