SOLID is a mnemonic acronym used in Object-Orientated Programming for five design principles. Incorporating SOLID into your project should make your code more flexible, maintainable and understandable. SOLID stands for:
- SRP - Single Responsibility Principle
- OCP - Open Closed Principle
- LSP - Liskov Substitution Principle
- ISP - Interface Segregation Principle
- DIP - Dependency Inversion Principle
The Single Responsibility Principle states that a class or method should only be responsible for one task. This also means that a developer should only have one reason to change the behavior of a method or class. When a class only does one thing it mitigates against any unwanted side effects of changing the behavior of the class.
To see if you are breaking the Single Responsibility Principle, ask yourself what is the role of your class. If your explanation involves the word 'and' you are probably breaking the Single Responsibility Principle.
The responsibility of the following class currently is to calculate the annual pay and hourly pay of a permanent employee.
public class PermanentEmployeeServices
{
public decimal CalculateAnnualPay(decimal AnnualSalary, decimal AnnualBonus)
{
decimal annualPay = AnnualSalary + AnnualBonus;
return annualPay;
}
public decimal CalculateHourlyPay(decimal AnnualSalary)
{
decimal hourlyPay = ((AnnualSalary / 52) / 5) / 7;
return hourlyPay;
}
}
Also there are now two reasons to change the behavior of this class, if we want to change the behavior of how we calculate annual pay and if we want to change the behavior of how we calculate hourly pay.
This class should be separated into two classes. The first class will have the sole responsibility for calculating annual pay for an employee. The second will have the sole responsibility of calculating hourly pay.
public class AnnualPay
{
public decimal Calculate(decimal AnnualSalary, decimal AnnualBonus)
{
decimal annualPay = AnnualSalary + AnnualBonus;
return annualPay;
}
}
public class HourlyPay
{
public decimal Calculate(decimal AnnualSalary)
{
decimal hourlyPay = ((AnnualSalary / 52) / 5) / 7;
return hourlyPay;
}
}
Now when discussing the role of the AnnualPay class it would be fair to say it is responsible for calculating the annual pay. It now has only one responsibility so conforms to the Single Responsibility Principle.
The Open - Closed Principle states that "software entities should be open for extension but closed for modification."
This means that when adding new functionality, the behavior of an existing method should not be changed as this could effect the behavior of other aspects of the code that expect the method to work in a certain way.
To add the new functionality the new method should be able to be extended. Being able to extend code means that the functionality of legacy systems is not effected whilst providing new functionality where required.
Using the current example of the HourlyPay
method shown above lets pretend that we need to change the method to factor in an 8.5 hr working day instead of a 7 hr working day. This is simple to do for this method but should be avoided.
Instead we first need to change the method so it can be extended. In C# this can be done with the virtual
reserved word.
public class HourlyPay
{
public virtual decimal Calculate(decimal AnnualSalary)
{
decimal hourlyPay = ((AnnualSalary / 52) / 5) / 7;
return hourlyPay;
}
}
We now need to create a child class that inherits from HourlyPay
. In this class we can create the new functionality without impacting anything that may rely on the base class.
public class BetterPermanentEmployeeServices: PermanentEmployeeServices
{
public override decimal CalculateHourlyPay(decimal AnnualSalary)
{
decimal hourlyPay = (((AnnualSalary / 52) / 5) / 8.5m);
return hourlyPay;
}
}
The Loskiv Substitution Principle states that objects of the superclass should be replaceable by objects of the subclass. This means that all objects of the subclass will need to behave in the same way as the objects of its superclass. To do this overridden methods of the subclass shall accept the same parameter values as the method from the superclass. To achieve this data validation performed by the subclass cannot be more restrictive than the data validation performed by the superclass.
It can be easy to assume a type is a type of something in the real world but translating this into code can lead to issues.
Your organization has two different types of employee, permanent employees and contracted/ temporary employees. Both of these types of employee share similar properties:
- name,
- employee ID,
- start date,
- end date.
These shared properties might encourage you to create a superclass called employee
that both the tempEmployee
and permanentEmployee
classes would inherit from. So far, so good.
Now we need to calculate the hourly pay for both types of employee. For the tempEmployee
the hourly rate should be calculated by dividing their daily rate by the number of hours worked. For the permanentEmployee
the hourly rate should be calculated by dividing the sum of their annual salary and annual bonus by the number of working hours in a year. In each class we create a CalculateHourlyPay
method. But with the tempEmployee
class we require the parameter dailyRate
and with the permanentEmployee
class we require both annualSalary
and annualBonus
as parameters.
This is where the problem lies, when we are dealing with employee
we cannot switch to working with the type permanentEmployee
or tempEmployee
because of the different parameters required by each types CalculateHourlyPay
method. This breaks the Loskiv Substitution Principle.
The solve this it should be considered that if a permanent employee really is a employee. Just because they share some properties does not mean a 'is-a' relationship exists between them.
When creating data models using inheritance is appropriate when they share properties. When writing classes inheritance should be used where behavior is shared.
The Interface Segregation aims to solve the problem of having fat interfaces or polluted interfaces. A fat or polluted interface is an interface that forces its clients to provide implementations for methods that they do not need. A common symptom of this is having client code that throws a NotImplemented
exception message. To solve this the fat interface should be broken down into separate interfaces that contain less method definitions.
Below is a repository interface for completing CRUD operations for a permanent employee.
public interface IPermanentRepo
{
PermanentEmployee CreatePermanentEmployee(PermanentEmployee employee);
List<PermanentEmployee> ReadPermanentEmployee(string Name);
List<PermanentEmployee> ReadAllPermanentEmployees();
bool DeletePermanentEmployee(PermanentEmployee employee);
bool UpdatePermanentEmployee(PermanentEmployee employee, string field, string value);
bool CheckPermanentEmployeeExists(string Name, out PermanentEmployee employee);
}
When using the interface the client code is required to provide method definitions for all 4 CRUD operations. What if you wanted a repository that could only read permanent employees? You would have to use this interface and use the NotImplemented
exception. This is now a fat interface. To solve this we should split up this interface into multiple, smaller interfaces.
public interface ICreatePermanent
{
PermanentEmployee CreatePermanentEmployee(PermanentEmployee employee);
}
public interface IReadPermanent
{
List<PermanentEmployee> ReadPermanentEmployee(string Name);
List<PermanentEmployee> ReadAllPermanentEmployees();
}
public interface IUpdatePermanent
{
bool UpdatePermanentEmployee(PermanentEmployee employee, string field, string value);
}
public interface IDeletePermanent
{
bool DeletePermanentEmployee(PermanentEmployee employee);
}
Now a repository can have more granular control over what methods they have to provide a method implementation for and adheres to the Interface Segregation Principle.
But what if we want to have a repository that uses all 4 of the CRUD operations. For this there are two options. We could simply inherit every interface individually but this can look messy and could cause confusion. Instead we can user interface inheritance to create a new interface that inherits from the individual interfaces. We then inherit this new interface!
public interface IPermanentRepo: ICreatePermanent, IReadPermanent, IUpdatePermanent, IDeletePermanent
The aim of the Dependency Inversion Principle is to ensure that software modules are loosely coupled. High level modules should be independent of low level modules. I.e. your business logic should not care what file system you are using.
The principle states that:
- High-level modules should not depend on the low-level modules. Both should depend on abstractions.
- Abstractions should not depend on details. Details should depend on abstractions.
This principle can be followed by using interfaces. A class should implement an interface. When an instance of this class is required or being referenced, the interface should be referenced instead. This ensures that the class referencing the interface does not depend on the details of how that interface is implemented. It also makes it easy to use a different class in your code, as long as it implements the same interface then the client code will not notice that a different implementation is being used.
This helps with implementing Dependency Injection. Any dependencies that a class has should be defined in the constructor of a class. The constructor should require an object that implements an interface as a parameter. This is called constructor dependency injection. This loose coupling also makes unit tests easier to write as interfaces are easier to mock.
Using Dependency Inversion allows you to use the dependency injection containers provided by .NETCore.