To build a clean architecture in C#, laying a solid foundation for your codebase is important. One of the fundamentals of clean architecture in C# is adherence to the SOLID principles, which serve as guidelines for designing robust, flexible, and maintainable software.
In this article, let’s discuss the definition of SOLID principles, their importance, and how to apply them in your C# code.
What are SOLID principles?
The SOLID principles were first introduced by Robert J. Martin, also known as Uncle Bob, in his paper “Design Principles and Patterns” in 2000.
These principles have since become essential guidelines for software developers in object-oriented programming. The acronym SOLID defines the following five key software design principles:
- S – Single-responsibility Principle
- O – Open-closed Principle
- L – Liskov Substitution Principle
- I – Interface Segregation Principle
- D – Dependency Inversion Principle
When building modern applications, your goal should be to produce code that guarantees high-quality and adaptable applications. The SOLID principles provide C# developers with guidelines and best practices to create maintainable, scalable, and extensible software systems.
By following these principles, you can get many benefits, such as improving code maintainability, adding new features or functionalities without adverse impacts on the existing codebase, promoting code reusability, and avoiding unnecessary dependencies. This ultimately leads to more efficient and robust C# applications.
Single Responsibility Principle
The single-responsibility Principle (SRP) says, “A class should have one and only one reason to change, meaning that a class should have only one job.“
This implies that a class should avoid methods that simultaneously handle multiple tasks, are tightly coupled, are dependent on one another, contain repetitive or duplicated code, and are excessively long, with hundreds of lines of code.
The SRP has a significant impact on code design. It improves the code readability as it becomes easier to understand the purpose and functionality of the class. When you need to implement new features or fix bugs, you can easily change the specific functionality, making code maintenance more efficient and less error-prone.
SRP promotes the separation of different responsibilities into separate classes.
This separation reduces complexity and simplifies locating and addressing issues or adding new features. SRP also helps to develop reusable classes, increase code efficiency, and reduce redundancy.
Furthermore, classes with SRP are easier to test because you can write focused unit test cases that verify the desired behavior of the class.
Following are two examples of how SRP applies in C# code.
1. The following Student class has several responsibilities, including student registration and course enrollment.
public class Student
{
public string Name { get; set; }
public string Email { get; set; }
public void Register()
{
// Register a student
// Perform validation and save to DB
Console.WriteLine("Registering the student");
}
public void EnrollInCourse()
{
// Enroll the student in a course
// Perform validation and save to DB
Console.WriteLine("Enrolling to the course");
}
}
To follow the SRP, you can split these responsibilities into separate classes.
public class Student
{
public string Name { get; set; }
public string Email { get; set; }
}
public class Registration
{
// Register a student
public void Register(Student student)
{
}
}
public class CourseEnrollment
{
// Enroll the user in a course
public void EnrollStudentInCourse(Student student, Course course)
{
}
}
The Student class only represents the student, and other separate classes handle registration and course enrollment.
2. The following Authentication class violates SRP by including a user registration method with different functionality.
public class Authentication
{
//Register a user
public void RegisterUser(User user)
{
}
// Perform user login
public void Login(string username, string password)
{
}
// User logout
public void Logout()
{
}
}
You can separate the user registration method into a separate class to satisfy the SRP, as in the following code.
public class Registration
{
public void Register(User user)
{
}
}
public class Authentication
{
public void Login(string username, string password)
{
}
public void Logout()
{
}
}
Open/Closed Principle
The Open-closed Principle (OCP) says, “Objects or entities should be open for extension but closed for modification.”
So what does that mean?
It means that a code that follows OCP allows its classes, modules, functions, etc., to be extended so that you can introduce new functionalities to satisfy business requirements. And the goal of the “closed” part of the principle is to reduce the number of changes you make to the existing code.
The OCP enables you to design your software to add new functionality or behaviors without modifying the existing code. This principle promotes using abstraction, inheritance, and interfaces to achieve flexibility and maintainability in software systems. Since OCP promotes modularity, it helps to extend the code independently without affecting the other modules.
Furthermore, OCP improves code-reusability.
If you put the code that you would otherwise duplicate into a separate class, you can reuse the existing code without modification. This also reduces code duplication and increases productivity. In addition, OCP helps improve software testability because of the loosely coupled architecture it promotes.
The following code is an example of applying the Open/Closed Principle in C# code.
Suppose an HR system manages the salaries of different types of employees.
The Employee
abstract class here defines the CalculateBonus
method.
Each employee type inherits the Employee
abstract class and implements the employee-specific bonus calculation functionality.
In this case, the existing code is not modifiable but extensible. Whenever you want to introduce an employee type, you can implement a new concrete class that inherits the Employee
parent abstract class.
public abstract class Employee
{
public abstract double CalculateBonus(double salary);
}
public class Developer : Employee
{
public override double CalculateBonus(double salary)
{
return salary * 12 * 0.2;
}
}
public class Tester : Employee
{
public override double CalculateBonus(double salary)
{
return salary * 12 * 0.1;
}
}
public class Manager : Employee
{
public override double CalculateBonus(double salary)
{
return salary * 12 * 0.5;
}
}
Liskov Substitution Principle
Barbara Liskov introduced the Liskov Substitution Principle (LSP) in 1987. This principle states that if S is a subclass of T, then objects of T can be replaced with objects of S without affecting the applications’ functionality. In simple terms, objects of a superclass should be replaceable with objects of its subclasses without breaking the code.
When understanding LSP, you should know the importance of inheritance in C#. Inheritance allows you to override methods and properties defined in the parent class within the subclasses.
This means you can provide specific implementations of certain behaviors in the subclasses while still inheriting and reusing the rest of the base class’s functionality. This flexibility enables you to customize and extend the behavior of your classes according to your specific requirements.
To follow the LSP, subclasses must-have method signatures like superclasses. Therefore, an overridden subclass method must accept the same input parameter values as the superclass method. This also means that you cannot implement stricter validations in your sub-class because exceptions can be thrown when your try to call methods on an object of the superclass.
In addition, this principle also applies to the return value of a method. The return value of a subclass method must adhere to the rules set by the return value of the corresponding superclass method.
In the following example, the Account
class is an abstract class with an abstract method CheckBalance
for checking the balance.
The Savings
and Current
classes inherit from Account
and provide their own method implementations. By treating instances of Savings
and Current
as instances of the Account
class, you can invoke the CheckBalance
method (Liskov Substitution) without introducing any issues or errors in the program’s functionality.
abstract class Account
{
public abstract void CheckBalance(double amount);
}
class Savings: Account
{
public override void CheckBalance(double amount)
{
Console.WriteLine("savings amount : " + amount);
}
}
class Current: Account
{
public override void CheckBalance(double amount)
{
Console.WriteLine("Current account amount: " + amount);
}
}
class Program
{
static void Main(string[] args)
{
Account savings = new Savings();
savings.CheckBalance(100);
Account current = new Current();
current.CheckBalance(50);
}
}
Interface Segregation Principle
The Interface Segregation Principle (ISP) says, “Clients should not be forced to depend upon interfaces they do not use.” In other words, a class should not be forced to implement a method in an interface it doesn’t require. If a method serves no purpose for a class, there should be no requirement for that class to implement it.
When understanding ISP, you should know the importance of interfaces in C#. Interfaces allow you to define a contract or set of methods a class must implement.
You can enforce encapsulation and abstraction by utilizing interfaces, ensuring that classes adhere to a specific behavior without exposing their internal implementation details. This promotes cleaner code organization and enhances the maintainability of your application.
C# allows a class to implement multiple interfaces. This enables a class to inherit behavior from multiple interfaces, promoting code reuse and providing flexibility in designing complex class relationships. By utilizing interfaces, you can achieve more code flexibility and expressiveness.
In the following example, the IMicrowaveOven
interface has operations specific to a microwave oven, such as setting the power level.
The IConvectionOven
interface implements functionalities specific to a convection oven, such as setting temperature.
public interface IMicrowaveOven
{
void SetPowerLevel(int level);
}
public interface IConvectionOven
{
void SetTemperature(int temperature);
}
The BasicMicrowaveOven
class implements the IMicrowaveOven
interface but does not need to implement functionalities specific to IConvectionOven.
The BasicConvectionOven
class implements the IConvectionOven
interface, and the ConvectionMicrowaveOven
class implements both IMicrowaveOven
and IConvectionOven
interfaces to provide combined functionality.
// Implement the interfaces
public class BasicMicrowaveOven : IMicrowaveOven
{
public void SetPowerLevel(int level)
{
// setting the power level
}
}
public class BasicConvectionOven : IConvectionOven
{
public void SetTemperature(int temperature)
{
// setting temperature
}
}
public class ConvectionMicrowaveOven : IMicrowaveOven, IConvectionOven
{
public void SetPowerLevel(int level)
{
// setting power level
}
public void SetTemperature(int temperature)
{
// setting temperature
}
}
Dependency Inversion Principle
The Dependency Inversion Principle (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.
The DIP help promote loose coupling and facilitates easier maintenance, testing, and future enhancements.
High-level modules typically abstract and complex higher-level tasks and have a broader scope than low-level modules. Such modules incorporate lower-level modules and provide a more simplified interface for the rest of the application.
On the other hand, low-level modules focus on specific lower-level details and implementations of smaller parts of the application. They often support the functionality provided by high-level modules and are designed to be reusable across different parts of the system. Examples of low-level modules in C# include utility classes and database access parts.
There are several techniques to apply DIP in C# code. Following is how you can apply DIP using the constructor injection technique.
The following Service class depends on an ILogger interface for logging. Instead of creating a concrete AccessLogger object inside the Service class, we can inject that dependent object through the constructor. This enables you to decouple the Service class from the AccessLogger class implementation.
public interface ILogger
{
void Log(string message);
}
public class AccessLogger : ILogger
{
public void Log(string message)
{
//log access to the application
}
}
public class Service
{
private readonly ILogger logger;
public Service(ILogger log)
{
logger = log;
}
public void GetDocuments()
{
logger.Log("logging..."");
}
}
Conclusion
SOLID Principles are important software design principles in object-oriented programming that help developers write maintainable, flexible, scalable, and extensible code. SOLID principles in C# development bring several important benefits to software development.
The Single Responsibility Principle (SRP) ensures that each class has a single responsibility, making testing individual components easier.
The Open-Closed Principle (OCP) promotes using abstractions and interfaces, allowing you to add new functionality by implementing new classes rather than modifying existing code.
The Liskov Substitution Principle (LSP) ensures you can use subclasses with their base classes without breaking the application’s functionality.
The Interface Segregation Principle (ISP) helps avoid forcing classes to use unnecessary implementations.
The Dependency Inversion Principle (DIP) promotes decoupling high-level modules from low-level modules, enabling you to build more flexible and testable code.
By following the SOLID principles, you can create code that is easier to understand, maintain, test, and extend. These principles improve code reusability, scalability, and extensibility, ultimately producing high-quality and efficient C# applications.