Since the early 2000s, object-oriented developers have adhered to the SOLID principles. They established best practices for programming in OOP languages, as well as for agile development and other areas. SOLID programs scale more effectively, take less time to develop, and adapt to change more quickly. Employers will always favor those who have a solid understanding of SOLID principles.
Let’s examine a few design flaws that frequently cause harm to any software:
- When we increase the pressure on particular courses by giving them more obligations that have nothing to do with those classes.
- When they are firmly coupled or when we force the class to hinge on one another.
- Duplicate code is used in the application
We can use the following strategies to overcome these worries:
- We need to choose the best architecture for the application.
- We must follow the design guidelines created by professionals.
- To create the software in accordance with its requirements and specifications, we must choose the appropriate design patterns.
The principles of SOLID are:
- Single-responsibility principle
- Open-closed principle
- Liskov substitution principle
- Interface segregation principle
- Dependency inversion principle
Lest start with, S: Single Responsibility Principle (SRP)
“A class should only have a single responsibility, that is, only changes to one part of the software’s specification should be able to affect the specification of the class.”
The Single Responsibility Principle, one of the most widely used design concepts, aids in achieving object-oriented objectives. By putting the single responsibility principle into practice, we may reduce dependencies between functionalities and better manage our code so that we can add new features in the long run. Every class, method, or module in your software should only handle one responsibility, according to the single-responsibility principle (SRP). Each should accept the position for a specific program functionality. The class should only contain variables and methods necessary for its operation.
Classes can work together to complete more challenging jobs, but before passing the output to another class, each class must complete a function from beginning to end. Martin stated, “A class should have only one cause to change,” in describing this. The “reason” in this case is that we want to change the sole functionality that this class aspires to. If we don’t decide to change just this one functionality, we won’t ever change this class because every part of the class should be related to that behavior.
SRP makes it straightforward to follow encapsulation, another renowned OOP principle. When all methods and data for a job fall under the precise single-responsibility class, it is easy to hide data from the customer. A single-responsibility class that uses a getter and setter technique satisfies the requirements for an encapsulated class. The benefit of using SRP-compliant programs is that you can change a function’s behavior by changing the one class that controls it. Additionally, you can be certain that only that class will malfunction if a single functionality fails because you know exactly where the issue is in the code.
public class BankAccount { public BankAccount() {} public string AccountNumber { get; set; } public decimal AccountBalance { get; set; } public decimal CalculateInterest() { // Code to calculate Interest }
Here, the BankAccount class includes the account’s information and calculates the interest on the account. Look at the change request we got from the company now:
- Introduce a new Property AccountHolderName, please.
- Incorporating a new rule into the interest calculation process.
These request types for changes are incredibly diverse. One modifies features, whilst the other modifies functionality. We have two distinct types of justifications for changing one class, which is against SRP.
We will use SRP to resolve this problem:
public interface IBankAccount { string AccountNumber { get; set; } decimal AccountBalance { get; set; } } public interface IInterstCalculator { decimal CalculateInterest(); } public class BankAccount : IBankAccount { public string AccountNumber { get; set; } public decimal AccountBalance { get; set; } } public class InterstCalculator : IInterstCalculator { public decimal CalculateInterest(IBankAccount account) { // Write your logic here return 1000; } }
Only the properties of the bank account fall inside the purview of our BankAccount class. We don’t need to update the BankAccount class if we want to add any extra business rules for the calculation of interest. In the event that a new Property AccountHolderName needs to be added, the InterestCalculator class does not require any changes. As a result, the Single Responsibility Principle is being put into practice.
O: Open Closed Principle (OCP)
This is Solid Principles’ second tenet, which is explained as follows:
“A software class or module should be open for extension but closed for modification”
If a class has been written, it must be adaptable enough that we may add new features to it without affecting its existing code, but we should wait to do so until bugs have been fixed before making changes (closed for modification). This principle emphasizes the requirement for classes to be able to add functionality without having to change their existing source code. Specifically, it must be possible to change the software’s behavior without changing the way it is currently implemented at its core. In essence, it says to design your classes or code in a way that adding new capabilities to the software doesn’t require changing any already existing code.
When a code implementation is described as “open for expansion,” it means that you should design it such that you can use inheritance to add additional features to your program. Your design should be such that adding a new class that derives from the base class and adding new code to this derived class is preferred over changing the existing class. Consider interface inheritance above class inheritance when considering inheritance. You are creating a dependency that is a tight connection between the derived class and the base if the derived class is built around the implementation in the base class. By adding a new class that uses the interface, you can add new features without changing the interface itself or superseding other classes. The interface also makes it easier for classes that implement the interface to work together loosely.
Code Example:
public class GarageStation { public void DoOpenGate() { //Open the gate functinality } public void PerformService(Vehicle vehicle) { //Check if garage is opened //finish the vehicle service } public void DoCloseGate() { //Close the gate functinality } }
When extending functionality, we can use OCP by utilizing interfaces, abstract classes, abstract methods, and virtual methods. I’ve merely used an interface here as an example; however, you can change it to suit your needs.
interface IAccount { // members and function declaration, properties decimal Balance { get; set; } decimal CalcInterest(); } //regular savings account public class RegularSavingAccount : IAccount { public decimal Balance { get; set; } = 0; public decimal CalcInterest() { decimal Interest = (Balance * 4) / 100; if (Balance < 1000) Interest -= (Balance * 2) / 100; if (Balance < 50000) Interest += (Balance * 4) / 100; return Interest; } } //Salary savings account public class SalarySavingAccount : IAccount { public decimal Balance { get; set; } = 0; public decimal CalcInterest() { decimal Interest = (Balance * 5) / 100; return Interest; } } //Corporate Account public class CorporateAccount : IAccount { public decimal Balance { get; set; } = 0; public decimal CalcInterest() { decimal Interest = (Balance * 3) / 100; return Interest; } }
By extending IAccount, the three new types normal saving account, salary saving account, and corporate account are generated in the code above.
This resolves the issue of class modification, and by extending the interface, functionality can be expanded.
As each class only performs one duty and we are just extending the class, the code above implements both the OCP and SRP principles.
L: Liskov Substitution Principle (LSP)
“Objects in a program should be replaceable with instances of their subtypes without altering the correctness of that program.”
In 1987, Barbara Liskov presented the Liskov Substitution Principle as the opening keynote for a symposium on data abstraction. Data abstraction and hierarchy in a computer program was the main heading. A few years later, they co-authored a study with Jeanette Wing in which they defined the principle as follows:
Let Φ (x) be a property that can be proven to apply to objects x of type T. Then, for objects y of type S—a subtype of T— Φ (y) must be true.
This perspective seems to be more didactic than useful. Using abstractions to create your solution hierarchy while keeping the logic sound and unaltered is what the Liskov Substitution Principle is all about. If the academic definition must be clarified, an object B inherits from or extends from an object A. Therefore, if B is handed to any logic that expects A, it should execute correctly. If you already work with an OOP (Object Oriented Programming) language, you probably already know that most compilers perform static checking to make sure that if class B inherits from class A, then class B contains every member of class A. As a result, this is not the real issue. You must do this yourself because the compiler won’t detect it.
Code Example:
Polymorphism is used in the majority of LSP implementations to produce class-specific behavior for the same calls. Let’s examine how we may modify a fruit categorization program to apply LSP in order to illustrate the LSP principle.
This illustration does not include to LSP:
namespace SOLID_PRINCIPLES.LSP { class Program { static void Main(string[] args) { Apple apple = new Orange(); Debug.WriteLine(apple.GetColor()); } } public class Apple { public virtual string GetColor() { return "Red"; } } public class Orange : Apple { public override string GetColor() { return "Orange"; } } }
Because the Orange class could not replace the Apple class without changing the program’s output, this does not adhere to LSP. The Orange class overrides the GetColor() method, thus it would return that an apple is orange if it were called.
namespace SOLID_PRINCIPLES.LSP { class Program { static void Main(string[] args) { Fruit fruit = new Orange(); Debug.WriteLine(fruit.GetColor()); fruit = new Apple(); Debug.WriteLine(fruit.GetColor()); } } public abstract class Fruit { public abstract string GetColor(); } public class Apple : Fruit { public override string GetColor() { return "Red"; } } public class Orange : Fruit { public override string GetColor() { return "Orange"; } } }
Thanks to the class-specific behavior of GetColor, any Fruit class subtype (such as Apple or Orange) can now be replaced with the opposite subtype without causing an error (). As a result, the LSP principle is now realized by this program.
I: Interface segregation principle
“Many client-specific interfaces are better than one general-purpose interface.”
According to the ISP (interface segregation principle), classes can only engage in behaviors that are beneficial to attaining their intended functionality. Classes do not consist of non-useful behaviors. Here, we’re referring to the first SOLID principle. ISP and OCP principles eliminate all practices, actions, or variables that do not directly advance their function. The entire process should contribute to the end result. This principle states that the client shouldn’t be forced to rely on strategies that it won’t use. Since it will allow clients to select the necessary interfaces and implement them, this principle promotes the adoption of multiple tiny interfaces as opposed to a single large interface.
This idea aims to divide the software into smaller classes that don’t handle the interface or methods that the class won’t utilize. This will make it easier to keep the class stationary, compact, and independent of dependencies. This idea suggests that instead of forcing classes to implement a single large interface, they should have a choice among numerous smaller interfaces. The interface that the class incorporates must be closely tied to the task that the class will carry out. We should build interfaces in accordance with Solid Principles’ Single Responsibility Principle. Since larger interfaces will necessitate more methods and not all implementors would need multiple methods, we should strive to make our interfaces as compact as possible. The Single Responsibility Principle may also be violated if we maintain a big size for interfaces, which could lead to a huge number of functions in the implementor class.
ISP has the advantage of decomposing complicated procedures into simpler ones that are more precise. As a result, the program is easier to debug for the reasons listed below:
- Only a little amount of code is shared between classes. Fewer bugs imply less code.
- A narrower variety of behaviors can be handled by one approach. You only need to review the minor methods if there is a problem with a behavior.
There will be a bug if the class tries to use an unsupported behavior if a general method with several behaviors is transferred to a class that doesn’t support all behaviors, such as calling for a property that the class doesn’t own.
Code Example:
Let’s examine how a program alters with and without adhering to the ISP principle to see how it appears in code.
First, the application that does not include ISP
// Not following the Interface Segregation Principle public interface IWorker { string ID { get; set; } string Name { get; set; } string Email { get; set; } float MonthlySalary { get; set; } float OtherBenefits { get; set; } float HourlyRate { get; set; } float HoursInMonth { get; set; } float CalculateNetSalary(); float CalculateWorkedSalary(); } public class FullTimeEmployee : IWorker { public string ID { get; set; } public string Name { get; set; } public string Email { get; set; } public float MonthlySalary { get; set; } public float OtherBenefits { get; set; } public float HourlyRate { get; set; } public float HoursInMonth { get; set; } public float CalculateNetSalary() => MonthlySalary + OtherBenefits; public float CalculateWorkedSalary() => throw new NotImplementedException(); } public class ContractEmployee : IWorker { public string ID { get; set; } public string Name { get; set; } public string Email { get; set; } public float MonthlySalary { get; set; } public float OtherBenefits { get; set; } public float HourlyRate { get; set; } public float HoursInMonth { get; set; } public float CalculateNetSalary() => throw new NotImplementedException(); public float CalculateWorkedSalary() => HourlyRate * HoursInMonth; }
This program does not include the ISP because the FullTimeEmployee class doesn’t need the CalculateWorkedSalary() function, and the ContractEmployee class doesn’t need the CalculateNetSalary().
Both of these techniques help these classes achieve their objectives. Instead, they are used since they are IWorker interface-derived classes.
The software could be refactored in the following way to adhere to the ISP principle:
// Following the Interface SegregationPrinciple public interface IBaseWorker { string ID { get; set; } string Name { get; set; } string Email { get; set; } } public interface IFullTimeWorkerSalary : IBaseWorker { float MonthlySalary { get; set; } float OtherBenefits { get; set; } float CalculateNetSalary(); } public interface IContractWorkerSalary : IBaseWorker { float HourlyRate { get; set; } float HoursInMonth { get; set; } float CalculateWorkedSalary(); } public class FullTimeEmployeeFixed : IFullTimeWorkerSalary { public string ID { get; set; } public string Name { get; set; } public string Email { get; set; } public float MonthlySalary { get; set; } public float OtherBenefits { get; set; } public float CalculateNetSalary() => MonthlySalary + OtherBenefits; } public class ContractEmployeeFixed : IContractWorkerSalary { public string ID { get; set; } public string Name { get; set; } public string Email { get; set; } public float HourlyRate { get; set; } public float HoursInMonth { get; set; } public float CalculateWorkedSalary() => HourlyRate * HoursInMonth; }
In this version, the main interface IWorker has been divided into three child interfaces: IFullTimeWorkerSalary, IContractWorkerSalary, and IBaseWorker.
All workers share several methods in the general interface. The child interfaces divide up techniques according to the type of employee: Full-Time with a salary or Contract with hourly pay.
In order to access all methods and attributes in the base class and the worker-specific interface, our classes can now implement the interface for that type of worker.
Now, the end classes only have the methods and properties necessary to accomplish their purpose and adhere to the ISP principle.
D: Dependency inversion principle
“One should depend upon abstractions, [not] concretions.”
There are two aspects to the dependency inversion principle (DIP):
- It is not advisable for high-level modules to rely on low-level ones. Both should rely on abstractions instead (interfaces)
- Details shouldn’t be the basis for abstractions. Abstractions ought to be dependent on specifics (like concrete implementations).
Traditional OOP program architecture is reversed in the first portion of this approach. Without DIP, programmers frequently create programs with deliberately coupled high-level (less specific, more abstract) and low-level (specific) components to carry out tasks.
DIP binds both high-level and low-level components to abstractions rather of decoupling them. While high-level and low-level components can still complement one another, a change in one shouldn’t always affect the other.
This aspect of DIP has the benefit because detached programs are easier to alter. Your application has a web of dependencies, which means a single change might have an impact on numerous independent components.
Changes will be more localized and take less time to locate all affected components if dependencies are kept to a minimum.
“The abstraction is not altered if the details are modified” might be seen as the second clause. The user-facing portion of the program is the abstraction.
The precise implementations that take place in the background to produce the user-visible program behavior are known as the details. In a DIP program, we could completely rewrite how the software achieves its behavior behind the scenes without the user’s knowledge.
Refactoring is the term for this process.
As a result, you won’t need to hard-code the interface to only use the most recent information (implementation). This maintains the flexibility of our code and gives us the ability to later refactor our implementations.
Code Example:
A general business application will be developed, complete with an interface, high-level, low-level, and detailed components.
Let’s start by implementing the getCustomerName() method in an interface. The users will see this.
public interface ICustomerDataAccess { string GetCustomerName(int id); }
We’ll now put the finishing touches on the ICustomerDataAccess interface-dependent details. The second component of the DIP principle is achieved by doing this.
public class CustomerDataAccess: ICustomerDataAccess { public CustomerDataAccess() { } public string GetCustomerName(int id) { return "Dummy Customer Name"; } }
The next step is to develop a factory class that implements the abstract interface ICustomerDataAccess and provides a usable version of it. Our low-level component is the CustomerDataAccess class that was returned.
public class DataAccessFactory { public static ICustomerDataAccess GetCustomerDataAccessObj() { return new CustomerDataAccess(); } }
The high-level component CustomerBuisnessLogic, which also implements the interface ICustomerDataAccess, will be the last thing we implement. Keep in mind that our high-level component only uses our low-level component rather than implementing it.
public class CustomerBusinessLogic { ICustomerDataAccess _custDataAccess; public CustomerBusinessLogic() { _custDataAccess = DataAccessFactory.GetCustomerDataAccessObj(); } public string GetCustomerName(int id) { return _custDataAccess.GetCustomerName(id); } }
Here is the complete script in both code and graphic form:
public interface ICustomerDataAccess { string GetCustomerName(int id); } public class CustomerDataAccess: ICustomerDataAccess { public CustomerDataAccess() { } public string GetCustomerName(int id) { return "Dummy Customer Name"; } } public class DataAccessFactory { public static ICustomerDataAccess GetCustomerDataAccessObj() { return new CustomerDataAccess(); } } public class CustomerBusinessLogic { ICustomerDataAccess _custDataAccess; public CustomerBusinessLogic() { _custDataAccess = DataAccessFactory.GetCustomerDataAccessObj(); } public string GetCustomerName(int id) { return _custDataAccess.GetCustomerName(id); } }