SOLID Principles In C# Explained
SOLID Principles Explained Using C#: An Overview
In Object-Oriented Programming (OOP), SOLID is an acronym introduced by Michael Feathers for five design principles used to make software design more understandable, flexible, and maintainable. These principles are a subset of many promoted by Robert C. Martin.
If you're interested in learning more about these principles and how to apply them in your software development projects, you may find a comprehensive guide in our Design Pattern Tutorial. Also, check out .NET Design Patterns Training Course to explore Design patterns with real-world examples.
What are SOLID Design Principles?
The SOLID Principles C# manages the majority of software design problems that developers face daily. These concepts are tried-and-true processes that make software designs clearer, more flexible, and more maintainable. As a result, if we follow these rules when creating our applications, we can create superior applications.
What are the SOLID principles C#?
There are five SOLID principles in C#:Single Responsibility Principle (SRP)
Open Closed Principle (OCP)
Liskov Substitution Principle (LSP)
Interface Segregation Principle (ISP)
Dependency Inversion Principle (DIP)
1. Single Responsibility Principle (SRP)
Definition: A class should have only one reason to change.
Robert C. Martin gave this definition in his book Agile Software Development, Principles, Patterns, and Practices, which waslater republished in the C# version of the book Agile Principles, Patterns, and Practices in C#.
In layman's terminology, this means that a class should not be loaded with multiple responsibilities, and a single responsibility should not be spread across multiple classes or mixed with other responsibilities. The reason is that the more changes requested in the future, the more changes the class needs to apply.
Understanding
- The single Responsibility Principle is one of the five SOLID principles in C# that developers use to guide them as they write code or design an application.
- In simple terms, a module or class should have a very small piece of responsibility in the entire application. As the rule states, a class/module should have no more than one reason to change.
- If a class has only a single responsibility, it is likely to be very robust. It’s easy to verify that it's working according to the logic defined, and it’s easy to change in class as it has a single responsibility.
- The Single Responsibility Principle provides another benefit. Classes, software components, and modules that have only one responsibility are much easier to explain, implement, and understand than ones that give a solution for everything.
- This also reduces the number of bugs, improves development speed, and, most importantly, makes the developer’s life a lot easier.
Implementation
Let’s take a scenario of Garage service station functionality. It has three main functions: open gate, close gate, and performing service. The below example violates the SRP principle. The code below violates the SRP principle as it mixes open-gate and close-gate responsibilities with the main function of servicing the vehicle.
public class GarageStation
{
public void DoOpenGate()
{
//Open the gate functinality
}
public void PerformService(Vehicle vehicle)
{
//Check if garage is opened
//finish the vehicle service
}
public void DoCloseGate()
{
//Close the gate functinality
}
}
We can correctly apply SRP by refactoring the above code and introducing an interface. A new interface called IGarageUtility is created, and gate-related methods are moved to a different class called GarageStationUtility.
public class GarageStation
{
IGarageUtility _garageUtil;
public GarageStation(IGarageUtility garageUtil)
{
this._garageUtil = garageUtil;
}
public void OpenForService()
{
_garageUtil.OpenGate();
}
public void DoService()
{
//Check if service station is opened and then
//finish the vehicle service
}
public void CloseGarage()
{
_garageUtil.CloseGate();
}
}
public class GarageStationUtility : IGarageUtility
{
public void OpenGate()
{
//Open the Garage for service
}
public void CloseGate()
{
//Close the Garage functionlity
}
}
public interface IGarageUtility
{
void OpenGate();
void CloseGate();
}
2. Open Closed Principle (OCP)
Definition: Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.
Bertrand Meyer is generally credited for having originated the definition of open/closed principle in his book Object-Oriented Software Construction.
Understanding
- This principle suggests that the class should be easily extended, but there is no need to change its core implementations.
- The application or software should be flexible to change. How change management is implemented in a system has a significant impact on the success of that application/ software. The OCP states that the behaviors of the system can be extended without having to modify its existing implementation.
- i.e., New features should be implemented using the new code, not by changing existing code. Adhering to OCP potentially streamlines code maintenance and reduces the risk of breaking the existing implementation.
Implementation
Let’s take an example of bank accounts like regular savings, salary savings, corporate, etc., for different customers. For each customer type, there are different rules and interest rates. The code below violates the OCP principle if the bank introduces a new Account type. Said code modifies this method for adding a new account type.
public class Account
{
public decimal Interest { get; set; }
public decimal Balance { get; set; }
// members and function declaration
public decimal CalcInterest(string accType)
{
if (accType == "Regular") // savings
{
Interest = (Balance * 4) / 100;
if (Balance < 1000) Interest -= (Balance * 2) / 100;
if (Balance < 50000) Interest += (Balance * 4) / 100;
}
else if (accType == "Salary") // salary savings
{
Interest = (Balance * 5) / 100;
}
else if (accType == "Corporate") // Corporate
{
Interest = (Balance * 3) / 100;
}
return Interest;
}
}
When we want to extend functionality, we can apply OCP by using interfaces, abstract classes, abstract methods, and virtual methods. Here, I have used the interface, for example, only, but you can go as per your requirement.
interface IAccount
{
// members and function declaration, properties
decimal Balance { get; set; }
decimal CalcInterest();
}
//regular savings account
public class RegularSavingAccount : IAccount
{
public decimal Balance { get; set; } = 0;
public decimal CalcInterest()
{
decimal Interest = (Balance * 4) / 100;
if (Balance < 1000) Interest -= (Balance * 2) / 100;
if (Balance < 50000) Interest += (Balance * 4) / 100;
return Interest;
}
}
//Salary savings account
public class SalarySavingAccount : IAccount
{
public decimal Balance { get; set; } = 0;
public decimal CalcInterest()
{
decimal Interest = (Balance * 5) / 100;
return Interest;
}
}
//Corporate Account
public class CorporateAccount : IAccount
{
public decimal Balance { get; set; } = 0;
public decimal CalcInterest()
{
decimal Interest = (Balance * 3) / 100;
return Interest;
}
}
In the above code, three new classes are created: regular saving account, SalarySavingAccount, and CorporateAccount, by extending them from IAccount.This solves the problem of class modification, and by extending the interface, we can extend functionality. The above code implements both the OCP and SRP principles, as each class is doing a single task, and we are not modifying the class and only doing an extension.
3. Liskov Substitution Principle (LSP)
Definition by Robert C. Martin: Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.
The Liskov substitution principle (LSP) is a definition of a subtyping relation called (strong) behavioral subtyping. Barbara Liskov initially introduced in a 1987 conference keynote address titled Data Abstraction and Hierarchy.
Understanding
- LSP states that the child class should be perfectly substitutable for their parent class. If class C is derived from P then C should be substitutable for P.
- We can check using LSP that inheritance is applied correctly or not in our code.
- LSP is a fundamental principle of SOLID Principles in C# and states that if a program or module is using a base class, then the derived class should be able to extend its base class without changing its original implementation.
Implementation
Let’s consider the code below where LSP is violated. We cannot simply substitute a Triangle, which results in the printing shape of a triangle, with a Circle.
namespace Demo
{
public class Program
{
static void Main(string[] args)
{
Triangle triangle = new Circle();
Console.WriteLine(triangle.GetColor());
}
}
public class Triangle
{
public virtual string GetShape()
{
return "Triangle";
}
}
public class Circle: Triangle
{
public override string GetShape()
{
return "Circle";
}
}
}
To correct the above implementation, we need to refactor this code by introducing an interface with a method called GetShape.
namespace Demo
{
class Program
{
static void Main(string[] args)
{
Shape shape = new Circle();
Console.WriteLine(shape.GetShape());
shape = new Triangle ();
Console.WriteLine(shape.GetShape());
}
}
public abstract class Shape
{
public abstract string GetShape();
}
public class Triangle: Shape
{
public override string GetShape()
{
return "Triangle";
}
}
public class Circle: Triangle
{
public override string GetShape()
{
return "Circle";
}
}
}
4. Interface Segregation Principle (ISP)
Definition: No client should be forced to implement methods that they do not use, and the contracts should be broken down into thin ones.
The ISP was first used and formulated by Robert C. Martin while consulting for Xerox.
Understanding
- The interface segregation principle is required to solve the application's design problem.
- When all the tasks are done by a single class or, in other words, one class is used in almost all the application classes, then it has become a fat class with overburden.
- Inheriting such a class will result in sharing methods that are not relevant to derived classes, but since they are present in the base class, they will be inherited in the derived class.
- Using ISP, we can create separate interfaces for each operation or requirement rather than having a single class to do the same work.
Implementation
In the code below, ISP is broken as the process method is not required by the OfflineOrder class but is forced to be implemented.
public interface IOrder
{
void AddToCart();
void CCProcess();
}
public class OnlineOrder : IOrder
{
public void AddToCart()
{
//Do Add to Cart
}
public void CCProcess()
{
//process through credit card
}
}
public class OfflineOrder : IOrder
{
public void AddToCart()
{
//Do Add to Cart
}
public void CCProcess()
{
//Not required for Cash/ offline Order
throw new NotImplementedException();
}
}
We can resolve this violation by dividing the IOrder Interface.
public interface IOrder
{
void AddToCart();
}
public interface IOnlineOrder
{
void CCProcess();
}
public class OnlineOrder : IOrder, IOnlineOrder
{
public void AddToCart()
{
//Do Add to Cart
}
public void CCProcess()
{
//process through credit card
}
}
public class OfflineOrder : IOrder
{
public void AddToCart()
{
//Do Add to Cart
}
}
5. Dependency Inversion Principle (DIP)
This principle is about dependencies among components. The definition of DIP is given by Robert C. Martin is as follows:
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.
Understanding
- The principle says that high-level modules should depend on abstraction, not on the details, of low-level modules.
- In simple words, the principle states that software components should not be tightly coupled and should depend on abstraction to avoid that.
- The terms Dependency Injection (DI) and Inversion of Control (IoC) are generally used interchangeably to express the same design pattern.
- The pattern was initially called IoC, but Martin Fowler (known for designing enterprise software) anticipated the name DI because all frameworks or runtimes inverted control in some way, and he wanted to know which aspect of control was being inverted.
- Inversion of Control (IoC) is a technique to implement the Dependency Inversion Principle in C#.
- Inversion of control can be implemented using either an abstract class or interface.
- The rule is that the lower-level entities should join the contract to a single interface, and the higher-level entities will use only entities that implement the interface.
- This technique removes the dependency between the entities.
Note: In the implementation below, I have used an interface as a reference, but you can use an abstract class or interface as per your requirement.
Implementation
In the code below, we have implemented DIP using IoC and an injection constructor. There are different ways to implement Dependency injection. Here, I have used injection through the constructor, but you inject the dependency into the class's constructor (Constructor Injection), set property (Setter Injection), method (Method Injection), events, index properties, fields, and any public members of the class.
public interface IAutomobile
{
void Ignition();
void Stop();
}
public class Jeep : IAutomobile
{
#region IAutomobile Members
public void Ignition()
{
Console.WriteLine("Jeep start");
}
public void Stop()
{
Console.WriteLine("Jeep stopped.");
}
#endregion
}
public class SUV : IAutomobile
{
#region IAutomobile Members
public void Ignition()
{
Console.WriteLine("SUV start");
}
public void Stop()
{
Console.WriteLine("SUV stopped.");
}
#endregion
}
public class AutomobileController
{
IAutomobile m_Automobile;
public AutomobileController(IAutomobile automobile)
{
this.m_Automobile = automobile;
}
public void Ignition()
{
m_Automobile.Ignition();
}
public void Stop()
{
m_Automobile.Stop();
}
}
class Program
{
static void Main(string[] args)
{
IAutomobile automobile = new Jeep();
//IAutomobile automobile = new SUV();
AutomobileController automobileController = new AutomobileController(automobile);
automobile.Ignition();
automobile.Stop();
Console.Read();
}
}
In the above code, the IAutomobile interface is in an abstraction layer, and AutomobileController is the higher-level module. Here, we have integrated everything in a single line of code, but in the real world, each abstraction layer is a separate class with additional functionality. Here, products are completely decoupled from the consumer using the IAutomobile interface. The object is injected into the constructor of the AutomobileController class about the interface automobile. The constructor where the object gets injected is called the injection constructor.
Summary
The SOLID principles C# provide an effective foundation for creating clean, manageable, and extensible object-oriented code in C#. By following these five principles, developers can construct code that is easier to understand, edit, and extend: single responsibility, open/closed, Liskov substitution, interface segregation, and dependency inversion. To learn about various other aspects of design patterns, consider enrolling in our .NET Design Patterns Course.