In the field of software development, writing scalable, flexible, and maintainable code is essential to developing reliable apps. SOLID principles, which encourage clear and modular object-oriented design, offer a set of guidelines that assist developers in achieving these objectives. In this piece, we’ll examine the five SOLID principles and see how they help create maintainable, high-quality code.
A popular paradigm in software development, object-oriented programming (OOP) enables programmers to write scalable, maintainable, and effective code. Since Robert C. Martin first presented these Solid Principles of OOP, they have grown to be the cornerstone for creating reliable and adaptable software. We will examine each SOLID principle in detail and illustrate their importance with instances from everyday life.
Single Responsibility Principle (SRP)
According to the Single Responsibility Principle, a class should only alter for one reason at a time. Stated differently, a class ought to be accountable for a solitary, precisely defined task or feature. This idea promotes a strong sense of unity among students, which facilitates comprehension, upkeep, and modification.
Let’s look at an example of how SRP can be applied to make our RegisterUser class more readable.
// does not follow SRP
public class RegisterService
{
public void RegisterUser(string username)
{
if (username == "admin")
throw new InvalidOperationException();
SqlConnection connection = new SqlConnection();
connection.Open();
SqlCommand command = new SqlCommand("INSERT INTO [...]");//Insert user into database.
SmtpClient client = new SmtpClient("smtp.myhost.com");
client.Send(new MailMessage()); //Send a welcome email.
}
}
The aforementioned program does not adhere to SRP since RegisterUser performs three distinct tasks: sending an email, connecting to the database, and registering a user.
Larger projects would need clarification on this kind of class because email generation and registration are unexpected to occur in the same class.
Additionally, there are a lot of other things that could lead to this code change, such as changing the database schema or using a different email API.
Rather, we must divide the class into three distinct classes, each of which completes a single task. This is how our same class would appear if every other job were broken up into different classes:
public void RegisterUser(string username)
{
if (username == "admin")
throw new InvalidOperationException();
_userRepository.Insert(...);
_emailService.Send(...);
}
This achieves the SRP because RegisterUser only registers a user and the only reason it would change is if more username restrictions are added. All other behavior is maintained in the program but is now achieved with calls to userRepository and emailService.
Open/Closed Principle (OCP)
Software entities (classes, modules, and functions) are encouraged to be open for expansion but closed for modification according to the Open-Closed Principle. It implies that you should write your code so that new features can be added without changing the already-written code. This principle promotes the creation of modular and extensible systems through the use of abstractions, interfaces, and inheritance. Following OCP makes adding new features or behaviors simple without creating regression bugs in the current code.
Rather than hard-coding specific scenarios, OCP implementations frequently use polymorphism and abstraction to code behavior at the class level. Let’s examine how to adapt an area calculator software to adhere to OCP:
// Does not follow OCP
public double Area(object[] shapes)
{
double area = 0;
foreach (var shape in shapes)
{
if (shape is Rectangle)
{
Rectangle rectangle = (Rectangle) shape;
area += rectangle.Width*rectangle.Height;
}
else
{
Circle circle = (Circle)shape;
area += circle.Radius * circle.Radius * Math.PI;
}
}
return area;
}
public class AreaCalculator
{
public double Area(Rectangle[] shapes)
{
double area = 0;
foreach (var shape in shapes)
{
area += shape.Width*shape.Height;
}
return area;
}
}
Because Area() can only handle Rectangle and Circle shapes and is not open to extensions, this program does not adhere to OCP. The method is not closed to modification; in order to add support for Triangle, we would need to make some modifications.
By including an abstract class Shape that all shapes inherit, we can achieve OCP.
public abstract class Shape
{
public abstract double Area();
}
public class Rectangle : Shape
{
public double Width { get; set; }
public double Height { get; set; }
public override double Area()
{
return Width*Height;
}
}
public class Circle : Shape
{
public double Radius { get; set; }
public override double Area()
{
return Radius*Radius*Math.PI;
}
}
public double Area(Shape[] shapes)
{
double area = 0;
foreach (var shape in shapes)
{
area += shape.Area();
}
return area;
}
Now, polymorphism allows each shape subtype to calculate its own area. Because a new shape can be added quickly and error-free with its own area calculation, this allows the Shape class to be extended.
Furthermore, the original shape is not altered by the program, and it won’t require alteration in the future. The program now fulfills the OCP principle as a result.
Liskov Substitution Principle (LSP)
The behavior of subtypes in respect to their base types is the main subject of the Liskov Substitution Principle. It says that superclass objects should be interchangeable with subclass objects without compromising program correctness. Stated differently, subclasses ought to function indistinguishably with their parent class. By following LSP, you can enable polymorphism and encourage code reuse by ensuring that the behavior and contracts defined by the base class are maintained by its subclasses.
Polymorphism is used in the majority of LSP implementations to provide class-specific behavior for the same calls. Let’s look at how we can modify a fruit categorization program to apply LSP to illustrate the LSP principle.
This illustration does not adhere 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 take the place of the Apple class without changing the program output, this does not adhere to LSP. Since the Orange class overrides the GetColor() method, it would return that an apple is orange.
We’ll add an abstract Fruit class that Apple and Orange will both implement in order to fix this.
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 GetColor()’s class-specific behavior, any subtype of the Fruit class—Apple or Orange—can now be substituted with the other subtype without causing an error. Consequently, the LSP principle is now achieved by this program.
Interface Segregation Principle (ISP)
The Interface Segregation Principle emphasizes that clients should not be forced to depend on interfaces they do not use. It suggests that you should create specific interfaces that are tailored to the needs of the clients consuming them. This principle helps avoid bloated interfaces and minimizes the impact of changes on client code. By separating interfaces into smaller, focused units, you enable clients to depend only on the interfaces that are relevant to them, leading to more flexible and maintainable code.
Let us examine how a program changes both with and without adhering to the ISP principle to see how the principle manifests itself in code.
First, the non-ISP-following program:
// 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 follow ISP because the FullTimeEmployee class does not need the CalculateWorkedSalary() function, and the ContractEmployeeclass
does not need the CalculateNetSalary().
Neither of these methods advance the goal of these classes. Instead, they are implemented because they are derived classes of the IWorker interface.
Here’s how we could refactor the program to follow the ISP principle:
// Following the Interface Segregation Principle
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;
}
The general interface IWorker has been divided into two child interfaces, IFullTimeWorkerSalary and IContractWorkerSalary, and one base interface, IBaseWorker, in this version.
Shared methods are found in the general interface. The child interfaces divide up the methods based on the type of worker: contract, which is paid hourly, or full-time with a salary.
Our classes can now access all of the properties and methods in the base class as well as the worker-specific interface by implementing the interface for that kind of worker.
The end classes now only include properties and methods that help them accomplish their objective and uphold the ISP principle.
Dependency Inversion Principle (DIP)
The separation of high-level modules from low-level implementation details is the main goal of the Dependency Inversion Principle. It implies that abstractions, as opposed to specific implementations, should be the basis for high-level modules. This idea encourages loose coupling and makes it simpler to replace dependencies. Abstractions allow you to extend or modify a system’s behavior without changing its essential parts, which increases the code’s flexibility and testability.
We’ll develop a general business program with detailed, high-level, and low-level components as well as an interface.
Let’s start by utilizing the getCustomerName() method to create an interface. It will be seen by our users.
public interface ICustomerDataAccess
{
string GetCustomerName(int id);
}
We will now put details that rely on the ICustomerDataAccess interface into practice. By doing this, the DIP principle’s second part is realized.
public class CustomerDataAccess: ICustomerDataAccess
{
public CustomerDataAccess() {
}
public string GetCustomerName(int id) {
return "Dummy Customer Name";
}
}
Next, we’ll develop a factory class that returns the abstract interface ICustomerDataAccess in a form that can be used. Our low-level component is the CustomerDataAccess class that is returned.
public class DataAccessFactory
{
public static ICustomerDataAccess GetCustomerDataAccessObj()
{
return new CustomerDataAccess();
}
}
Lastly, we will implement CustomerBuisnessLogic, a high-level component that implements the ICustomerDataAccess interface as well. Take note of the fact 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);
}
}
This is the complete program in a visual chart and code:
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);
}
}
Modular, flexible, and maintainable object-oriented system design is made possible by the SOLID principles. These guidelines can help developers write code that is simpler to test, comprehend, and expand upon in the future. Better code quality, less code duplication, more reusability, and overall better software design are the results of applying the SOLID principles. By adopting these ideas, developers can create applications that are flexible and scalable, able to change as needs and technology do.