SOLID Design Principles: Enhancing Software Design with Java Examples
Guide to SOLID Principles for Robust and Maintainable Software
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:
Single Responsibility Principle (SRP)
Open/Closed Principle (OCP)
Liskov Substitution Principle (LSP)
Interface Segregation Principle (ISP)
Dependency Inversion Principle (DIP)
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:
Easier to understand and maintain.
Enhances testability.
Cons:
May lead to more classes and increased complexity in the class structure.
When to Use Use SRP:
When different functionalities in a class change for different reasons.
To make classes more modular and easier to test.
When to Avoid SRP:
For very small projects where the overhead of multiple classes might outweigh the benefits.
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.
Facilitates scalability and flexibility.
Reduces the risk of introducing bugs in existing code.
Cons:
Can lead to an increase in the number of classes and interfaces.
Requires careful design to ensure extensibility.
When to Use OCP:
When the application is expected to grow and require new features.
In complex systems where changes can introduce new bugs.
When to Avoid OCP:
In simple applications where extensibility is not a priority.
When the system requirements are stable and unlikely to change.
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 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
Ensures robustness and correctness.
Improves code reusability and substitutability.
Cons:
Requires thorough understanding of inheritance and polymorphism.
Can limit the design choices in subclass behavior.
When to Use LSP:
When designing class hierarchies to ensure subclasses can be used interchangeably with their superclasses.
To maintain the integrity of inherited behavior.
When to Avoid LSP:
In cases where subclass behavior diverges significantly from the superclass, making substitution impractical.
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:
Promotes decoupling and modularity.
Enhances code readability and maintainability.
Cons:
Can result in more interfaces, increasing the complexity of the codebase.
Requires careful design to avoid overly fragmented interfaces.
When to Use ISP:
When different clients need different subsets of an interface's methods.
To create clear and concise interfaces that reflect specific client needs.
Avoid ISP:
In simple applications where the benefits of small interfaces do not outweigh the added complexity.
When the system requirements are stable and interfaces are unlikely to change.
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 theLightBulb
class, violating the DIP. Here, theSwitch
class is tightly coupled to theLightBulb
class. If we want to switch to another type of device, we would need to modify theSwitch
class, which violates the DIP.Here, the
Switch
class depends on theSwitchable
interface rather than a concrete implementation. This way, theSwitch
class can work with any device that implements theSwitchable
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);
}
}
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:
Promotes loose coupling and flexibility.
Makes the system more modular and easier to extend.
Cons:
Can introduce complexity due to the need for more interfaces and abstraction layers.
Requires careful design to avoid unnecessary abstractions.
When to Use DIP:
When building large, complex systems that require flexibility and maintainability.
To decouple high-level and low-level modules, promoting reuse and ease of testing.
When to Avoid DIP:
In simple applications where the overhead of additional abstractions is not justified.
When the application requirements are stable and unlikely to change.
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
In large and complex systems where maintainability and scalability are crucial.
When building systems expected to grow and change over time.
To improve code quality and adherence to best practices.
When Not to Use SOLID Principles
In small, simple projects where the overhead of applying SOLID principles might outweigh the benefits.
When the project scope and requirements are stable and unlikely to change significantly.
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.