C# | SOLID Principles
SOLID stands for:
- S = Single Responsibility Principle
- O = Open/Closed Principle
- L = Liskov Substitution Principle
- I = Interface Segregation Principle
- D = Dependency Inversion Principle
Single Responsibility Principle
Each software module should have one and only one reason to change.
The Single Responsibility Principle is the first principle of SOLID principles. It is the fundamental principle of object-oriented programming that determines how we should design classes.
The Single Responsibility Principle states that:
In other words, a class should have only one responsibility and therefore it should have only one reason to change its code. If a class has more than one responsibility, then there will be more than one reason to change the class (code).
Now, the question is what is responsibility?
An application can have many functionalities (features). For example, an online e-commerce application has many features such as displaying product lists, submitting an order, displaying product ratings, managing customers' shipping addresses, managing payments, etc. Along with these features, it also validates and persists products and customers' data, logs activities for auditing and security purposes, applies business rules, etc. You can think of these points as functionalities or features or responsibilities. Change in any functionality leads to change in the class that is responsible for that functionality.
Let's check how many responsibilities the following Student
class has:
public class Student
{
public int StudentId { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public DateTime DoB { get; set; }
public string email { get; set; }
public string Address1 { get; set; }
public string Address2 { get; set; }
public string City { get; set; }
public string State { get; set; }
public string Zipcode { get; set; }
public void Save()
{
Console.WriteLine("Starting Save()");
//use EF to save student to DB
Console.WriteLine("End Save()");
}
public void Delete()
{
Console.WriteLine("Starting Delete()");
//check if already subscribed courses then don't delete
Console.WriteLine("End Delete()");
}
public IList<Course> Subscribe(Course cs)
{
Console.WriteLine("Starting Subscribe()");
//apply business rules based on the course type
if(cs.Type == "online")
{
//validate
}
else if(cs.type == "live")
{
}
//payment processing code
//save course subscription to DB
//send email confirmation code
Console.WriteLine("End Subscribe()");
}
}
The above Student
class has the following responsibilities:
- Holds student's properties such as
StudentId
,FirstName
,LastName
, andDoB
. - Save a new student, or update an existing student to a database.
- Delete existing students from the database if not subscribed to any course.
- Apply business rules to subscribe to courses based on the course type.
- Process the payment for the course.
- Send confirmation email to a student upon successful registration.
- Logs each activity to the console.
If anything in the above responsibility changes, then we will have to modify the Student
class. For example, if you need to add a new property then we need to change the Student
class. Or, if you need a change in the database, maybe moving from a local server to a cloud, then you need to change the code of the Student
class. Or, if you need to change the business rules (validation) before deleting a student or subscribing to a course, or change the logging medium from console to file, then in all these cases you need to change the code of the Student
class. Thus, you have many reasons to change the code because it has many responsibilities.
SRP tells us to have only one reason to change a class. Let's change the Student
class considering SRP where we will keep only one responsibility for the Student
class and abstract away (delegate) other responsibilities to other classes.
Start with each responsibility mentioned above and decide whether we should delegate it to other classes or not.
- The
Student
class should contain all the properties and methods which are specific to the student. Except for theSubscribe()
method, all the properties and methods are related to the student, so keep all the properties. - The
Save()
andDelete()
method is also specific to a student. Although, it uses Entity Framework to do the CRUD operation which is another reason to change theStudent
class. We should move the underlying EF code to another class to do all DB operations e.g.StudentRepository
class should be created for all CRUD operations for theStudent
. This way if any changes on the DB side then we may need to change only theStudentRepository
class. - The
Subscribe()
method is more suitable for theCourse
class because a course can have different subscription rules based on the course type. So it is idle to move theSubscribe()
method to theCourse
class. - Sending confirmation emails is also a part of the
Subscribe()
method, so it will be a part of theCourse
class now. Although, we will create a separate classEmailManger
for sending emails. - Here, all the activities are logged on the console using the hard-coded
Console.WriteLine()
method. Any changes in the logging requirement would cause theStudent
class to change. For example, if the admin decides to log all activities in the text file then you need to change theStudent
class. So, it's better to create a separateLogger
class that is responsible for all the logging activities.
Now, look at the following classes redesigned after applying SRP using the above considerations for SRP.
public class Student
{
public int StudentId { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public DateTime DoB { get; set; }
public string email { get; set; }
public string Address1 { get; set; }
public string Address2 { get; set; }
public string City { get; set; }
public string State { get; set; }
public string Zipcode { get; set; }
public void Save()
{
Logger.Log("Starting Save()");
_studentRepo.Save(this);
Logger.Log("End Save()");
}
public void Delete()
{
Logger.Log("Starting Delete()");
//check if already subscribed courses
_studentRepo.Delete(this);
Logger.Log("End Delete()");
}
}
Public class Logger
{
Public static void Log(string message)
{
Console.WriteLine(message);
}
}
public class StudentRepository()
{
Public bool Save(Student std)
{
Logger.log("Starting Save()");
//use EF to add a new student or update existing student to db
Logger.log("Ending Saving()");
}
public bool Delete()
{
Logger.log("Starting Delete()");
//use EF to delete a student
Logger.Log("Ending Delete()");
}
public bool SaveCourse(Student std, Course cs)
{
Logger.log("Starting SaveCourse()");
//use EF to save a course for a student
Logger.Log("Ending SaveCourse()");
}
}
public class Course
{
public int CourseId { get; set; }
public string Title { get; set; }
public string Type { get; set; }
public void Subscribe(Student std)
{
Logger.Log("Starting Subscribe()");
//apply business rules based on the course type live, online, offline, if any
if (this.Type == "online")
{
//subscribe to online course
}
else if (this.Type == "live")
{
//subscribe to offline course
}
// payment processing
PaymentManager.ProcessPayment();
//create CourseRepository class to save student and course into StudentCourse table
// send confirmation email
EmailManager.SendEmail();
Logger.Log("End Subscribe()");
}
}
Public class EmailManager
{
Public static void SendEmail(string recEmailed, string senderEmailId, string subject, string message)
{
// smtp code here
}
}
Public class PaymentManger
{
Public static void ProcessPayment()
{
//payment processing code here
}
}
Now, think about the above classes. Each class has a single responsibility. The Student
class contains properties and methods specific to the student-related activities. The Course
class has course-related responsibilities. The StudentRepository
has responsibilities for student-related CRUD operations using Entity Framework. The Logger
class is responsible for logging activity. The EmailManager
class has email-related responsibilities. The PaymentManager
class has payment-related activities.
In this way, we have delegated specific responsibilities to separate classes so that each class has only one reason to change. These increase cohesion and loose coupling.
Separation of Concerns
The Single Responsibility Principle follows another principle called Separation of Concerns.
Separation of Concerns suggests that the application should be separated into distinct sections where each section addresses a separate concern or set of information that affects the program. It means that high-level business logic should avoid dealing with low-level implementation.
In our example, we separated each concern into separate classes. We had only one Student
class initially, but then we separated each concern like CRUD operations, logging, email, etc. into separate classes. Thus, the Student
class (high-level class) does not have any idea how CRUD or sending emails is happening. It just use the appropriate method and that's it.
The SRP and Separation of Concerns principle increase cohesion and loose coupling.
Open/Closed Principle
Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.
The Open/Closed Principle (OCP) is the second principle of SOLID. Dr. Bertrand Meyer originated this term in his book Object-oriented Software Construction.
Open/Cloced Principle states that:
Now, what does it mean by extension and modification?
Here, the extension means adding new features to the system without modifying that system. The plugin systems are the main example of OCP where new features are added using new features without modifying the existing ones.
OCP says the behavior of a method of a class should be changed without modifying its source code. You should not edit the code of a method (bug fixes are ok) instead you should use polymorphism or other techniques to change what it does. Adding new functionality by writing new code.
In C#, Open/Closed principle can be applied using the following approaches:
- Using Function Parameters
- Using Extension methods
- Using Classes, Abstract class, or Interface-based Inheritance
- Generics
- Composition and Dependency Injection
To demonstrate OCP, let's take an example of the Logger
class shown below. Assume that you are the creator of this class and other programmers want to reuse your class so that they don't have to spend time to rewriting it (SOLID principles promote reusability).
public class Logger
{
public void Log(string message)
{
Console.WriteLine(message);
}
public void Info(string message)
{
Console.WriteLine($"Info: {message}");
}
public void Debug(string message)
{
Console.WriteLine($"Debug: {message}");
}
}
Now, some developers want to change debug messages to suit their needs. For example, they want to start debugging messages with "Dev Debug ->"
. So, to satisfy their need, you need to edit the code of the Logger
class and either create a new method for them or modify the existing Debug()
method. If you change the existing Debug()
method then the other developers who don't want this change will also be affected.
One way to use OCP and solve this problem is to use class based-inheritance (polymorphism) and override methods. You can mark all the methods of the Logger
class as virtual
so that if somebody wants to change any of the methods then they can inherit the Logger
class into a new class and override it.
public class Logger
{
public virtual void Log(string message)
{
Console.WriteLine(message);
}
public virtual void Info(string message)
{
Console.WriteLine($"Info: {message}");
}
public virtual void Debug(string message)
{
Console.WriteLine($"Debug: {message}");
}
}
Now, a new class can inherit the Logger
class and change one or more method behavior. The developers who wanted to change the debug message will create a new class, inherit the Logger
class and override the Debug()
method to display the message they wanted, as shown below.
public class NewLogger : Logger
{
public override void Debug(string message)
{
Console.WriteLine($"Dev Debug -> {message}");
}
}
They will now use the above class to display debug messages they want without editing the source code of the original class.
public class Program
{
public static void Main(string[] args)
{
Logger logger = new Logger();
logger.Debug("Testing debug");
Logger newlogger = new NewLogger();
newlogger.Debug("Testing debug ");
}
}
Debug: Testing debug
Dev Debug -> Testing debug
Thus, OCP using inheritance makes it "Open for extension and closed for modification".
Let's take another example. The following is the Course
class that we created in the previous SRP section.
public class Course
{
public int CourseId { get; set; }
public string Title { get; set; }
public string Type { get; set; }
public void Subscribe(Student std)
{
Logger.Log("Starting Subscribe()");
//apply business rules based on the course type live, online, offline, if any
if (this.Type == "online")
{
//subscribe to online course
}
else if (this.Type == "offline")
{
//subscribe to offline course
}
// payment processing
PaymentManager.ProcessPayment();
//create CourseRepository class to save student and course into StudentCourse table
// send confirmation email
EmailManager.SendEmail();
Logger.Log("End Subscribe()");
}
}
We will have to edit the above Course
class whenever there is a requirement of adding a new type of course. We will have to add one more if condition or switch cases to process the course type. Also, the above Course
class does not follow the Single Responsibility Principle because if there is any change in the process of subscribing to courses or need to add new types of courses, then we will have to change the Course
class.
To apply OCP to our Course
class, abstract class-based inheritance is more suitable. We can create an abstract class as a base class and then create a new class for each type of course and implement the Subscribe()
method in each class which will do all the necessary subscription steps, as shown below.
public abstract class Course
{
public int CourseId { get; set; }
public string Title { get; set; }
public abstract void Subscribe(Student std);
}
public class OnlineCourse : Course
{
public override void Subscribe(Student std)
{
//write code to subscribe to an online course
}
}
public class OfflineCourse : Course
{
public override void Subscribe(Student std)
{
//write code to subscribe to a offline course
}
}
As you can see, the Course
class is now an abstract class where the Subscribe()
method is an abstract method that needs to be implemented in a class that inherits the Course
class. This way there is a separate Subscribe()
function for separate course types (Separation of concerns). You can create a new class for a new type of course in the future that inherits the Course
class. That way, you don't have to edit the existing classes.
You can now subscribe a student to a course, as shown below:
public class Program
{
public static void Main(string[] args)
{
Student std = new Student() { FirstName = "Steve", LastName = "Jobs" };
Course onlineSoftwareEngCourse = new OnlineCourse() { Title = "Software Engneering" };
onlineSoftwareEngCourse.Subscribe(std);
}
}
Advantages of OCP:
- Minimize the possibilities of error by not modifying existing classes.
- Easily add new functionalities by adding new classes wherein no current functionality depends on the new classes.
- Promote the Single Responsibility Principle
- Unit test each class
Liskov Substitution Principle
Subtypes must be substitutable for their base type.
Liskov Substitution Principle was introduced by Barbara Liskov in 1987. She described this principle in mathematical terms as below:
LSP guides how to use inheritance in object-oriented programming. It is about subtyping, and how to correctly derive a type from a base type. Robert Martin explains LSP as below:
Here, the type can be interface, class, or abstract class in C#.
Let's simplify it further. A derived class must be correctly substitutable for its base class. When you derived a class from a base class then the derived class should correctly implement all the methods of the base class. It should not remove some methods by throwing NotImplementedException
.
Consider the following IMyCollection
interface which can be implemented to create any type of collection class.
public interface IMyCollection
{
void Add(int item);
void Remove(int item);
int Get(int idex);
}
public class MyReadOnlyCollection : IMyCollection
{
private IList _collection;
public MyReadOnlyCollection(IList<int> col)
{
_collection = col;
}
public void Add(int item)
{
throw new NotImplementedException();
}
public int Get(int index)
{
return _collection[index];
}
public void Remove(int item)
{
throw new NotImplementedException();
}
}
The above example violates the Liskov Substitution principle because the MyReadOnlyCollection
class implements the IMyCollection
interface but it throws NotImplementedException
for two methods Add()
and Remove()
because the MyReadOnlyCollection
class is for the read-only collection so you cannot add or remove any item. LSP suggests that the subtype must be substitutable for the base class or base interface. In the above example, we should create another interface for read-only collection without Add()
and Remove()
methods.
Let's understand what is the meaning of "A derived class should correctly implement methods of a base class".
Consider the following Rectangle
class:
public class Rectangle {
public virtual int Height { get; set; }
public virtual int Width { get; set; }
}
Mathematically, a square is the same as a rectangle that has four equal sides. We can use inheritance "is-a" relationship here. A square is a rectangle. The Square
class can inherit the Rectangle
class with equal height and width, as shown below.
class Square : Rectangle
{
private int _height;
private int _width;
public override int Height
{
get { return _height; }
set {
_height = value;
_width = value;
}
}
public override int Width
{
get { return _width; }
set
{
_width = value;
_height = value;
}
}
}
The following calculates the area of a rectangle:
public class AreaCalculator
{
public static int CalculateArea(Rectangle r)
{
return r.Height * r.Width;
}
}
Now, the following returns the wrong results:
Rectangle sqr1 = new Square();
sqr1.Height = 6;
sqr1.Width = 8;
Console.WriteLine(AreaCalculator.CalculateArea(sqr1)); //returns 64
Rectangle sqr2 = new Square();
sqr2.Height = 8;
sqr2.Width = 6;
Console.WriteLine(AreaCalculator.CalculateArea(sqr2)); //returns 36
LSP says that the derived class should correctly implement the base class methods. Here, the square class is not a subtype of the rectangle class because it has equal sides. So, only one property is needed instead of two properties, height, and width. It creates confusion for the users of the class and might give the wrong result.
Interface Segregation Principle
Clients should not be forced to depend on methods they do not use.
Interface Segregation Principle (ISP) is the fourth principle of SOLID principles. It can be used in conjunction with LSP.
Interface Segregation Principle (ISP) states that:
Now, you may wondering, who is the client and what and whose methods it is talking about?
Here, a client is a code that calls the methods of a class with an instance of the interface. For example, a class implements an interface that contains 10 methods. Now, you create an object of that class with a variable of that interface and call only 5 methods for the functionality you wanted and never call the other 5 methods. So, this means that the interface contains more methods that are not used by all client codes. It is called a fat interface. ISP suggests segregating that interface into two or more interfaces so that a class can implement the specific interface that it requires.
Let's use the following interface to learn ISP in detail:
public interface IStudentRepository
{
void AddStudent(Student std);
void EditStudent(Student std);
void DeleteStudent(Student std);
void AddCourse(Course cs);
void EditCourse(Course cs);
void DeleteCourse(Course cs);
bool SubscribeCourse(Course cs);
bool UnSubscribeCourse(Course cs);
IList<Student> GetAllStudents();
IList<Student> GetAllStudents(Course cs);
IList<Course> GetAllCourse();
IList<Course> GetAllCourses(Student std);
}
public class StudentRepository : IStudentRepository
{
public void AddCourse(Course cs)
{
//implementation code removed for better clarity
}
public void AddStudent(Student std)
{
//implementation code removed for better clarity
}
public void DeleteCourse(Course cs)
{
//implementation code removed for better clarity
}
public void DeleteStudent(Student std)
{
//implementation code removed for better clarity
}
public void EditCourse(Course cs)
{
//implementation code removed for better clarity
}
public void EditStudent(Student std)
{
//implementation code removed for better clarity
}
public IList<Course> GetAllCourse()
{
//implementation code removed for better clarity
}
public IList<Course> GetAllCourses(Student std)
{
//implementation code removed for better clarity
}
public IList<Student> GetAllStudents()
{
//implementation code removed for better clarity
}
public IList<Student> GetAllStudents(Course cs)
{
//implementation code removed for better clarity
}
public bool SubscribeCourse(Course cs)
{
//implementation code removed for better clarity
}
public bool UnSubscribeCourse(Course cs)
{
//implementation code removed for better clarity
}
}
The above IStudentRepository
interface contains 12 methods for different purposes. The StudentRepository
class implements the IStudentRepository
interface.
Now, after some time, you observe that not all instances of the StudentRepository
class call all the methods. Sometimes it calls methods that perform student-related tasks or sometimes calls course-related methods. Also, the StudentRepository
does not follow the single responsibility principle because you may need to edit its code if student related as well as course-related business logic changes.
To apply ISP to the above problem, we can split our large interface IStudentRepository
and create another interface ICourseRepository
with all course-related methods, as shown below.
public interface IStudentRepository
{
void AddStudent(Student std);
void EditStudent(Student std);
void DeleteStudent(Student std);
bool SubscribeCourse(Course cs);
bool UnSubscribeCourse(Course cs);
IList<Student> GetAllStudents();
IList<Student> GetAllStudents(Course cs);
}
public interface ICourseRepository
{
void AddCourse(Course cs);
void EditCourse(Course cs);
void DeleteCourse(Course cs);
IList<Course> GetAllCourse();
IList<Course> GetAllCourses(Student std);
}
Now, we can create two concrete classes that implement the above two interfaces. This will automatically support SRP and increase cohesion.
ISP is not specific to interfaces only but it can be used with abstract classes or any class that provides some services to the client code.
ISP helps in implementing Liskov Substitution Principle, increasing cohesion that in turn supports the Single Responsibility Principle.
The following code smells detects ISP violation:
- When you have large interfaces.
- When you implement an interface in a concrete class where some methods do not have any implementation code or throw
NotImplementedException
. - When you call only a small set of methods of a larger interface.
The solution to ISP violations:
- Split large interfaces into smaller ones.
- inherit multiple small interfaces if required.
- Use the adapter design pattern for the third-party large interface so that your code can work with the adapter.
Dependency Inversion Principle
High-level modules should not depend on low-level modules. Both should depend on abstraction
Dependency Inversion Principle is the last principle of SOLID principles. It helps in loose coupling.
Dependency Inversion Principle states that:
Now, the question is what are high-level and low-level modules and what is an abstraction?
A high-level module is a module (class) that uses other modules (classes) to perform a task. A low-level module contains a detailed implementation of some specific task that can be used by other modules. The high-level modules are generally the core business logic of an application whereas the low-level modules are input/output, database, file system, web API, or other external modules that interact with users, hardware, or other systems.
Abstraction is something that is not concrete. Abstraction should not depend on detail but details should depend on abstraction. For example, an abstract class or interface contains methods declarations that need to be implemented in concrete classes. Those concrete classes depend on the abstract class or interface but not vice-versa.
Now, how do we know a class depends on another class?
You can identify a class is depends on another class if it creates an object of another class. You may require to add the reference of the namespace to compile or run the code.
Let's use the following example to understand the DIP:
public class Student
{
public int StudentId { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public DateTime DoB { get; set; }
//tight coupling
private StudentRepository _stdRepo = new StudentRepository();
public Student()
{
}
public void Save()
{
_stdRepo.AddStudent(this);
}
}
public class StudentRepository
{
public void AddStudent(Student std)
{
//EF code removed for clarity
}
public void DeleteStudent(Student std)
{
//EF code removed for clarity
}
public void EditStudent(Student std)
{
//EF code removed for clarity
}
public IList<Student> GetAllStudents()
{
//EF code removed for clarity
}
}
The above Student class creates an object of the StudentRepository
class for CRUD operation to a database. Thus, the Student
class depends on the StudentRepository
class for CRUD operations. The Student
class is the high-level module and the StudentRepository
class is the low-level module.
Here, the problem is that the Student
class creates an object of concrete StudentRepository
class using the new keyword and makes both tightly coupled. This leads to the following problems:
- Creating objects using the
new
keyword at all places is repeated code. The object creation is not in one place. Violation of the Do Not Repeat Yourself (DRY) principle. If there is some change in the constructor of theStudentRepository
class then we need to make the changes in all the places. If object creation is in one place then it would be easy to maintain the code. - Creating an object using
new
also make unit testing impossible. We cannot unit test theStudent
class separately. - The
StudentRepository
class is a concrete class, so any changes in the class will require changing theStudent
class too.
DIP says that high-level modules should not depend on the low-level module. Both should depend on abstraction. Here, abstraction means use of interface or abstract class.
The following is the result of applying the DIP principle to the above example.
public class Student
{
public int StudentId { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public DateTime DoB { get; set; }
private IStudentRepository _stdRepo;
public Student(IStudentRepository stdRepo)
{
_stdRepo = stdRepo;
}
public void Save()
{
_stdRepo.AddStudent(this);
}
}
public interface IStudentRepository
{
void AddStudent(Student std);
void EditStudent(Student std);
void DeleteStudent(Student std);
IList<Student> GetAllStudents();
}
public class StudentRepository : IStudentRepository
{
public void AddStudent(Student std)
{
//code removed for clarity
}
public void DeleteStudent(Student std)
{
//code removed for clarity
}
public void EditStudent(Student std)
{
//code removed for clarity
}
public IList<Student> GetAllStudents()
{
//code removed for clarity
}
}
The StudentRepository
class above implements the IStudentRepository
interface. Here, IStudentRepository
is an abstraction of CRUD operations for student-related data. The StudentRepository
class provides the implementation of that methods, so it depends on the methods of the IStudentRepository
interface.
The Student class does not create an object of the StudentRepository
class using the new
keyword. The constructor requires a parameter of the IStudentRepository
class which will be passed from the calling code. Thus, it also depends on the abstraction (interface) rather than the low-level concrete class (StudentRepository
).
This will create loose coupling and also make each class unit testable. The caller of the Student
class can pass an object of any class that implements the IStudentRepository
interface and by so not tied to the specific concrete class.
public class Program
{
public static void Main(string[] args)
{
//for production
Student std1 = new Student(new StudentRepository);
//for unit test
Student std2 = new Student(new TestStudentRepository);
}
}
Instead of creating manually, you can use the factory class to create it, so that all the object creation will be in one place.
public class RepositoryFactory
{
public static IStudentRepository GetStudentRepository()
{
return new StudentRepository();
}
public static IStudentRepository GetTestStudentRepository()
{
return new TestStudentRepository();
}
}
public class Program
{
public static void Main(string[] args)
{
//for production
Student std1 = new Student(RepositoryFactory.GetStudentRepository());
//for unit test
Student std2 = new Student(RepositoryFactory.TestGetStudentRepository());
}
}
It is recommended to use Dependency Injection and IoC containers for creating and passing objects of low-level classes to high-level classes.
Comments
Post a Comment