SOLID Principles with examples in C#
SOLID is the abbreviation for the five principles of object-oriented software design, which helps developers write code that is easily extendable and avoid common coding errors.
- Single responsibility principle(SRP): Each class should have a single responsibility, and that responsibility should be fully encapsulated by the class.
- Open-closed principle(OCP): Classes must be designed in such a way that they are easily extensible without modifying their source code.
- Liskov substitution principle(LSP): Derived classes must be substitutable for their base classes.
- Interface segregation principle(ISP): Classes should not be forced to implement interfaces that they do not use.
- Dependency inversion principle(DIP): Classes should depend on abstractions, not concrete implementations.
In this article, each SOLID principle will be explored through practical coding examples in C#.
Example Scenario: Client and Employee Management System
Suppose we’re developing a simple system to manage clients and employees in a company.
1. Single Responsibility Principle (SRP)
Example:
# Before SRP :
public class Client
{
public string Name { get; set; }
public string Email { get; set; }
public void AddClient()
{
// Logic to add client to database
}
public void SendEmail()
{
// Logic to send email to client
}
}
In the initial block of code, there’s a C# class called Client which handles multiple tasks such as adding a client to the database and sending emails. The Single Responsibility Principle (SRP) requires each class to have only one responsibility. Therefore, in C#, when creating a class for managing Client data, ensure it focuses solely on that task. If there’s a need to handle data saving, it’s better to create a separate class for that purpose.
Here’s how it applies :
- Client class: Represents client data.
- ClientService class: Focuses on adding a client to the database. It aligns with the principle by handling a single responsibility.
- EmailService class: Responsible for sending emails to clients. Again, it adheres to SRP by having a single responsibility.
So, in alignment with the principle, each class is responsible for one specific task, fulfilling the Single Responsibility Principle.
# After SRP :
public class Client
{
public string Name { get; set; }
public string Email { get; set; }
}
public class ClientService
{
public void AddClient(Client client)
{
// Logic to add client to database
}
}
public class EmailService
{
public void SendEmail(string email)
{
// Logic to send email
}
}
2. Open-closed principle (OCP)
Each software entities (classes, modules, etc.) should be open for extension but closed for modification. and we can achieve this by using abstraction and inheritance.
Example :
# Before OCP :
public class Employee
{
public virtual double CalculateSalary()
{
return 0;
}
}
public class PermanentEmployee : Employee
{
public override double CalculateSalary()
{
// Logic to calculate salary for permanent employee
}
}
public class ContractEmployee : Employee
{
public override double CalculateSalary()
{
// Logic to calculate salary for contract employee
}
}
Adding a new type of employee (e.g., PartTimeEmployee) would require modifying existing classes, violating OCP.
Here’s how it applies:
- Employee class: Represents employee data.
- PermanentEmployee class: Handles the calculation of salary for permanent employees, adhering to OCP by being open for extension.
- ContractEmployee class: Handles the calculation of salary for contract employees, also following OCP.
So, in alignment with the principle, classes should be open for extension but closed for modification.
# After OCP :
public abstract class Employee
{
public abstract double CalculateSalary();
}
// Implementations of PermanentEmployee, ContractEmployee, etc.
public class PermanentEmployee : Employee
{
public override double CalculateSalary()
{
// Logic to calculate salary for permanent employee
}
}
public class ContractEmployee : Employee
{
public override double CalculateSalary()
{
// Logic to calculate salary for contract employee
}
}
3. Liskov substitution principle (LSP)
Example: Show a scenario where subtype instances cannot be substituted for their base type.
# Before LSP :
public class Employee
{
public virtual void AttendMeeting()
{
// Logic to attend meeting
}
}
public class Manager : Employee
{
public override void AttendMeeting()
{
// Logic to attend meeting as a manager
}
}
public class Developer : Employee
{
// Developer does not attend meetings
}
In the initial block of code, Developers should not inherit from Employee if they don’t attend meetings, violating LSP.
Here’s how it applies: Ensure subtypes can be substituted for their base types.
- Employee class: Represents employee data and behavior.
- Manager class: Represents a manager, which attends meetings differently than regular employees.
- Developer class: Represents a developer, which doesn’t attend meetings.
So, in alignment with the principle, subclasses should be substitutable for their base classes.
# After LSP :
// No changes in Employee class
public class Manager : Employee
{
public override void AttendMeeting()
{
// Logic to attend meeting as a manager
}
}
public class Developer
{
// Developer does not implement Employee class
}
Before the refactoring, the Developer class inherited from Employee but didn’t need to attend meetings, violating LSP. After refactoring, Developer no longer inherits from Employee, ensuring adherence to the principle.
4. Interface Segregation Principle (ISP)
Example:
# Before ISP :
public interface IEmployee
{
void AddEmployee();
void DeleteEmployee();
void CalculateSalary();
}
public class HRDepartment : IEmployee
{
public void AddEmployee()
{
// Logic to add employee
}
public void DeleteEmployee()
{
// Logic to delete employee
}
public void CalculateSalary()
{
// Logic to calculate salary
}
}
IEmployee interface: Represents common behaviors of employees and HRDepartment class Implements IEmployee but may not need all its methods, violating ISP.
Here’s how it applies: Ensure that each class should not be forced to depend on methods they do not use.
- IEmployee interface: Represents common behaviors of employees.
- IAddable interface: Represents the behavior of adding an item.
- IDeletable interface: Represents the behavior of deleting an item.
- ISalaryCalculable interface: Represents the behavior of calculating salary.
So, in alignment with the principle, clients should not be forced to depend on methods they do not use.
# After ISP :
// No changes in IEmployee interface
public interface IAddable
{
void Add();
}
public interface IDeletable
{
void Delete();
}
public interface ISalaryCalculable
{
void CalculateSalary();
}
public class HRDepartment : IAddable, IDeletable, ISalaryCalculable
{
// Implementations
}
Before the refactoring, the HRDepartment class implemented IEmployee interface which contained methods it didn’t need, violating ISP. After refactoring, the interface is split into smaller, more specific interfaces, allowing clients to implement only the methods they need.
5. Dependency Inversion Principle (DIP)
The dependency inversion principle is the fifth principle of Solid Principles, which is described as follows. This principle indicates that loose coupling between high level and low-level classes should exist .To get this, loose coupling elements should rely on abstraction. In straightforward terms, it suggests that classes should rely on abstract classes/interfaces and not on concrete types.
Example :
# Before DIP :
public class ClientService
{
private Database _database;
public ClientService()
{
_database = new Database();
}
public void AddClient(Client client)
{
_database.Add(client);
}
}
ClientService directly depends on the Database class, violating DIP.
Here’s how it applies:
- IDataService interface: Represents a service for interacting with data.
- Database class: Implements IDataService and handles database interactions.
- ClientService class: Depends on IDataService, following DIP by depending on abstractions rather than concrete implementations.
So, in alignment with the principle, high-level modules should not depend on low-level modules. Both should depend on abstractions.
# After DIP :
public interface IDataService<T>
{
void Add(T item);
}
public class Database : IDataService<Client>
{
public void Add(Client client)
{
// Logic to add client to database
}
}
public class ClientService
{
private IDataService<Client> _dataService;
public ClientService(IDataService<Client> dataService)
{
_dataService = dataService;
}
public void AddClient(Client client)
{
_dataService.Add(client);
}
}
Conclusion
While the benefits of applying SOLID principles may not be immediately apparent in smaller applications, they become invaluable as projects grow in size and complexity. By adhering to SOLID, developers can ensure their code remains clean, consistent, and maintainable, even as the software evolves and expands.