SOLID Principles
SOLID is a collection of fundamental principles designed to enhance the manageability and scalability of code in Object-Oriented Programming (OOP). It consists of five key principles:
- Single Responsibility Principle — SRP
- Open-Closed Principle — OCP
- Liskov’s Substitution Principle — LSP
- Interface Segregation Principle — ISP
- Dependency Inversion Principle — DIP
These principles were introduced by Robert C. Martin (also known as Uncle Bob) in the early 2000s and have since become widely adopted in the software development community. By following SOLID principles, developers can create code that is easier to understand, modify, and extend, leading to more robust and maintainable software systems.
Single Responsibility Principle (SRP)
Single Responsibility Principle is the first and most fundamental principle in OOP and SOLID. As the name sounds, this principle means “One class should have only one specific responsibility to take care of”.
Suppose we have a class called Invoice
, which contains 2 methods generateInvoice()
and saveToFiles()
.
public class Invoice {
private Long InvoiceNo;
public void generateInvoice() {
// code to generate Invoice.
}
public void saveToFiles() {
// code to save invoice as a file.
}
}
This is not a good practice because the Invoice
class has two responsibilities. A better approach would be to separate these functionalities into dedicated classes.
public class Invoice {
private Long InvoiceNo;
public void generateInvoice() {
// code to generate Invoice.
}
}
public class FileManager {
public void saveToFiles(Invoice invoice) {
// code to save invoice as a file.
}
}
Here, we can see we have 2 classes for the use case:
- Generating the invoice
- Save it to files
Benefits of following SRP
- Improved Code Organization: By separating concerns into different classes, the codebase becomes more organized and easier to navigate.
- Better Maintainability: When a class has a single responsibility, it is easier to understand its purpose and make changes without unintended side effects.
- Increased Reusability: Classes with a single responsibility are more likely to be reusable in different parts of the application or even in other projects.
- Easier Testing: Classes with a single responsibility are typically smaller and more focused, making them easier to test in isolation.
Open-Closed Principle (OCP)
Open-Closed principle is another core principle in SOLID. This principle was introduced by Bertrand Meyer in 1997. The idea behind this principle is “Software artifacts (classes, modules, and functions) should open for extensions, but closed for modifications.”
For example;
Let’s say, we have a class called Shape
, we can use this class to calculate the area of the shape.
public class Shape {
private String shapeType;
private double radius;
private double length;
private double width;
public Shape(String shapeType, double radius, double length, double width) {
this.shapeType = shapeType;
this.radius = radius;
this.length = length;
this.width = width;
}
public double area() {
if (shapeType.equals("circle")) {
return Math.PI * (radius * radius);
} else if (shapeType.equals("rectangle")) {
return length * width;
} else {
throw new IllegalArgumentException("Unknown shape type");
}
}
}
// Usage
public class Main {
public static void main(String[] args) {
Shape circle = new Shape("circle", 5, 0, 0);
Shape rectangle = new Shape("rectangle", 0, 4, 6);
System.out.println(circle.area());
System.out.println(rectangle.area());
}
}
In the code above, adding a new shape requires modifying the existing Shape
class, which is not considered a good practice.
Below is a code example that demonstrates how to apply the Open-Closed Principle to this scenario.
public interface Shape {
public double area();
}
class Circle implements Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double area() {
return Math.PI * (radius * radius);
}
}
class Rectangle implements Shape {
private double length;
private double width;
public Rectangle(double length, double width) {
this.length = length;
this.width = width;
}
@Override
public double area() {
return length * width;
}
}
// Usage
public class Main {
public static void main(String[] args) {
Shape circle = new Circle(5);
Shape rectangle = new Rectangle(4, 6);
System.out.println(circle.area());
System.out.println(rectangle.area());
}
}
With the application of OCP, we can add many shapes as we want without modifying the current implementation.
NOTE: Using interfaces is not the only way to achieve OCP.
Benefits of following OCP
- Reduced Risk of Bugs: By not modifying existing code, the risk of introducing new bugs or breaking existing functionality is minimized.
- Improved Maintainability: Code that follows the OCP is easier to maintain and extend, as new features can be added without altering the existing codebase.
- Enhanced Flexibility: The use of abstractions and polymorphism allows for more flexible and adaptable designs, making it easier to accommodate changing requirements.
Liskov’s Substitution Principle (LSP)
Liskov’s Substitution Principle is another important principle in OOP. It was introduced by Barbara Liskov in 1987 during a conference talk on data abstraction.
The principle states, “Objects of a superclass should be replaceable with objects of its subclasses without altering the correctness of the program”.
For example, if Circle
and Rectangle
are sub types of Shape
, then we should be able to replace Shape
object with a Circle
or Rectangle
object without any issues.
public interface Shape {
public double area();
}
class Circle implements Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double area() {
return Math.PI * (radius * radius);
}
}
class Rectangle implements Shape {
private double length;
private double width;
public Rectangle(double length, double width) {
this.length = length;
this.width = width;
}
@Override
public double area() {
return length * width;
}
}
public class Main {
public static void main(String[] args) {
Shape circle = new Circle(5); // Replace Shape object with Circle
Shape rectangle = new Rectangle(4, 6); // Replace Shape object with Rectangle
System.out.println(circle.area());
System.out.println(rectangle.area());
}
}
As demonstrated in this example, adhering to the Liskov Substitution Principle means we should be able to substitute a superclass instance with a subclass instance seamlessly.
Benefits of following LSP
- Improved Code Reusability: By ensuring that subtypes can be substituted for their base types, code that uses the base type can also work with any of its subtypes, promoting code reuse.
- Enhanced Maintainability: Code that follows LSP is easier to maintain because it reduces the risk of introducing bugs when modifying or extending the codebase.
- Better Testability: LSP makes it easier to write unit tests for classes and their subtypes, as the tests can be written against the base type and should work for all subtypes.
Interface Segregation Principle (ISP)
The Interface Segregation Principle is one of the five SOLID principles introduced by Robert C. Martin. It states: “Clients should not be forced to depend on interfaces they do not use”.
In other words, Using many task specific interfaces is better than using one general purpose interface.
Below example shows the usage of general purpose interface.
public interface MultifunctionPrinter {
void printDocument(Document document);
void scanDocument(Document document);
void faxDocument(Document document);
}
public class BasicPrinter implements MultifunctionPrinter {
@Override
public void printDocument(Document document) {
System.out.println("Printing: " + document.name());
}
@Override
public void scanDocument(Document document) {
throw new UnsupportedOperationException("Scanning not supported.");
}
@Override
public void faxDocument(Document document) {
throw new UnsupportedOperationException("Faxing not supported.");
}
}
Using a general-purpose interface like MultifunctionPrinter
forces us to implement unnecessary methods, which is considered bad practice. Let’s explore how we can apply the Interface Segregation Principle to this scenario.
Interfaces
public interface Printer {
void printDocument(Document document);
}
public interface Scanner {
void scanDocument(Document document);
}
public interface Fax {
void faxDocument(Document document);
}
Implementations
public class BasicPrinter implements Printer {
@Override
public void printDocument(Document document) {
System.out.println("Printing: " + document.name());
}
}
public class AdvancedPrinter implements Printer, Scanner {
@Override
public void printDocument(Document document) {
System.out.println("Printing: " + document.name());
}
@Override
public void scanDocument(Document document) {
System.out.println("Scanning: " + document.name());
}
}
public class FaxMachine implements Fax {
@Override
public void faxDocument(Document document) {
System.out.println("Faxing: " + document.name());
}
}
By applying the ISP, we split it into smaller, role-specific interfaces — like Printer
, Scanner
, and Fax
. This allows each class (e.g. BasicPrinter
, AdvancedPrinter
, or FaxMachine
) to implement only the relevant functionality, promoting modularity and reducing unnecessary dependencies.
Benefits of following ISP
- Modular and Reusable Code: By breaking down large interfaces into smaller, more specific ones, the code becomes more modular and reusable. Classes or modules can implement only the interfaces they need, reducing unnecessary dependencies and making it easier to reuse code across different parts of the system.
- Reduced Code Complexity: When classes or modules depend only on the interfaces they need, the code becomes less complex and easier to understand. This is because developers do not have to deal with unnecessary methods or dependencies. These are not relevant to their specific use case.
- Improved Maintainability: With smaller and more focused interfaces, it becomes easier to maintain the code. Changes to one interface are less likely to affect other parts of the system, reducing the risk of introducing bugs or breaking existing functionality.
- Better Testability: Smaller and more focused interfaces make it easier to write unit tests for individual components. This is because the tests can focus on specific behaviors without being affected by irrelevant methods or dependencies.
- Increased Flexibility: By adhering to the ISP, the system becomes more flexible and easier to extend or modify. New features or requirements can be added by creating new interfaces or modifying existing ones without affecting the entire system.
Dependency Inversion Principle (DIP)
Dependency Inversion Principle is the final principle of SOLID. Which was also introduced by Robert C. Martin. This promotes loosely-coupled code.
DIP states few points:
- High-level modules should not depend on low-level modules.
- Both should depend on abstraction.
- Abstraction should not depend on details.
- Details should depend on abstraction.
In simple terms, instead of a class directly depending on other specific classes (concrete implementations), it should depend on interfaces or abstract classes. This makes the code more flexible and easier to maintain, as you can swap out implementations without changing the dependent class.
Tightly coupled code (without DIP)
public class Keyboard {
public void type() {
System.out.println("Typing...");
}
}
public class Computer {
private Keyboard keyboard;
public Computer() {
this.keyboard = new Keyboard(); // Direct dependency
}
public void use() {
keyboard.type();
}
}
As shown in the example above, the Computer
class directly depends on the Keyboard
class.
Loosely coupled code (with DIP)
public interface InputDevice {
void type();
}
pubic class Keyboard implements InputDevice {
@Override
public void type() {
System.out.println("Typing...");
}
}
public class Computer {
private InputDevice inputDevice;
public Computer(InputDevice inputDevice) {
this.inputDevice = inputDevice; // Depends on abstraction
}
public void use() {
inputDevice.type();
}
}
Now, Computer
depends on the InputDevice
interface, not a specific Keyboard
. This makes it easy to switch to another input device, like a WirelessKeyboard
, without modifying the Computer
class.
Benefits of following DIP
- Loose Coupling: By depending on abstractions rather than concrete implementations, the code becomes less tightly coupled, making it easier to change one part of the system without affecting others.
- Improved Maintainability: Changes in low-level modules do not impact high-level modules, making the system easier to maintain and extend.
- Enhanced Testability: High-level modules can be tested using mock implementations of the low-level modules, making testing faster and more reliable.
- Increased Reusability: High-level modules can be reused in different contexts without needing to change the low-level modules they depend on.
Conclusion
In conclusion, the SOLID principles: Single Responsibility, Open-Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion provide essential guidelines for writing clean, maintainable, and scalable code in object-oriented programming.
By adhering to these principles, developers can create systems that are easier to understand, modify, and extend, ultimately leading to higher quality software and more efficient development processes.