SOLID Design Principles: Enhancing Software Design with Java Examples
In the world of software development, creating maintainable, scalable, and robust code is more important than anything else. The SOLID principles, introduced by Robert C. Martin, provide a set of guidelines to achieve these goals.
This blog will explore each of the SOLID principles in depth, complete with Java code examples, to document my learning in this. We will also discuss the trade-offs associated with each principle and scenarios where they might be overkill.
SOLID Design Principles
The SOLID acronym stands for:
Let's delve into each of these principles, understand their importance, and see how to implement them in Java.
Single Responsibility Principle (SRP)
There should never be more than one reason to change a certain module.
A class should have only one reason to change, meaning it should have only one job or responsibility. This makes the class easier to understand, maintain, and test.
Java Examples
// Violating SRP
class User {
private String username;
private String password;
public void login(String username, String password) {
// login logic
}
public void register(String username, String password) {
// registration logic
}
public void sendEmail(String message) {
// email sending logic
}
}
Above code violates SRP because business logic along with logic to send email is also part of same class. So, below code is example for how to follow SRP
class User {
private String username;
private String password;
// Getters and setters
}
class UserService {
public void login(String username, String password) {
// login logic
}
public void register(String username, String password) {
// registration logic
}
}
class EmailService {
public void sendEmail(String message) {
// email sending logic
}
}
Pros:
Cons:
When to Use Use SRP:
When to Avoid SRP:
Open/Closed Principle (OCP)
Modules should be open for extension but closed for modification
Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. This means you should be able to add new functionality without changing existing code.
By not modifying well tested existing code, chances of causing bugs will be reduced.
Code that violates OCP:
class Rectangle {
public double length;
public double width;
}
class AreaCalculator {
public double calculateArea(Rectangle rectangle) {
return rectangle.length * rectangle.width;
}
}
Here, the AreaCalculator class is tightly coupled to the Rectangle class. If we want to add a new shape, such as a Circle or Square, we would have to modify the AreaCalculator class to handle the new shape, thus violating the OCP.
We can refactor code to make it follow OCP
interface Shape {
double calculateArea();
}
class Rectangle implements Shape {
private double length;
private double width;
@Override
public double calculateArea() {
return length * width;
}
}
class Circle implements Shape {
private double radius;
@Override
public double calculateArea() {
return Math.PI * radius * radius;
}
}
class AreaCalculator {
public double calculateArea(Shape shape) {
return shape.calculateArea();
}
}
Here, the AreaCalculator class operates on the Shape interface, which is implemented by both Rectangle and Circle. This allows us to add new shapes without modifying the AreaCalculator class, thus adhering to the OCP.
If we follow this principle adding new Shape is easy by extending the code without modifying existing code.
class Triangle implements Shape {
private double base;
private double height;
public Triangle(double base, double height) {
this.base = base;
this.height = height;
}
@Override
public double calculateArea() {
return 0.5 * base * height;
}
}
OCP can be explained in various ways, including inheritance (extending classes) and interfaces (implementing interfaces). The key aspect is that the system should be extendable without modifying existing code.
Pros:
Cons:
When to Use OCP:
When to Avoid OCP:
Liskov Substitution Principle (LSP)
Subtypes must be substitutable for their base types.
Objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. This principle ensures that a subclass can stand in for its superclass. Suppose A and B are types and B is derived from A (so B is the subtype and A is the base type or supertype). A method m requiring a parameter of type A can be called with objects of type B because every object of type B is also an object of type A.
Code that violates LSP:
class Bird {
public void fly() {
System.out.println("Flying");
}
}
class Ostrich extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("Ostriches can't fly");
}
}
Here, the Bird class has a method fly(), which all subclasses are expected to implement. However, Ostrich cannot fly, so it throws an exception. This violates the LSP because an instance of Ostrich cannot be used in place of a Bird without causing errors.
We can refactor code to make it follow LSP
abstract class Bird {
public abstract void move();
}
class Sparrow extends Bird {
@Override
public void move() {
System.out.println("Flying");
}
}
class Ostrich extends Bird {
@Override
public void move() {
System.out.println("Running");
}
}
Here, the Bird class defines an abstract method move(), which allows different subclasses to provide their specific implementation. This design respects the LSP because both Sparrow and Ostrich can be used interchangeably as Bird objects without causing unexpected behavior.
领英推荐
Pros:
Cons:
When to Use LSP:
When to Avoid LSP:
Interface Segregation Principle (ISP)
Don't expose methods to your client, methods that they don't use.
Clients should not be forced to depend on interfaces they do not use. This principle encourages creating small, specific interfaces rather than large, monolithic ones. This reduces the coupling between classes and enhances the modularity and maintainability of the code.
Code example that violates ISP
interface Worker {
void work();
void eat();
}
class HumanWorker implements Worker {
public void work() {
System.out.println("Working");
}
public void eat() {
System.out.println("Eating");
}
}
class RobotWorker implements Worker {
public void work() {
System.out.println("Working");
}
public void eat() {
throw new UnsupportedOperationException("Robots don't eat");
}
}
Here, both HumanWorker and RobotWorker implement the Worker interface, which includes both work() and eat() methods. Since RobotWorker doesn't need the eat() method, it throws an exception, violating the ISP because RobotWorker is forced to depend on a method it doesn't use.
Code can be refactored with interfaces that are segregated to follow the ISP
interface Workable {
void work();
}
interface Eatable {
void eat();
}
class HumanWorker implements Workable, Eatable {
public void work() {
System.out.println("Working");
}
public void eat() {
System.out.println("Eating");
}
}
class RobotWorker implements Workable {
public void work() {
System.out.println("Working");
}
}
Here, the Worker interface is split into two specific interfaces: Workable and Eatable. This allows HumanWorker to implement both interfaces since it needs both methods, while RobotWorker only implements Workable, adhering to the ISP. By splitting the responsibilities into separate interfaces, each class only implements the methods it needs. This adheres to the ISP, ensuring that classes are not forced to depend on methods they do not use.
Pros:
Cons:
When to Use ISP:
Avoid ISP:
Dependency Inversion Principle (DIP)
Depend on abstractions. High-level modules should not depend on low-level modules. Both should depend on abstractions (e.g., interfaces). Abstractions should not depend on details. Details should depend on abstractions. This principle decouples software modules, making them more flexible and easier to maintain.
Code example that violates DIP
//violating code
class LightBulb {
public void turnOn() {
System.out.println("LightBulb turned on");
}
public void turnOff() {
System.out.println("LightBulb turned off");
}
}
class Switch {
private LightBulb lightBulb;
public Switch(LightBulb lightBulb) {
this.lightBulb = lightBulb;
}
public void operate() {
lightBulb.turnOn();
// Perform some operation
lightBulb.turnOff();
}
}
// DIP following code
interface Switchable {
void turnOn();
void turnOff();
}
class LightBulb implements Switchable {
public void turnOn() {
System.out.println("LightBulb turned on");
}
public void turnOff() {
System.out.println("LightBulb turned off");
}
}
class Switch {
private Switchable device;
public Switch(Switchable device) {
this.device = device;
}
public void operate() {
device.turnOn();
// Perform some operation
device.turnOff();
}
}
Switch class directly depends on the LightBulb class, violating the DIP. Here, the Switch class is tightly coupled to the LightBulb class. If we want to switch to another type of device, we would need to modify the Switch class, which violates the DIP.
Here, the Switch class depends on the Switchable interface rather than a concrete implementation. This way, the Switch class can work with any device that implements the Switchable interface, adhering to the DIP.
One more example for DIP:
// Violating code example
public class SmartHomeController {
private final SmartLight light;
private final Thermostat thermostat;
public SmartHomeController() {
this.light = new SmartLight();
this.thermostat = new Thermostat();
}
public void controlHome() {
light.turnOn();
thermostat.setTemperature(22);
}
}
class SmartLight {
public void turnOn() {
System.out.println("SmartLight turned on");
}
}
class Thermostat {
public void setTemperature(int temperature) {
System.out.println("Thermostat set to " + temperature + " degrees");
}
}
In this code, SmartHomeController directly depends on the SmartLight and Thermostat classes. This tight coupling makes it difficult to switch out these components with different implementations or to test the controller in isolation. Code can be refactored to follow DIP.
// Define the Interfaces
public interface Light {
void turnOn();
}
public interface TemperatureControl {
void setTemperature(int temperature);
}
// Implement the Interfaces
public class SmartLight implements Light {
@Override
public void turnOn() {
System.out.println("SmartLight turned on");
}
}
public class Thermostat implements TemperatureControl {
@Override
public void setTemperature(int temperature) {
System.out.println("Thermostat set to " + temperature + " degrees");
}
}
//Update the SmartHomeController to Use the Abstractions
public class SmartHomeController {
private final Light light;
private final TemperatureControl thermostat;
public SmartHomeController(Light light, TemperatureControl thermostat) {
this.light = light;
this.thermostat = thermostat;
}
public void controlHome() {
light.turnOn();
thermostat.setTemperature(22);
}
}
// Use
public class Main {
public static void main(String[] args) {
Light light = new SmartLight();
TemperatureControl thermostat = new Thermostat();
SmartHomeController controller = new SmartHomeController(light, thermostat);
controller.controlHome();
}
}
We can easily swap SmartLight and Thermostat with other implementations, like PhilipsHueLight or NestThermostat, without changing the SmartHomeController class.
The Dependency Inversion Principle promotes the use of abstractions over concrete implementations to reduce coupling between high-level and low-level modules. By adhering to DIP, the code becomes more flexible and easier to extend or modify.
Pros:
Cons:
When to Use DIP:
When to Avoid DIP:
Conclusion
The SOLID principles provide a robust foundation for creating maintainable, scalable, and flexible software. By adhering to these principles, developers can design systems that are easier to understand, test, and extend. However, it is essential to balance these principles with the practical needs of the project, considering trade-offs and context-specific requirements.
When to Use SOLID Principles
When Not to Use SOLID Principles
By understanding and applying SOLID principles thoughtfully, developers can build better software that stands the test of time.
Having explored the SOLID principles, which lay the foundation for modular and maintainable software design, we now embark on the next phase of our journey: Design patterns. Design patterns offer proven solutions to common design problems, enhancing our ability to create flexible and efficient software architectures. By understanding how SOLID principles establish fundamental design principles, we pave the way for applying specific patterns—such as creational, structural, and behavioral patterns—to further refine our coding practices and elevate our development skills in upcoming blogs.