SOLIDify Your Foundation: Mastering Software Design with a Deep Dive into SOLID Principles
In the world of software development, writing maintainable and extensible code is crucial. One way to achieve this is by following the SOLID principles, a set of five design principles that help you create code that is more robust, easier to understand, and less prone to bugs. In this blog post, we'll explore each of the SOLID principles and provide code examples in Java to demonstrate how to apply them effectively.
- Single Responsibility Principle (SRP)
The Single Responsibility Principle states that a class should have only one reason to change, meaning it should have only one responsibility. If a class has multiple responsibilities, changes in one area may inadvertently affect other areas of the code. Let's look at an example:
public class Item {
private String description;
private double price;
}
public class Order {
private int orderId;
private int quantity;
private Item item;
public Order(int orderId, Item item, int quantity) {
this.orderId = orderId;
this.item = item;
this.quantity = quantity;
}
public double calculateTotal() {
// Calculate the order total
double orderTotal = item.price * this.quantity;
return orderTotal;
}
public void saveToDatabase() {
// Save the order to the database
}
}
In this example, the Order class is responsible for two distinct tasks: calculating the order total and saving it to the database. This violates the SRP because the class has multiple reasons to change. This can lead to several problems such as
Code Maintenance: If there are changes in the order calculation logic or the database operations, it can inadvertently affect the other part of the class. This design can lead to code that is difficult to maintain, test, and understand.
Reusability: The order calculation and database operation logic are tightly coupled within the class, making it challenging to reuse either of these functionalities independently.
To adhere to SRP, we can separate these responsibilities into two different classes:
public class Item {
private String description;
private double price;
}
public class Order {
private int orderId;
private int quantity;
private Item item;
public Order(int orderId, Item item, int quantity) {
this.orderId = orderId;
this.item = item;
this.quantity = quantity;
}
public double calculateTotal() {
// Calculate the order total
double orderTotal = item.price * this.quantity;
return orderTotal;
}
}
public class OrderRepository {
public void saveToDatabase(Order order) {
// Save the order to the database
}
}
Now, the Order class is responsible only for order-related calculations, adhering to the SRP. The OrderRepository class is responsible for database operations, such as saving and retrieving orders. This separation of concerns not only makes the code easier to maintain and understand but also allows for independent testing and modification of each class without affecting the other.
- Open/Closed Principle (OCP)
The Open/Closed Principle suggests that software entities (classes, modules, functions) should be open for extension but closed for modification. In other words, you should be able to add new functionality to your code without changing existing code. This principle encourages the use of inheritance and polymorphism to achieve extensibility.
Let's illustrate the Open/Closed Principle with an example involving geometric shapes in Java:
Consider a scenario where you're building a system to calculate and display the areas of various geometric shapes. Initially, you have a ShapeCalculator class that calculates the area of different shapes. However, as new shapes are introduced, the existing code must remain untouched.
public class ShapeCalculator {
public double calculateArea(Shape shape) {
if (shape instanceof Circle) {
Circle circle = (Circle) shape;
return Math.PI * Math.pow(circle.getRadius(), 2);
} else if (shape instanceof Rectangle) {
Rectangle rectangle = (Rectangle) shape;
return rectangle.getWidth() * rectangle.getHeight();
}
// Add more shapes and calculations...
return 0.0;
}
}
In this non-OCP example, to add a new shape, you would need to modify the ShapeCalculator class, violating the Open/Closed Principle.
Now, let's apply the Open/Closed Principle to create an extensible design using inheritance and polymorphism:
interface Shape {
public double calculateArea();
}
class Circle implements Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double calculateArea() {
return Math.PI * Math.pow(radius, 2);
}
}
class Rectangle implements Shape {
private double width;
private double height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double calculateArea() {
return width * height;
}
}
public class ShapeCalculator {
public double calculateArea(Shape shape) {
return shape.calculateArea();
}
}
Now, you can easily add new shape types(e.g., Triangle, Square) without modifying the ShapeCalculator class.
- Liskov Substitution Principle (LSP)
The Liskov Substitution Principle (LSP) is one of the SOLID principles of object-oriented design, and it emphasizes that objects of derived classes should be able to replace objects of the base class without affecting the correctness of the program. In simpler terms, if you have a base class and derived classes, the derived classes should be able to extend the base class(and not narrow it down) without changing the expected behaviour of the program.
Let's illustrate LSP using a "Vehicle" example:
public class Vehicle {
public String getEngineName() {
return "Vehicle Engine";
}
public int getNumberOfWheels() {
return 2;
}
}
Now, let's create the "Car" class as a subclass of "Vehicle" to represent cars with engines and four wheels and let's also create a "Bicycle" class that extends the "Vehicle" class:
public class Car extends Vehicle {
@Override
public String getEngineName() {
return "Car Engine";
}
@Override
public int getNumberOfWheels() {
return 4;
}
}
public class Bicycle extends Vehicle {
// Bicycle-specific properties and methods
@Override
public int getNumberOfWheels() {
return 2;
}
}
The problem here is that a bicycle doesn't have an engine, so the "getEngineName" method doesn't make sense for a bicycle (We can either choose to implement its own getEngineName function and return null or do not implement it at all and just inherit the generic getEngineName method). If you create an instance of the "Bicycle" class and try to call "getEngineName", it could lead to confusion or unexpected behaviour in either case because bicycles don't have engines:
Violation of Liskov Substitution Principle:
public class Bicycle extends Vehicle {
// Bicycle-specific properties and methods
@Override
public String getEngineName() {
return null;
}
@Override
public int getNumberOfWheels() {
return 2;
}
}
public class Main{
public static void main(String[] args) {
List<Vehicle> vehicleList = new ArrayList<>();
vehicleList.add(new Vehicle());
vehicleList.add(new Car());
vehicleList.add(new Bicycle());
for(Vehicle vehicle : vehicleList){
System.out.println(vehicle.getEngineName());// This is not appropriate for a bicycle
}
}
}
Resolution - Applying Liskov Substitution Principle:
To adhere to the Liskov Substitution Principle, you should modify the class hierarchy. One way to do this is by introducing an intermediate class, let's call it "EngineVehicle," which includes the "engineName" method:
public class Vehicle {
public int getNumberOfWheels() {
return 2;
}
}
public class EngineVehicle extends Vehicle {
public String getEngineName() {
return "Vehicle Engine";
}
}
public class Car extends EngineVehicle {
@Override
public String getEngineName() {
return "Car Engine";
}
@Override
public int getNumberOfWheels() {
return 4;
}
}
public class Bicycle extends Vehicle {
@Override
public int getNumberOfWheels() {
return 2;
}
}
Now, the "Vehicle" class remains a base class without knowing anything about the engine, and the "EngineVehicle" class handles the engine-related methods.
By restructuring the class hierarchy in this way, you ensure that the Liskov Substitution Principle is not violated. Now, you can use both "Vehicle" and "Bicycle" objects interchangeably without unexpected behaviour:
public class Main{
public static void main(String[] args) {
List<Vehicle> vehicleList = new ArrayList<>();
vehicleList.add(new Vehicle());
vehicleList.add(new Car());
vehicleList.add(new Bicycle());
for(Vehicle vehicle : vehicleList){
System.out.println(vehicle.getNumberOfWheels());// This will work fine;
}
List<EngineVehicle> engineVehicleList = new ArrayList<>();
engineVehicleList.add(new EngineVehicle());
engineVehicleList.add(new Car());
// and this is how we can print engine name
for(EngineVehicle engineVehicle : engineVehicleList){
System.out.println(engineVehicle.getEngineName();
}
}
- Interface Segregation Principle (ISP)
The Interface Segregation Principle suggests that clients should not be forced to depend on interfaces they don't use. In other words, it's better to have several small, specific interfaces than one large, general interface. To illustrate the ISP, let's consider a restaurant employee scenario involving waiters and cooks.
Imagine you're designing a software system for a restaurant management application, and you want to create interfaces for restaurant employees. In a poorly designed system without considering ISP, you might have a single monolithic interface that contains methods for all possible tasks employees could perform, such as serving customers, cooking food, cleaning tables, and managing reservations:
public interface RestaurantEmployee {
void takeOrder();
void serveFood();
void prepareFood();
void handleCustomerPayment();
void manageKitchen();
}
In this scenario, you're violating the ISP because employees, such as cooks, should not be forced to implement methods like takeOrder or handleCustomerPayment that are irrelevant to their role. The same applies to waiters, who should not have to implement methods like prepareFood or manageKitchen.
To adhere to the ISP, you should break down this monolithic interface into smaller, more specific interfaces that each represent a particular role or responsibility. Let's create separate interfaces for waiters and cooks:
// Interface for Waiters
public interface Waiter {
void takeOrder();
void serveFood();
void handleCustomerPayment();
}
// Interface for Cooks
public interface Cook {
void prepareFood();
void manageKitchen();
}
Now, the Waiter interface contains methods relevant to the responsibilities of waiters, while the Cook interface contains methods relevant to the responsibilities of cooks.
With this design, employees can implement only the interfaces that match their specific roles. Waiters will implement the Waiter interface, and cooks will implement the Cook interface.
This segregation ensures that each class is responsible for a focused set of tasks, making the codebase more maintainable and less error-prone. It also allows you to add new roles or responsibilities to your restaurant management system without affecting existing classes that don't need to implement them.
- Dependency Inversion Principle (DIP)
The Dependency Inversion Principle (DIP) is one of the SOLID principles of object-oriented design. It states that high-level modules should not depend on low-level modules; both should depend on abstractions. In other words, it encourages the use of interfaces or abstract classes to define abstractions, allowing for flexibility and decoupling in your code.
According to DIP, our classes should depend upon interfaces or abstract classes instead of concrete classes and functions.
Let's explain the Dependency Inversion Principle using an example of a keyboard:
Imagine a typical computer system with a keyboard. In a system that doesn't follow the DIP, the high-level module (application) might directly depend on the low-level module (keyboard). This direct dependency can lead to inflexibility and complications when you want to switch or upgrade the keyboard.
Non-DIP Example:
public class Application {
private Keyboard keyboard;
// Direct dependency on Keyboard class.
public Application(Keyboard keyboard) {
this.keyboard = keyboard;
}
public void typeMessage() {
keyboard.type(); // The application directly uses the concrete keyboard class.
}
}
public class Main{
public static void main(String[] args) {
Application application = new Application(new Keyboard()); //Directly instantiates the Keyboard class.
}
}
In this non-DIP example, the Application class depends directly on the Keyboard class, which represents a specific type of keyboard. If you ever want to change the keyboard or use a different input device, you'd need to modify the Application class, violating the Open/Closed Principle (OCP).
Now, let's apply the Dependency Inversion Principle:
DIP Example:
public interface InputDevice {
void type();
}
public class Keyboard implements InputDevice {
public void type() {
// Typing logic specific to the keyboard
}
}
public class Application {
private InputDevice inputDevice;
public Application(InputDevice inputDevice) {
this.inputDevice = inputDevice; // Accepts any input device that implements the InputDevice interface.
}
public void typeMessage() {
inputDevice.type(); // The application uses the InputDevice interface, allowing flexibility.
}
}
public class Main{
public static void main(String[] args) {
Application application = new Application(new Keyboard());
Application application = new Application(new Mouse()); //In future we can pass any input device that implements the InputDevice interface without changing a single line in the Application class.
}
}
In this DIP-compliant example, we've introduced the InputDevice interface, which serves as an abstraction for any input device, such as a keyboard or a different input device. The Keyboard class implements this interface.
The Application class now accepts an instance of an InputDevice through its constructor. This means you can easily swap out the concrete input device without modifying the Application class, adhering to the Open/Closed Principle (OCP) and providing flexibility. This separation of concerns and abstraction of dependencies makes the code more maintainable and extensible, aligning with the Dependency Inversion Principle (DIP).
Conclusion
SOLID principles are a cornerstone of writing clean, maintainable, and adaptable code. They provide a roadmap for creating software that can withstand the test of time, accommodate new features and requirements, and minimize the introduction of bugs during development and maintenance.
By internalizing these principles and applying them consistently in your software development practices, you can build unshakable code that serves as a foundation for robust, long-lasting software systems. Embracing SOLID principles is not just a best practice but a commitment to quality and professionalism in the field of software development.