Solid Principles in C#
- Raghav Kumar
- May 7
- 9 min read

In software development, SOLID principles are essential concepts in object-oriented programming and design, particularly in C#. These principles help developers create better, more manageable, intelligible, and flexible software systems. By following SOLID principles, you may improve code quality while lowering the risk of introducing errors.
Why SOLID Principles?
As software developers, we frequently begin designing applications with our previous expertise and knowledge. However, over time, each change request or new feature could involve modifications to the app's appearance, and poor design might make the system more complex and challenging to maintain and update.
To address these issues and create robust software, SOLID principles are used.
What are the SOLID Principles?
SOLID principles are the design principles that enable us to manage several software design problems. Robert C. Martin compiled these principles in the 1990s. These principles provide us with ways to transition from tightly coupled code and limited encapsulation to the desired results of loosely coupled and properly encapsulated real business needs. SOLID is an acronym for the following.
S: Single Responsibility Principle (SRP)
O: Open-Closed Principle (OCP)
L: Liskov Substitution Principle (LSP)
I: Interface Segregation Principle (ISP)
D: Dependency Inversion Principle (DIP)
S: Single Responsibility Principle (SRP)
SRP says, "Every software module should have only one reason to change.".
This means every class or similar structure in your code should have only one job. Everything in that class should be related to a single purpose. Our class should not be like a Swiss knife wherein if one of them needs to be changed, the entire tool needs to be altered. It does not mean that your classes should only contain one method or property. There may be many members as long as they relate to a single responsibility.
Before Applying SRP
public class Employee
{
public string Name { get; set; }
public int HoursWorked { get; set; }
public decimal HourlyRate { get; set; }
public void SaveToFile()
{
File.WriteAllText("employee.txt", $"Name: {Name}, Hours Worked: {HoursWorked}, Hourly Rate: {HourlyRate}");
Console.WriteLine("Employee data saved to file.");
}
public decimal CalculateSalary()
{
return HoursWorked * HourlyRate;
}
}
class Program
{
static void Main(string[] args)
{
Employee employee = new Employee { Name = "John", HoursWorked = 40, HourlyRate = 20 };
employee.SaveToFile(); // Violates SRP
Console.WriteLine("Salary: " + employee.CalculateSalary()); // Violates SRP
}
}
After Applying SRP
// Class responsible for storing employee data
public class Employee
{
public string Name { get; set; }
public int HoursWorked { get; set; }
public decimal HourlyRate { get; set; }
}
// Class responsible for saving employee data
public class EmployeeDataSaver
{
public void SaveToFile(Employee employee)
{
File.WriteAllText("employee.txt", $"Name: {employee.Name}, Hours Worked: {employee.HoursWorked}, Hourly Rate: {employee.HourlyRate}");
Console.WriteLine("Employee data saved to file.");
}
}
// Class responsible for salary calculations
public class SalaryCalculator
{
public decimal CalculateSalary(Employee employee)
{
return employee.HoursWorked * employee.HourlyRate;
}
}
class Program
{
static void Main(string[] args)
{
Employee employee = new Employee { Name = "John", HoursWorked = 40, HourlyRate = 20 };
// Saving employee data to file using a dedicated class
EmployeeDataSaver dataSaver = new EmployeeDataSaver();
dataSaver.SaveToFile(employee);
// Calculating salary using a dedicated class
SalaryCalculator salaryCalculator = new SalaryCalculator();
Console.WriteLine("Salary: " + salaryCalculator.CalculateSalary(employee));
}
}
Explanation:
The "Employee" class only stores employee data.
"EmployeeDataSaver" is responsible for saving the data and adhering to SRP.
"SalaryCalculator" is responsible for calculating the salary.
O: Open-Closed Principle (OCP)
The Open/closed Principle says, "A software module/class is open for extension and closed for modification."
Here, "Open for extension" means we must design our module/class so that the new functionality can be added only when new requirements are generated. "Closed for modification" means we have already developed a class, and it has gone through unit testing.
Before Applying OCP
public class Rectangle
{
public double Width { get; set; }
public double Height { get; set; }
}
public class Circle
{
public double Radius { get; set; }
}
public class Shape
{
public double CalculateArea(object shape)
{
if (shape is Rectangle rectangle)
{
return rectangle.Width * rectangle.Height;
}
else if (shape is Circle circle)
{
return Math.PI * circle.Radius * circle.Radius;
}
throw new ArgumentException("Unknown shape!");
}
}
static void Main(string[] args)
{
Shape shapeCalculator = new Shape();
Rectangle rectangle = new Rectangle { Width = 5, Height = 10 };
Circle circle = new Circle { Radius = 3 };
Console.WriteLine("Rectangle Area: " + shapeCalculator.CalculateArea(rectangle)); // Violates OCP
Console.WriteLine("Circle Area: " + shapeCalculator.CalculateArea(circle)); // Violates OCP
}
Output:
Rectangle Area: 50
Circle Area: 28.274333882308138
Issue:
If you need to add a new shape (e.g., triangle), you must modify the Shape class, violating OCP.
Every time you add new shapes, you risk breaking existing code.
After Applying OCP
// Base class or interface for shapes
public abstract class Shape
{
public abstract double CalculateArea();
}
// Rectangle class extending the base class
public class Rectangle : Shape
{
public double Width { get; set; }
public double Height { get; set; }
public override double CalculateArea()
{
return Width * Height;
}
}
// Circle class extending the base class
public class Circle : Shape
{
public double Radius { get; set; }
public override double CalculateArea()
{
return Math.PI * Radius * Radius;
}
}
// Now we can add new shapes without modifying the existing code
public class Triangle : Shape
{
public double Base { get; set; }
public double Height { get; set; }
public override double CalculateArea()
{
return 0.5 * Base * Height;
}
}
static void Main(string[] args)
{
// Using polymorphism to handle different shapes
Shape rectangle = new Rectangle { Width = 5, Height = 10 };
Shape circle = new Circle { Radius = 3 };
Shape triangle = new Triangle { Base = 4, Height = 6 };
Console.WriteLine("Rectangle Area: " + rectangle.CalculateArea());
Console.WriteLine("Circle Area: " + circle.CalculateArea());
Console.WriteLine("Triangle Area: " + triangle.CalculateArea());
}
Output:
Rectangle Area: 50
Circle Area: 28.274333882308138
Triangle Area: 12
Explanation:
Shape is an abstract base class that defines the CalculateArea method.
Rectangle, Circle, and Triangle classes extend the base class and provide their implementations of the CalculateArea method.
You can add more shapes in the future by simply creating a new class (e.g., Triangle) without modifying the existing classes.
L: Liskov Substitution Principle (LSP)
The Liskov Substitution Principle (LSP) states, "you should be able to use any derived class instead of a parent class and have it behave in the same manner without modification.". It ensures that a derived class does not affect the behavior of the parent class; in other words, a derived class must be substitutable for its base class.
This principle is just an extension of the Open-Closed Principle, and we must ensure that newly derived classes extend the base classes without changing their behavior.
Before Applying LSP
public class Bird
{
public virtual void Fly()
{
Console.WriteLine("The bird is flying.");
}
}
public class Penguin : Bird
{
// Penguin cannot fly, so overriding Fly method with wrong behavior
public override void Fly()
{
Console.WriteLine("Penguins can't fly.");
}
}
class Program
{
static void Main(string[] args)
{
Bird myBird = new Bird();
myBird.Fly();
Bird myPenguin = new Penguin();
myPenguin.Fly();
}
}
Output:
Penguins can't fly.
After Applying LSP
// Base class for all birds
public abstract class Bird
{
public abstract void Move();
}
// Derived class for flying birds
public class FlyingBird : Bird
{
public override void Move()
{
Fly();
}
public virtual void Fly()
{
Console.WriteLine("This bird is flying.");
}
}
// Derived class for non-flying birds
public class NonFlyingBird : Bird
{
public override void Move()
{
Walk();
}
public virtual void Walk()
{
Console.WriteLine("This bird is walking.");
}
}
// Subclass for sparrow, a flying bird
public class Sparrow : FlyingBird
{
public override void Fly()
{
Console.WriteLine("The sparrow is flying.");
}
}
// Subclass for penguin, a non-flying bird
public class Penguin : NonFlyingBird
{
public override void Walk()
{
Console.WriteLine("The penguin is waddling.");
}
}
static void Main(string[] args)
{
// Now substitution works correctly
Bird sparrow = new Sparrow();
sparrow.Move(); // Output: "The sparrow is flying."
Bird penguin = new Penguin();
penguin.Move(); // Output: "The penguin is waddling."
}
Output:
The sparrow is flying.
The penguin is waddling.
Explanation:
We have an abstract Bird class that defines a general behavior (Move()), which is then specialized in subclasses.
FlyingBird and NonFlyingBird are derived from Bird. Each subclass handles its behavior: FlyingBird uses Fly(), and NonFlyingBird uses Walk().
The substitution now works correctly: you can replace Bird with either Sparrow or Penguin, and they behave as expected, adhering to LSP.
I: Interface Segregation Principle (ISP)
The Interface Segregation Principle states "that clients should not be forced to implement interfaces they don't use. Instead of one fat interface, many small interfaces are preferred based on groups of methods, each serving one submodule.".
Before Applying ISP
// Interface with methods not all workers need
public interface IWorker
{
void Work();
void Eat(); // Not all workers eat
}
public class Human : IWorker
{
public void Work()
{
Console.WriteLine("Human is working.");
}
public void Eat()
{
Console.WriteLine("Human is eating.");
}
}
public class Robot : IWorker
{
public void Work()
{
Console.WriteLine("Robot is working.");
}
// Robot doesn't eat, but we are forced to implement Eat() anyway
public void Eat()
{
throw new NotImplementedException("Robots don't eat.");
}
}
static void Main(string[] args)
{
IWorker human = new Human();
human.Work(); // Output: Human is working.
human.Eat(); // Output: Human is eating.
IWorker robot = new Robot();
robot.Work(); // Output: Robot is working.
robot.Eat(); // Throws exception: NotImplementedException: Robots don't eat.
}
After Applying ISP
// Separate interface for working
public interface IWorkable
{
void Work();
}
// Separate interface for eating
public interface IEatable
{
void Eat();
}
// Humans can work and eat
public class Human : IWorkable, IEatable
{
public void Work()
{
Console.WriteLine("Human is working.");
}
public void Eat()
{
Console.WriteLine("Human is eating.");
}
}
// Robots only work, they don't eat
public class Robot : IWorkable
{
public void Work()
{
Console.WriteLine("Robot is working.");
}
}
static void Main(string[] args)
{
// Human can work and eat
IWorkable humanWorker = new Human();
humanWorker.Work(); // Output: Human is working.
IEatable humanEater = new Human();
humanEater.Eat(); // Output: Human is eating.
// Robot can only work, no need for Eat() method
IWorkable robotWorker = new Robot();
robotWorker.Work(); // Output: Robot is working.
}
Output:
Human is working.
Human is eating.
Robot is working.
Explanation:
We now have two separate interfaces: IWorkable for the Work() method and IEatable for the Eat() method.
The Human class implements both IWorkable and IEatable because humans can both work and eat.
The Robot class implements only IWorkable because robots can only work, but they don’t eat.
D: Dependency Inversion Principle (DIP)
The Dependency Inversion Principle (DIP) states that high-level modules/classes should not depend on low-level modules/classes.
High-level modules/classes implement business rules or logic in a system (application). Low-level modules/classes deal with more detailed operations; in other words, they may write information to databases or pass messages to the operating system or services.
A high-level module/class that depends on low-level modules/classes or some other class and knows a lot about the other classes it interacts with is said to be tightly coupled. When a class knows explicitly about the design and implementation of another class, it raises the risk that changes to one class will break the other. So we must keep these high-level and low-level modules/classes loosely coupled as much as possible. To do that, we need to make both of them dependent on abstractions instead of knowing each other.
Before Applying DIP
public class EmailService
{
public void SendEmail(string message)
{
Console.WriteLine("Sending email: " + message);
}
}
public class UserService
{
private readonly EmailService _emailService;
public UserService()
{
_emailService = new EmailService();
}
public void NotifyUser(string message)
{
_emailService.SendEmail(message);
}
}
static void Main(string[] args)
{
UserService userService = new UserService();
userService.NotifyUser("Hello, user!"); // Sends email: Hello, user!
}
Output:
Sends email: Hello, user!
Issue:
A UserService directly depends on the EmailService. If we want to change the way notifications are sent (e.g., via SMS), we'd need to modify the UserService class, which violates the Dependency Inversion Principle.
After Applying DIP
// Abstraction (interface) for sending notifications
public interface INotificationService
{
void Send(string message);
}
// Concrete implementation: EmailService
public class EmailService : INotificationService
{
public void Send(string message)
{
Console.WriteLine("Sending email: " + message);
}
}
// Concrete implementation: SMSService
public class SMSService : INotificationService
{
public void Send(string message)
{
Console.WriteLine("Sending SMS: " + message);
}
}
// High-level module (UserService) depends on abstraction (INotificationService)
public class UserService
{
private readonly INotificationService _notificationService;
// Dependency injection via constructor
public UserService(INotificationService notificationService)
{
_notificationService = notificationService;
}
public void NotifyUser(string message)
{
_notificationService.Send(message);
}
}
static void Main(string[] args)
{
// Using EmailService
INotificationService emailService = new EmailService();
UserService emailUserService = new UserService(emailService);
emailUserService.NotifyUser("Hello via Email!"); // Output: Sending email: Hello via Email!
// Using SMSService
INotificationService smsService = new SMSService();
UserService smsUserService = new UserService(smsService);
smsUserService.NotifyUser("Hello via SMS!"); // Output: Sending SMS: Hello via SMS!
}
Output:
Sending email: Hello via Email!
Sending SMS: Hello via SMS!
Explanation:
INotificationService: Defines Send() method, implemented by EmailService and SMSService.
UserService: Depends on INotificationService to send notifications, allowing flexibility.
Program: Uses EmailService and SMSService with UserService to send messages via email or SMS.
Summary
We have gone through all five SOLID principles successfully. They help developers build software with clear roles, expandability without modification, interchangeable components, focused interfaces, and decoupled dependencies, resulting in resilient and flexible codebases.
Happy coding and learning!
Comments