When writing code, it’s crucial to focus on the overall structure and design of a codebase. For a developer, nailing the right codebase design is important in order to accommodate any future changes in the requirement, business functionality, technology, or adding new features. The loosely-coupled design classes can allow the addition of new code without breaking the existing code and can be easily understood by other developers.
While writing code, you must adhere to certain crucial guidelines for an object-oriented design, called SOLID principles in programming, to create better code. These guidelines were proposed by Mr. Bob Martin in his book “Clean Code.”
Let’s discuss these 5 SOLID in object-oriented design below:
Single Responsibility Principle (SRP)
The SRP means that a class should have well-defined roles and responsibilities and only one reason to change.
Let’s consider an example of a payroll application in image 1. The EmployeeService class calculates the employee pay, gets employee details from the database, and gets contact information for a temporary employee. This class has three different responsibilities and multiple reasons to change. If a business wants to change the pay calculation or contract period logic, then you need to make changes in the EmployeeService class. It breaks the single responsibility.
To implement the SRP, you can decouple these three different business concerns into separate classes so that the change in one class does not impact other classes.
Image 2 shows a re-factored code that adheres to the SRP where EmployeeService class is responsible for getting employee-related information, PayService class is responsible for calculating employee perks and EmpContractService class is responsible for handling contract employee-related operations.
So, there are different classes for various business concerns that correspond separately to a change.
Open-Closed Principle (OCP)
According to the OCP, any class or module of an application should be open for extension but closed for modification. It means that once a module is completed, your classes can allow the behavior to be extended by adding new functionalities or features without changing the existing base classes.
Let’s consider the same example of Payroll application, in image 3, where PayService class is responsible for calculating the employees salary. If employee='TEMPORARY' then employees are compensated by a set hourly rate; if employee='Permanent' then employees will get a fixed monthly salary. If there is a new requirement from the client for a third employee category at any later point in time, then you need to modify your existing code for caculatePay(). This code breaks the Open-Closed Principle.
As per the OCP, you should not modify the PayService class. Instead, you must use the interface and invoke different implementations at run time, as shown in image 4. Each employee category has its own implementation of computePay() method.
The above code is open for extension. When you have a third category of the employee, you need to create a new class and implement computePay() method without any changes to the PayService class.
Liskov Substitution Principle (LSP)
The LSP says that subclasses or derived classes should be substitutable for their parent class. It means if ‘S’ is a subclass of ‘T,’ then superclass object ‘T’ can be replaced with object ‘S’ without breaking the existing code. The LSP is a way of ensuring that polymorphism and inheritance are used correctly.
This principle is an extension of the open-closed principle. It dictates making changes to the class by altering objects without breaking existing code, without telling the difference to the user or client.
Let's take a look at Payroll application classes in image 5. The generatePaySlip method in PayService class takes input as the PermanentEmployee type and generates payslip for permanent employees.
Later, if the client need payslips for temporary employees, you can add a new generatePaySlip method for temporary employees in PayService. However, this is not an ideal approach since the code is not extensible and it breaks the open-closed principle.
If you re-factored this code using the LSP, you can replace the method input parameter PermanentEmployee object with some generic employee (IEmployee interface). You would use the same generatePaySlip method for generating temporary employee payslips. See the updated version in image 6, where the subtype object is substitutable for the base type. This solution makes our code more generic. It helps in reducing coupling and facilitate code reusability in your application.
Interface Segregation Principle (ISP)
According to the ISP, a client should not be forced to implement interfaces that may not be needed – classes should not be forced to implement methods they do not use. Instead of one larger monolithic interface, many small interfaces are preferred for related operations and unrelated operations should be placed in separate interfaces.
Image 7 is an example of a codebase design that violates the ISP. It has an IEmployeeService interface that contains three methods calculatePay, getEmployeeDetails, and getContractPeriod. Each class implementing IEmployeeService Interface must implement all three methods whether they are required or not.
A class PayrollService that deals with payroll-related operations may not be interested in the method of getEmployeeDetails. EmployeeService class that deals with employee-related information may not be interested in caculatePay operations. A larger monolithic interface would force the class to implement unrelated methods that they do not use.
Thus, it’s important to change the design to make it more flexible. The example in image 8 applies the ISP and splits interfaces based on classes that will implement them. It segregates the single IEmployeeService interface into smaller interfaces: IEmployeeService, IPayrollService, and IEmpContractService, as shown in image 8.
This way, it's easier to implement classes that do not need to handle all original functionalities of the IEmployeeService. Hence, the code is more decoupled and easier to maintain.
Dependency Inversion Principle (DIP)
The DIP 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."
Consider a scenario that violates this principle. Let’s say you are implementing a payroll application for a software team that consists of developers and testers, as shown in image 9.
The image shows that the Application class is a high-level class or module, and it depends on low-level class - Developers and Testers.. It shows that the methods writeCode() and writeTestCases() belong to their corresponding classes, not the Application class. In this implementation, the Application class must already know about methods to execute the business logic. As a result, the system is very tightly-coupled.
To solve this problem, you can implement an interface called App Builder and re-factor the Developers and Testers classes, as shown below:
You can now re-factor the Application class so that it will not depend on lower-level classes.
The creation of the abstraction between different app builders and application has resulted in a design code that is extendable and easy to maintain.
The applications of today require a broader scope of adding new features in the future. Therefore, it becomes necessary for programmers to follow the SOLID principles in OOPs in their programming practices to create more readable, thoroughly tested, and maintainable code.