Image of Robert C. Martin (Uncle Bob) in his office with computers, taken on 22 January 2020. This image is the work of Angelacleancoder and is available under a Creative Commons Attribution-ShareAlike 4.0 International license.
Table of Contents
Introduction:
The SOLID principles are a set of fundamental guidelines that every Java developer should strive to follow. Introduced by Robert C. Martin (Uncle Bob) in the early 2000s, these principles are a collection of best practices that guide us in designing and writing software that is easy to understand, extend, and maintain.
As software developers, writing clean, maintainable, and scalable code is crucial for the long-term success of our projects. Adhering to the SOLID principles can help us achieve this goal by promoting code that is modular, flexible, and resistant to change.
In this blog post, we’ll explore the SOLID principles in the context of Java programming, and we’ll provide practical examples to help you understand and apply these principles in your own projects. By mastering the SOLID principles, you’ll be well on your way to becoming a better Java developer and creating software systems that stand the test of time.
The SOLID Principles Explained:
1. Single Responsibility Principle (SRP)
The Single Responsibility Principle states that a class should have only one reason to change. In other words, a class should have a single responsibility or job. By adhering to this principle, we can create classes that are more focused, easier to maintain, and less prone to unexpected side effects when changes are made.
Real-time Scenario: An e-commerce application where the OrderProcessor
class handles order processing, inventory management, and email notifications.
// Violates SRP
class OrderProcessor {
private List<Order> orders;
private List<Product> inventory;
public void processOrder(Order order) {
// Order processing logic
// ...
updateInventory(order);
sendOrderConfirmationEmail(order);
}
private void updateInventory(Order order) {
// Inventory management logic
// ...
}
private void sendOrderConfirmationEmail(Order order) {
// Email sending logic
// ...
}
}
Fix using SRP:
class OrderProcessor {
private List<Order> orders;
public void processOrder(Order order) {
// Order processing logic
// ...
InventoryManager.updateInventory(order);
EmailNotifier.sendOrderConfirmationEmail(order);
}
}
class InventoryManager {
private List<Product> inventory;
public static void updateInventory(Order order) {
// Inventory management logic
// ...
}
}
class EmailNotifier {
public static void sendOrderConfirmationEmail(Order order) {
// Email sending logic
// ...
}
}
2. Open Closed Principle (OCP)
The Open Closed Principle states that software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. This means that you should be able to extend the behaviour of a class without modifying its source code.
Real-time Scenario: A payroll system that calculates employee salaries based on their employment type (full-time, part-time, contractor).
// Violates OCP
class PayrollCalculator {
public double calculateSalary(Employee employee) {
double salary = 0;
if (employee.getEmploymentType() == EmploymentType.FULL_TIME) {
salary = calculateFullTimeSalary(employee);
} else if (employee.getEmploymentType() == EmploymentType.PART_TIME) {
salary = calculatePartTimeSalary(employee);
} else if (employee.getEmploymentType() == EmploymentType.CONTRACTOR) {
salary = calculateContractorSalary(employee);
}
return salary;
}
// Methods for calculating salaries for each employment type
// ...
}
Fix using OCP:
package solid.demo;
enum EmploymentType{
FULL_TIME,
PART_TIME,
CONTRACT
}
class Employee{
private EmploymentType employmentType;
public Employee(EmploymentType employmentType){
this.employmentType = employmentType;
}
public EmploymentType getEmploymentType(){
return employmentType;
}
}
interface SalaryCalculator{
double calculateSalary(Employee employee);
}
class FullTimeSalaryCalculator implements SalaryCalculator{
@Override
public double calculateSalary(Employee employee) {
System.out.println("Calculate Full Time salary");
return 5000;
}
}
class PartTimeSalaryCalculator implements SalaryCalculator{
@Override
public double calculateSalary(Employee employee) {
System.out.println("Calculate Part time Salary");
return 2500;
}
}
class PayrollCalculator{
SalaryCalculator salaryCalculator;
public PayrollCalculator(SalaryCalculator salaryCalculator){
this.salaryCalculator = salaryCalculator;
}
public double calculateSalary(Employee employee){
return salaryCalculator.calculateSalary(employee);
}
}
public class Main{
public static void main(String[] args) {
System.out.println("Full Time Salary Calculator");
Employee fullTimeEmployee = new Employee(EmploymentType.FULL_TIME);
PayrollCalculator fullTimePayrollCalculator = new PayrollCalculator(new FullTimeSalaryCalculator());
System.out.println("FullTime employee Salary :"+ fullTimePayrollCalculator.calculateSalary(fullTimeEmployee));
System.out.println("*********");
System.out.println("Part Time Salary Calculator");
Employee partTimeEmployee = new Employee(EmploymentType.PART_TIME);
PayrollCalculator partTimeSalaryCalculator = new PayrollCalculator(new PartTimeSalaryCalculator());
System.out.println("Part Time Employee Salary:"+ partTimeSalaryCalculator.calculateSalary(partTimeEmployee));
}
}
If you want to calculate the payroll for contract-based employees, you just need to add a ContractSalaryCalculator
class that implements the SalaryCalculator
interface. Here’s how you can do it:
// Implement SalaryCalculator for contract-based employees
class ContractSalaryCalculator implements SalaryCalculator {
@Override
public double calculateSalary(Employee employee) {
// For simplicity, let's say all contract-based employees have a fixed salary
return 3000.00;
}
}
And in your main method, you can create a PayrollCalculator
for contract-based employees and calculate the salary like this:
// Create a contract-based employee
Employee contractEmployee = new Employee(EmploymentType.CONTRACT);
// Create a PayrollCalculator for contract-based employees
PayrollCalculator contractPayrollCalculator = new PayrollCalculator(new ContractSalaryCalculator());
// Calculate and print the salary
System.out.println("Contract employee salary: " + contractPayrollCalculator.calculateSalary(contractEmployee));
With this design, adding support for a new type of employee is as simple as creating a new class that implements SalaryCalculator
. This is a great example of the Open-Closed Principle in action. The PayrollCalculator
class doesn’t need to change when a new type of employee is added, which makes the code more flexible and easier to maintain.
3. Liskov Substitution Principle (LSP)
The Liskov Substitution Principle states that subtypes must be substitutable for their base types. This means that if you have a class Parent
and a subclass Child
, anywhere you use an instance of Parent
, you should be able to use an instance of Child
without breaking the application.
Real-time Scenario: A graphics application that has a base class Shape
and a derived class Rectangle
.
// Violates LSP
class Rectangle {
protected int width, 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 {
public void setWidth(int width) {
super.setWidth(width);
super.setHeight(width);
}
public void setHeight(int height) {
super.setWidth(height);
super.setHeight(height);
}
}
public class Main {
public static void main(String[] args) {
//Rectangle r = new Rectangle ();
/*
The LSP states that a program using a base class (in this case, Rectangle) should be able to replace the base class with a derived class (Square) without affecting the functionality of the program.
*/
Rectangle r = new Square();
r.setWidth(5);
r.setHeight(4);
System.out.println(r.getArea()); // Outputs 16, but we expected 20
}
}
In the first example where the Liskov Substitution Principle (LSP) was violated, we had a Rectangle
class and a Square
class that extends Rectangle
. Here, Rectangle
is the base class and Square
is the derived class.
The LSP states that a program using a base class (in this case, Rectangle
) should be able to replace the base class with a derived class (Square
) without affecting the functionality of the program.
However, in our example, when we tried to use a Square
object in place of a Rectangle
object, it did affect the functionality of the program. We set the width and height of the Rectangle
to 5 and 4 respectively, expecting the area to be 20. But when we replaced the Rectangle
with a Square
(which sets both width and height to the same value), the area became 16 instead of 20. This is a clear violation of the LSP because the functionality of the program changed when we substituted a Square
for a Rectangle
.
Fix using LSP:
interface Shape {
public int getArea();
}
class Rectangle implements Shape {
protected int width, 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 implements Shape {
private int side;
public void setSide(int side) {
this.side = side;
}
public int getArea() {
return side * side;
}
}
public class Main {
public static void main(String[] args) {
Shape s = new Square();
((Square) s).setSide(5);
System.out.println(s.getArea()); // Outputs 25
Shape r = new Rectangle();
((Rectangle) r).setWidth(5);
((Rectangle) r).setHeight(4);
System.out.println(r.getArea()); // Outputs 20
}
}
In the fixed version, both Rectangle
and Square
implement a Shape
interface. This way, a Square
is no longer a subtype of Rectangle
, and we avoid violating the Liskov Substitution Principle. Each class can now be used independently without causing unexpected behavior. This is a simple example, but it illustrates the principle well. In more complex systems, adhering to the Liskov Substitution Principle can help prevent bugs and make the code more maintainable.
In the second example, we fixed this violation by making Rectangle
and Square
implement a common Shape
interface instead of Square
extending Rectangle
. Now, Rectangle
and Square
are independent classes both implementing the Shape
interface, and they can be used interchangeably without affecting the functionality of the program, thus adhering to the Liskov Substitution Principle. This way, a Square
is no longer a subtype of Rectangle
, and we avoid violating the LSP. Each class can now be used independently without causing unexpected behavior.
Another Example of Violating LSP:
abstract class Bird {
abstract void fly();
}
class Duck extends Bird {
void fly() {
System.out.println("Duck is flying");
}
}
class Ostrich extends Bird {
void fly() {
throw new UnsupportedOperationException("Ostrich can't fly");
}
}
In this example, Bird
is an abstract class with a method fly()
. Duck
and Ostrich
are subclasses of Bird
. Duck
can fly, so it implements the fly()
method. However, Ostrich
can’t fly, so it throws an UnsupportedOperationException
in the fly()
method. This violates the Liskov Substitution Principle because we cannot substitute a Bird
with an Ostrich
and expect it to fly.
4. Interface Segregation Principle (ISP)
The Interface Segregation Principle states that clients should not be forced to depend on interfaces they do not use. This principle states that you should not have a single, monolithic interface with many methods, but rather have smaller, more specific interfaces.
Real-time Scenario: A home automation system that controls various devices like lights, security cameras, and appliances.
// Violates ISP
interface HomeAutomationDevice {
void turnOn();
void turnOff();
void adjustBrightness();
void recordVideo();
void setTemperature();
}
class Light implements HomeAutomationDevice {
@Override
public void turnOn() {
System.out.println("Turning on the light.");
}
@Override
public void turnOff() {
System.out.println("Turning off the light.");
}
@Override
public void adjustBrightness() {
System.out.println("Adjusting the light brightness.");
}
// Unused methods for Light
@Override
public void recordVideo() {}
@Override
public void setTemperature() {}
}
In the above code, the HomeAutomationDevice
interface violates the Interface Segregation Principle because it defines methods that are not relevant to all types of devices. For example, the Light
class has to provide empty implementations for the recordVideo
and setTemperature
methods, even though lights don’t perform these actions.
Fix using ISP:
interface Switchable {
void turnOn();
void turnOff();
}
interface Dimmable {
void adjustBrightness();
}
interface VideoRecorder {
void recordVideo();
}
interface ThermostatControl {
void setTemperature();
}
class Light implements Switchable, Dimmable {
@Override
public void turnOn() {
System.out.println("Turning on the light.");
}
@Override
public void turnOff() {
System.out.println("Turning off the light.");
}
@Override
public void adjustBrightness() {
System.out.println("Adjusting the light brightness.");
}
}
class SecurityCamera implements Switchable, VideoRecorder {
@Override
public void turnOn() {
System.out.println("Turning on the security camera.");
}
@Override
public void turnOff() {
System.out.println("Turning off the security camera.");
}
@Override
public void recordVideo() {
System.out.println("Recording video from the security camera.");
}
}
In the fixed code, we have separate interfaces for different device capabilities: Switchable
for devices that can be turned on/off, Dimmable
for devices with adjustable brightness, VideoRecorder
for devices that can record video, and ThermostatControl
for devices with temperature control.
The Light
class implements Switchable
and Dimmable
interfaces, the SecurityCamera
class implements Switchable
and VideoRecorder
interfaces, and the ThermostatControlledAppliance
class implements Switchable
and ThermostatControl
interfaces.
By segregating the interfaces, each device class only depends on the interfaces it needs, following the Interface Segregation Principle. This approach promotes better code organization, loose coupling, and easier maintenance and extensibility.
5. Dependency Inversion Principle (DIP)
The Dependency Inversion Principle is all about reducing dependencies among the code modules. It’s defined in two parts:
- The Dependency Inversion Principle states that 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.
Let’s break it down:
- High-level modules are the parts of the system that bring real value, like business logic or domain-specific operations.
- Low-level modules are the parts of the system that are required for the high-level modules to function, like utility services or basic input/output operations.
In traditional programming, high-level modules directly depend on low-level modules. This makes high-level modules difficult to reuse and maintain because any change in a low-level module could affect them.
The Dependency Inversion Principle addresses this issue by introducing an abstraction layer between high-level and low-level modules.
This way, both high-level and low-level modules depend on the same abstraction. This reduces the direct dependency between high-level and low-level modules, making the system more flexible, easier to maintain, and more amenable to testing.
Real-time Scenario 1: In this example, the TextEditor
class (a high-level module) is directly dependent on the PlainTextFileReader
class (a low-level module). This violates the Dependency Inversion Principle because high-level modules should not depend on low-level modules; both should depend on abstractions.
// High-level module
class TextEditor {
private PlainTextFileReader reader;
// The TextEditor depends directly on a low-level module (PlainTextFileReader)
public TextEditor(PlainTextFileReader reader) {
this.reader = reader;
}
public String getAllText() {
return reader.read();
}
}
// Low-level module
class PlainTextFileReader {
public String read() {
// Implementation for reading plain text files
return "PlainTextFile content";
}
}
public class Main {
public static void main(String[] args) {
PlainTextFileReader plainTextReader = new PlainTextFileReader();
TextEditor plainTextEditor = new TextEditor(plainTextReader);
System.out.println(plainTextEditor.getAllText());
}
}
If we wanted to add a new type of reader (like a JsonFileReader
), we would have to modify the TextEditor
class to accommodate this new reader. This makes the code less flexible and harder to maintain, which is exactly what the Dependency Inversion Principle aims to avoid. It also makes the system more tightly coupled, making it harder to understand, modify, and test.
In following example, the TextEditor
class now depends on both PlainTextFileReader
and JsonFileReader
. It has separate methods for getting all plain text and all JSON text. This approach works, but it’s not ideal because every time you want to add a new type of reader, you have to modify the TextEditor
class. This makes the code less flexible and harder to maintain, which is exactly what the Dependency Inversion Principle aims to avoid.
It also makes the system more tightly coupled, making it harder to understand, modify, and test. It’s better to depend on abstractions (like an interface) rather than concrete classes. This way, you can easily add new types of readers without changing the TextEditor
class. This is the essence of the Dependency Inversion Principle. It makes the code more flexible, reusable, and easier to maintain. It also improves the system’s decoupling, making it easier to understand, modify, and test.
// High-level module
class TextEditor {
private PlainTextFileReader plainTextReader;
private JsonFileReader jsonReader;
// The TextEditor depends directly on low-level modules (PlainTextFileReader and JsonFileReader)
public TextEditor(PlainTextFileReader plainTextReader, JsonFileReader jsonReader) {
this.plainTextReader = plainTextReader;
this.jsonReader = jsonReader;
}
public String getAllPlainText() {
return plainTextReader.read();
}
public String getAllJsonText() {
return jsonReader.read();
}
}
// Low-level module 1
class PlainTextFileReader {
public String read() {
// Implementation for reading plain text files
return "PlainTextFile content";
}
}
// Low-level module 2
class JsonFileReader {
public String read() {
// Implementation for reading JSON files
return "JsonFile content";
}
}
public class Main {
public static void main(String[] args) {
PlainTextFileReader plainTextReader = new PlainTextFileReader();
JsonFileReader jsonReader = new JsonFileReader();
TextEditor textEditor = new TextEditor(plainTextReader, jsonReader);
System.out.println(textEditor.getAllPlainText());
System.out.println(textEditor.getAllJsonText());
}
}
In follwing example, I have fix all above issues. Here TextEditor
is a high-level module that depends on the TextFileReader
interface, not on the concrete implementations (PlainTextFileReader
and JsonFileReader
).
This way, we can easily introduce new types of readers (like an XmlFileReader
) without changing the TextEditor
class. This is the essence of the Dependency Inversion Principle. It makes the code more flexible, reusable, and easier to maintain. It also improves the system’s decoupling, making it easier to understand, modify, and test
// The abstraction upon which high-level and low-level modules depend
interface TextFileReader {
String read();
}
// High-level module
class TextEditor {
private TextFileReader reader;
// The TextEditor depends on the abstraction, not on a concrete implementation
public TextEditor(TextFileReader reader) {
this.reader = reader;
}
public String getAllText() {
return reader.read();
}
}
// Low-level module 1
class PlainTextFileReader implements TextFileReader {
@Override
public String read() {
// Implementation for reading plain text files
return "PlainTextFile content";
}
}
// Low-level module 2
class JsonFileReader implements TextFileReader {
@Override
public String read() {
// Implementation for reading JSON files
return "JsonFile content";
}
}
public class Main {
public static void main(String[] args) {
TextFileReader plainTextReader = new PlainTextFileReader();
TextEditor plainTextEditor = new TextEditor(plainTextReader);
System.out.println(plainTextEditor.getAllText());
TextFileReader jsonReader = new JsonFileReader();
TextEditor jsonEditor = new TextEditor(jsonReader);
System.out.println(jsonEditor.getAllText());
}
}
Real-time Scenario 2: For example, consider a Car class (high-level module) that depends on a GasEngine class (low-level module). If we want to change the engine to an ElectricEngine, we would have to modify the Car class, which is not ideal. By applying the Dependency Inversion Principle, we would create an Engine interface, and both Car and GasEngine would depend on this interface. If we want to switch to an ElectricEngine, we just need to ensure that ElectricEngine also implements the Engine interface. The Car class remains unchanged, which is a much better design.
// The abstraction upon which high-level and low-level modules depend
interface Engine {
void start();
}
// High-level module
class Car {
private Engine engine;
// The Car depends on the abstraction, not on a concrete implementation
public Car(Engine engine) {
this.engine = engine;
}
public void start() {
engine.start();
}
}
// Low-level module 1
class GasEngine implements Engine {
@Override
public void start() {
// Implementation for starting a gas engine
System.out.println("Gas engine started");
}
}
// Low-level module 2
class ElectricEngine implements Engine {
@Override
public void start() {
// Implementation for starting an electric engine
System.out.println("Electric engine started");
}
}
public class Main {
public static void main(String[] args) {
Engine gasEngine = new GasEngine();
Car gasCar = new Car(gasEngine);
gasCar.start();
Engine electricEngine = new ElectricEngine();
Car electricCar = new Car(electricEngine);
electricCar.start();
}
}
In this example, Car
is a high-level module that depends on the Engine
interface, not on the concrete implementations (GasEngine
and ElectricEngine
). This way, we can easily introduce new types of engines (like a HybridEngine
) without changing the Car
class. This is the essence of the Dependency Inversion Principle. It makes the code more flexible, reusable, and easier to maintain. It also improves the system’s decoupling, making it easier to understand, modify, and test.
// The abstraction upon which high-level and low-level modules depend
interface Database {
void get();
void insert();
void update();
void delete();
}
// High-level module
class BudgetReport {
private Database database;
// The BudgetReport depends on the abstraction, not on a concrete implementation
public BudgetReport(Database database) {
this.database = database;
}
public void open() {
database.get();
}
public void save() {
database.insert();
}
}
// Low-level module 1
class MySQLDatabase implements Database {
@Override
public void get() {
// Implementation for getting data from MySQL database
System.out.println("Getting data from MySQL database");
}
@Override
public void insert() {
// Implementation for inserting data into MySQL database
System.out.println("Inserting data into MySQL database");
}
@Override
public void update() {
// Implementation for updating data in MySQL database
System.out.println("Updating data in MySQL database");
}
@Override
public void delete() {
// Implementation for deleting data from MySQL database
System.out.println("Deleting data from MySQL database");
}
}
// Low-level module 2
class MongoDB implements Database {
@Override
public void get() {
// Implementation for getting data from MongoDB
System.out.println("Getting data from MongoDB");
}
@Override
public void insert() {
// Implementation for inserting data into MongoDB
System.out.println("Inserting data into MongoDB");
}
@Override
public void update() {
// Implementation for updating data in MongoDB
System.out.println("Updating data in MongoDB");
}
@Override
public void delete() {
// Implementation for deleting data from MongoDB
System.out.println("Deleting data from MongoDB");
}
}
public class Main {
public static void main(String[] args) {
Database mysql = new MySQLDatabase();
BudgetReport reportMySQL = new BudgetReport(mysql);
reportMySQL.open();
Database mongo = new MongoDB();
BudgetReport reportMongo = new BudgetReport(mongo);
reportMongo.open();
}
}
In this example, BudgetReport
is a high-level module that depends on the Database
interface, not on the concrete implementations (MySQLDatabase
and MongoDB
). This way, we can easily introduce new types of databases (like a PostgreSQLDatabase
) without changing the BudgetReport
class. This is the essence of the Dependency Inversion Principle.
It makes the code more flexible, reusable, and easier to maintain. It also improves the system’s decoupling, making it easier to understand, modify, and test. This approach also follows the open-closed principle because to add any new database we don’t have to change the BudgetReport
class. We just need to add a new database class that implements the Database
interface. This makes the code more flexible and easier to maintain.
Real-time Scenario 3: A banking application where the AccountManager
class directly depends on concrete implementations of SavingsAccount
and CheckingAccount
classes.
// Violates DIP
class SavingsAccount {
// Savings account logic
}
class CheckingAccount {
// Checking account logic
}
class AccountManager {
private SavingsAccount savingsAccount;
private CheckingAccount checkingAccount;
public AccountManager() {
savingsAccount = new SavingsAccount();
checkingAccount = new CheckingAccount();
}
// Methods for managing accounts
// ...
}
In the above code, the AccountManager
class violates the Dependency Inversion Principle because it depends directly on concrete implementations of SavingsAccount
and CheckingAccount
classes. If a new type of account is introduced, such as a CreditAccount
, the AccountManager
class would need to be modified to accommodate the new account type.
Fix using DIP:
interface Account {
void deposit(double amount);
void withdraw(double amount);
// Other account operations
}
class SavingsAccount implements Account {
// Savings account logic
@Override
public void deposit(double amount) {
// Deposit logic for savings account
}
@Override
public void withdraw(double amount) {
// Withdrawal logic for savings account
}
}
class CheckingAccount implements Account {
// Checking account logic
@Override
public void deposit(double amount) {
// Deposit logic for checking account
}
@Override
public void withdraw(double amount) {
// Withdrawal logic for checking account
}
}
class AccountManager {
private List<Account> accounts;
public AccountManager(List<Account> accounts) {
this.accounts = accounts;
}
// Methods for managing accounts
public void depositIntoAccounts(double amount) {
for (Account account : accounts) {
account.deposit(amount);
}
}
public void withdrawFromAccounts(double amount) {
for (Account account : accounts) {
account.withdraw(amount);
}
}
}
In the fixed code, we have an Account
interface that defines the contract for different account types. The SavingsAccount
and CheckingAccount
classes implement this interface and provide their own implementations of the deposit and withdrawal operations.
The AccountManager
class now depends on the Account
interface instead of concrete implementations. This means that you can pass any implementation of the Account
interface to the AccountManager
class without modifying its code. If a new account type is introduced, such as a CreditAccount
, you can create a new implementation of the Account
interface without modifying the AccountManager
class.
By introducing an abstraction (Account
interface) and making the high-level module (AccountManager
) depend on it, we follow the Dependency Inversion Principle, promoting loose coupling, modularity, and easier maintenance and extensibility of the codebase.
These examples demonstrate how to apply the SOLID principles in Java source code to improve code quality, maintainability, and extensibility in real-time scenarios and practical examples.
Benefits of Following the SOLID Principles:
- Improved code maintainability
- It is easier to understand and extend the codebase
- Better testability
- Reduced technical debt
- Increased scalability and flexibility
Conclusion:
The SOLID principles are a powerful set of guidelines that can help you write clean, maintainable, and scalable code in Java. By following these principles, you can create software systems that are easier to understand, modify, and extend over time. While it may take some effort to apply these principles initially, the long-term benefits of writing clean code are well worth it.
Remember, writing clean code is not just about following rules; it’s about creating software that is easy to work with and can adapt to changing requirements over time. By mastering the SOLID principles, you’ll be well on your way to becoming a better software developer.
Ref: Understanding SOLID Principles: Dependency Inversion – DEV Community