SOLID Principles


Introduction to SOLID Principles
As a software engineer, developing maintainable and scalable code is crucial. SOLID principles are a set of five design principles that help you create more robust and easily extendable code. Coined by Robert C. Martin, these principles help manage code dependencies, reduce complexity, and enhance development speed. Let's dive into each one with illustrations.
SOLID Principles Breakdown
S - Single Responsibility Principle (SRP)
O - Open/Closed Principle (OCP)
L - Liskov Substitution Principle (LSP)
I - Interface Segregation Principle (ISP)
D - Dependency Inversion Principle (DIP)
Let's discuss each principle in detail with examples.
Single Responsibility Principle (SRP)
A class should have only one reason to change, meaning it should have only one job or responsibility.
Cohesion: A class adhering to the SRP should exhibit high cohesion. Cohesion refers to how closely related the functionalities within a class are. When a class has a single responsibility, all its functions are directly related to that specific responsibility, making it easier to understand and manage.
Maintainability: Classes that follow the SRP are easier to maintain. When you need to make changes, you know exactly where to go because each class encapsulates a distinct part of the functionality. This isolation of concerns makes debugging and modifying code more straightforward.
Testing: SRP simplifies unit testing. Since each class handles one responsibility, the scope of testing for each class is confined, making it easier to write precise and effective tests.
Modularity: Adhering to the SRP enhances modularity. It enables you to easily replace or update a single part of the system without having to worry about unintended side effects in other parts. This leads to flexible code that can be more easily extended or adapted.ange, meaning it should have only one job or responsibility.
Examples:
Violating SRP
The below User
class above violates SRP because it has multiple responsibilities: validating email, saving the user to the database, and sending a welcome email. Changes in any one of these areas could affect the whole class, making it harder to maintain.
public class User {
private String name;
private String email;
public User(String name, String email) {
this.name = name;
this.email = email;
}
public void validateEmail() {
// Code to validate email format
}
public void saveToDatabase() {
// Code to save user to database
}
public void sendWelcomeEmail() {
// Code to send a welcome email to the user
}
// Getter and Setter methods for name and email, if needed
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
}
Adhering to SRP:
The below User
class is responsible solely for representing user data.
The
EmailValidator
class is responsible for validating email formats.The
UserRepository
class handles the logic for saving users to a database.The
EmailService
class is responsible for sending emails.
Each class has a single responsibility, improving maintainability, and modularity.
// User class
public class User {
private String name;
private String email;
public User(String name, String email) {
this.name = name;
this.email = email;
}
public String getName() {
return name;
}
public String getEmail() {
return email;
}
}
// EmailValidator class with a static validate method
public class EmailValidator {
public static void validate(String email) {
// Code to validate email format
}
}
// UserRepository class with a method to save the user
public class UserRepository {
public void save(User user) {
// Code to save user to database
}
}
// EmailService class with a method to send a welcome email
public class EmailService {
public void sendWelcomeEmail(User user) {
// Code to send a welcome email
}
}
Benefits:
Improved readability: Each class has a clear purpose, making the code easier to read and understand.
Easier to change or update: Making changes to a single responsibility is less likely to affect other parts of the code.
Enhanced testability: It is easier to write tests for classes that have a single, well-defined responsibility.
Conclusion:
The Single Responsibility Principle is fundamental for creating clean, understandable, and maintainable software. By ensuring that each class has only one reason to change, developers can create modules that are easier to work with, extending the longevity and adaptability of the software system.
Open/Closed Principle (OCP)
Definition: The Open/Closed Principle states that software entities (like classes, modules, functions) should be open for extension but closed for modification.
Why OCP?:
Enhances maintainability: Reduces the risk of introducing new bugs in existing, working code.
Encourages flexibility: Allows adding new features without disturbing existing functionality.
Easier to test: New functionalities can be tested in isolation without the need to retest established features.
Practical Example in Java
Imagine you have a basic application that processes different types of discounts. Initially, you have only one type of discount implemented.
class DiscountCalculator {
public double calculateDiscount(double amount) {
return amount * 0.1; // 10% discount
}
}
Now, your requirements change and you need to support additional types of discounts. According to the Open/Closed Principle, you shouldn't modify the existing DiscountCalculator
class. Instead, you should extend its functionality.
Step 1: Use an Interface or Abstract Class
First, create an interface for discount calculation. This allows different implementations to adhere to a common contract.
interface DiscountStrategy {
double applyDiscount(double amount);
}
Step 2: Implement Different Discount Strategies
Create different implementations for various discount strategies.
class TenPercentDiscount implements DiscountStrategy {
@Override
public double applyDiscount(double amount) {
return amount * 0.9; // 10% discount
}
}
class TwentyPercentDiscount implements DiscountStrategy {
@Override
public double applyDiscount(double amount) {
return amount * 0.8; // 20% discount
}
}
class NoDiscount implements DiscountStrategy {
@Override
public double applyDiscount(double amount) {
return amount; // No discount
}
}
Step 3: Modify the Context Class to use Different Strategies
Change your DiscountCalculator
class to use the DiscountStrategy
interface.
class DiscountCalculator {
private DiscountStrategy discountStrategy;
public DiscountCalculator(DiscountStrategy discountStrategy) {
this.discountStrategy = discountStrategy;
}
public double calculateDiscount(double amount) {
return discountStrategy.applyDiscount(amount);
}
}
Step 4: Use the DiscountCalculator with Different Strategies
Now you can easily extend functionality without modifying existing, stable code.
public class Main {
public static void main(String[] args) {
DiscountCalculator tenPercentDiscountCalculator = new DiscountCalculator(new TenPercentDiscount());
System.out.println("10% Discount: " + tenPercentDiscountCalculator.calculateDiscount(100));
DiscountCalculator twentyPercentDiscountCalculator = new DiscountCalculator(new TwentyPercentDiscount());
System.out.println("20% Discount: " + twentyPercentDiscountCalculator.calculateDiscount(100));
DiscountCalculator noDiscountCalculator = new DiscountCalculator(new NoDiscount());
System.out.println("No Discount: " + noDiscountCalculator.calculateDiscount(100));
}
}
Conclusion
The Open/Closed Principle helps to keep your codebase clean, maintainable, and flexible. By using interfaces and abstract classes, you can extend functionality without modifying existing code, which minimizes the risk of introducing bugs into your software.
Liskov Substitution Principle (LSP)
Subtypes must be substitutable for their base types without altering the correctness of the program.
"If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of the program (correctness, performance, etc.)."
Example Violating the Principle
Consider a simple example involving shapes:
class Rectangle {
private int width;
private int height;
public void setWidth(int width) {
this.width = width;
}
public void setHeight(int height) {
this.height = height;
}
public int getArea() {
return width * height;
}
}
class Square extends Rectangle {
@Override
public void setWidth(int width) {
super.setWidth(width);
super.setHeight(width);
}
@Override
public void setHeight(int height) {
super.setWidth(height);
super.setHeight(height);
}
}
At first glance, this may appear correct. However, substituting a Square
for a Rectangle
leads to unexpected behavior, violating the Liskov Substitution Principle. Here's why:
public class Main {
public static void main(String[] args) {
Rectangle rectangle = new Square();
rectangle.setWidth(5);
rectangle.setHeight(10);
// We expect the area to be width * height = 5 * 10 = 50
// But the square logic makes both dimensions the same, so area is 10 * 10 = 100
System.out.println("Expected area: 50, Actual area: " + rectangle.getArea());
}
}
This violates LSP because setting the width and height individually does not behave as expected, causing confusion and potential bugs.
Example Following the Principle
To better adhere to the Liskov Substitution Principle, we should design our classes such that subtype objects can seamlessly replace base type objects. One way to achieve this in our shape example is to create more explicit classes:
abstract class Shape {
public abstract int getArea();
}
class Rectangle extends Shape {
private int width;
private int height;
public Rectangle(int width, int height) {
this.width = width;
this.height = height;
}
@Override
public int getArea() {
return width * height;
}
}
class Square extends Shape {
private int side;
public Square(int side) {
this.side = side;
}
@Override
public int getArea() {
return side * side;
}
}
Here, the Shape
class is clearly defined with its fundamental operation, and each subclass follows its own logic without changing the behavior unexpectedly:
public class Main {
public static void main(String[] args) {
Shape rectangle = new Rectangle(5, 10);
Shape square = new Square(5);
System.out.println("Rectangle area: " + rectangle.getArea()); // Expected area: 50
System.out.println("Square area: " + square.getArea()); // Expected area: 25
}
}
Benefits of Adhering to LSP
Improved Reliability: By ensuring that subclasses operate as expected when substituting their base classes, code becomes more predictable and less prone to bugs.
Ease of Maintenance: Adhering to LSP simplifies debugging and testing, as behavior remains consistent across subclass implementations.
Enhanced Reusability: Properly designed classes promote reuse, reducing unnecessary duplication and fostering a more modular codebase.
Scalability: Adherence to LSP ensures that adding new subclasses or modifying existing ones does not require extensive changes to the code, facilitating easy scalability.
Conclusion
The Liskov Substitution Principle is a crucial concept in object-oriented design that ensures subclasses can be substituted for their base classes without altering the expected behavior of the program. Violations of LSP can lead to unexpected bugs and maintenance challenges. By adhering to the principle, developers can create
Interface Segregation Principle
"Clients should not be forced to depend on methods they do not use."
This means that creating smaller, more specific interfaces is preferable to having large, general-purpose interfaces, which can lead to "fat interfaces". By following ISP, we ensure that classes only implement methods that are relevant to them, thereby making the code more modular and maintainable.
Example Violating the Principle
Consider a scenario where you have a large interface Worker
:
public interface Worker {
void work();
void eat();
void sleep();
}
Now, assume you have different classes: HumanWorker
and RobotWorker
. Both classes implement the Worker
interface, but RobotWorker
does not need to eat or sleep. This setup forces RobotWorker
to implement methods it doesn't need, violating the ISP.
public class HumanWorker implements Worker {
@Override
public void work() {
// Human works
}
@Override
public void eat() {
// Human eats
}
@Override
public void sleep() {
// Human sleeps
}
}
public class RobotWorker implements Worker {
@Override
public void work() {
// Robot works
}
@Override
public void eat() {
// Robot doesn't eat
}
@Override
public void sleep() {
// Robot doesn't sleep
}
}
Example Following the Principle
To adhere to the Interface Segregation Principle, we can break down the Worker
interface into more specific interfaces:
public interface Workable {
void work();
}
public interface Eatable {
void eat();
}
public interface Sleepable {
void sleep();
}
Now, each class can implement the interfaces that are relevant to them:
public class HumanWorker implements Workable, Eatable, Sleepable {
@Override
public void work() {
// Human works
}
@Override
public void eat() {
// Human eats
}
@Override
public void sleep() {
// Human sleeps
}
}
public class RobotWorker implements Workable {
@Override
public void work() {
// Robot works
}
}
By segregating the interfaces, RobotWorker
is no longer forced to implement methods it doesn't need.
Benefits of Following Interface Segregation Principle
Decoupling: Smaller and more specific interfaces result in less coupling between classes. This decoupling makes the system more modular and easier to understand.
Reusability: When interfaces are specific, they are easier to reuse. Classes can implement multiple interfaces without inheriting unnecessary methods.
Maintainability: ISP leads to cleaner and more maintainable code. Changes in one part of the system are less likely to impact other parts.
Scalability: As the system grows, specific interfaces make it easier to manage and scale. New functionalities can be added with minimal impact on existing code.
Conclusion
The Interface Segregation Principle promotes the design of more focused and specific interfaces, leading to better decoupling, reusability, maintainability, and scalability of the codebase. By following this principle, developers can create systems that are robust, easier to manage, and less prone to errors. As with all SOLID principles, adhering to ISP requires practice and vigilance, but the long-term benefits are well worth the effort.
Remember, keeping interfaces small and focused ensures that classes only depend on what they actually need, resulting in a cleaner and more efficient codebase.
Dependency Inversion Principle
"High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions."
This principle encourages us to create a dependency on interfaces or abstract classes rather than concrete implementations, thus decoupling the high-level module from the low-level module and enabling easier modifications and scalability.
Examples Violating the Principle:
Consider the following example where a high-level class directly depends on a low-level class:
class Light {
public void turnOn() {
System.out.println("Light is turned on");
}
}
class Switch {
private Light light;
public Switch(Light light) {
this.light = light;
}
public void operate() {
light.turnOn();
}
}
public class Main {
public static void main(String[] args) {
Light light = new Light();
Switch lightSwitch = new Switch(light);
lightSwitch.operate();
}
}
Here, the Switch
class directly depends on the Light
class, making it difficult to replace Light
with another implementation, like a Fan
, without changing the Switch
class.
Example Following the Principle:
In order to follow the Dependency Inversion Principle, we introduce an interface or abstraction:
interface Device {
void turnOn();
}
class Light implements Device {
public void turnOn() {
System.out.println("Light is turned on");
}
}
class Fan implements Device {
public void turnOn() {
System.out.println("Fan is turned on");
}
}
class Switch {
private Device device;
public Switch(Device device) {
this.device = device;
}
public void operate() {
device.turnOn();
}
}
public class Main {
public static void main(String[] args) {
Device light = new Light();
Device fan = new Fan();
Switch lightSwitch = new Switch(light);
Switch fanSwitch = new Switch(fan);
lightSwitch.operate();
fanSwitch.operate();
}
}
In this example, Switch
depends on the Device
interface, promoting flexibility and decoupling. The Switch
class can control any device that implements the Device
interface without modification.
Benefits :
Decoupling: By abstracting dependencies, high-level modules are less affected by changes in low-level modules. This separation makes the system more flexible and easier to modify.
Maintainability: A decoupled architecture leads to easier maintenance and readability as changes to low-level modules do not ripple through the codebase.
Testability: Dependency injection facilitated by DIP makes it easier to swap real implementations with mock objects for testing purposes, leading to more effective unit testing.
Scalability: The system can grow without massive refactoring. New features or components can be integrated seamlessly by adhering to the abstractions defined.
Conclusion
The Dependency Inversion Principle is a cornerstone of creating flexible and maintainable software systems. By ensuring that high-level modules depend on abstractions rather than direct implementations, we achieve decoupling, making the system easier to maintain, test, and scale.
Implementing DIP may require additional initial effort to design appropriate abstractions but pays off significantly in creating a clean, modular, and resilient codebase.
Embracing DIP alongside the other SOLID principles will move you towards building better software that withstands the test of time and change.
By understanding and applying the Dependency Inversion Principle, you can approach your next project with the confidence that your code will be robust, scalable, and maintainable.
Happy coding!
Subscribe to my newsletter
Read articles from Maverick directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
