As a C# developer, you’ve likely encountered code that feels like a tangled mess—difficult to understand, modify, or extend—Have you ever wondered why some codebases remain robust and adaptable while others quickly become a nightmare?
The answer lies in SOLID principles.
These five fundamental design principles—Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion—form the foundation of clean and maintainable object-oriented design.
In this guide, we’ll explain each principle using real-world C# examples, helping you write better code that will stand the test of time.
Let’s dive in!
Single Responsibility Principle (SRP).
A class should have only one reason to change.
What is SRP?
The Single Responsibility Principle (SRP) states that a class should have only one reason to change—it should focus on a single responsibility.
This keeps code modular, easier to maintain, and less prone to unintended side effects.
Let’s take a blog project where users can create, edit, and publish articles.
A naive implementation might look like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
public class BlogPost { public int Id { get; set; } public string Title { get; set; } public string Content { get; set; } public DateTime PublishedDate { get; set; } public void SaveToDatabase() { // Logic to save post to the database } public void SendEmailNotification() { // Logic to send email notification to subscribers } public void GeneratePostSlug() { // Logic to generate URL-friendly slug from the title } } |
At first glance, this class seems fine—it holds the properties of a blog post and provides methods to handle its persistence, notification, and slug generation.
However, this violates SRP because:
- Persistence (
SaveToDatabase
) is a database concern. - Notification (
SendEmailNotification
) is a messaging concern. - Slug Generation (
GeneratePostSlug
) is a text-processing concern.
Each of these can change independently. If the notification system changes (e.g., moving from email to push notifications), modifying BlogPost
class unnecessarily affects unrelated logic.
Now let’s apply the SRP rule to refactor the BlogPost
class. This will separate the concerns into separate classes.
Applying SRP.
The BlogPost
class should focus only on representing a blog post.
1 2 3 4 5 6 7 |
public class BlogPost { public int Id { get; set; } public string Title { get; set; } public string Content { get; set; } public DateTime PublishedDate { get; set; } } |
Database interactions such as save, read, or delete belong in a repository class.
1 2 3 4 5 6 7 |
public class BlogRepository { public void Save(BlogPost post) { // Logic to save post to the database } } |
Create a separate class for sending notifications.
1 2 3 4 5 6 7 |
public class NotificationService { public void SendEmailNotification(BlogPost post) { // Logic to send email notification } } |
A separate class for slug generation ensures better testability.
1 2 3 4 5 6 7 |
public class SlugGenerator { public string GenerateSlug(string title) { return title.ToLower().Replace(" ", "-"); } } |
Benefits of Applying SRP.
- Easier Maintenance – Changes to notifications or persistence don’t affect the
BlogPost
class. - Better Testability – Each class has a single concern, making unit testing straightforward.
- Scalability – If we switch from email to SMS notifications, we only modify
NotificationService
, notBlogPost
. - Improved Readability – Each class has a clear, focused purpose.
SRP is not about creating a new class for every method—it’s about logically separating concerns that evolve independently.
Open/Closed Principle (OCP).
The Open/Closed Principle (OCP) states that: Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.
This means that you should be able to add new functionality without changing existing code. Instead of modifying existing classes when requirements change, you should extend them through abstraction and polymorphism.
Why OCP Matter?
When a class is open for modification, every new requirement forces developers to alter the existing codebase. This leads to:
- Higher risk of bugs – Changing old code may introduce new issues.
- Code fragility – A small change can break unrelated features.
- Harder maintenance – Developers must repeatedly touch old code.
By applying OCP, we can add features without modifying core logic, making the system more stable, testable, and scalable.
Violating OCP in a Payment System.
Imagine we have an e-commerce system that supports payments through credit cards. Later, we need to add support for PayPal and Bitcoin.
If you are thinking of editing the existing class to support new payment methods then this approach is not correct and will look more or less as given below.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
public class PaymentProcessor { public void Process(string paymentType) { if (paymentType == "CreditCard") { // Process credit card payment } else if (paymentType == "PayPal") { // Process PayPal payment } else if (paymentType == "Bitcoin") { // Process Bitcoin payment } } } |
Here’s why the above approach is not correct.
- Every time a new payment method is introduced, we must modify
PaymentProcessor
. - The class violates OCP because it requires modification instead of being extended.
- If more payment types are added, this
if-else
block keeps growing, making the code harder to maintain.
Now let’s apply OCP to fix the implementation.
To fix this, we can introduce an abstraction IPaymentMethod
interface and extend it for new payment types without modifying existing code.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
// Step 1: Define an Interface public interface IPaymentMethod { void ProcessPayment(); } // Step 2: Implement Payment Methods public class CreditCardPayment : IPaymentMethod { public void ProcessPayment() { /* Process credit card payment */ } } public class PayPalPayment : IPaymentMethod { public void ProcessPayment() { /* Process PayPal payment */ } } public class BitcoinPayment : IPaymentMethod { public void ProcessPayment() { /* Process Bitcoin payment */ } } // Step 3: Modify PaymentProcessor to Use Abstraction public class PaymentProcessor { private readonly IPaymentMethod _paymentMethod; public PaymentProcessor(IPaymentMethod paymentMethod) { _paymentMethod = paymentMethod; } public void Process() { _paymentMethod.ProcessPayment(); } } |
How OCP is Applied Here?
- We never modify
PaymentProcessor
class when adding new payment methods. - If we need a new payment method, we just create a new class implementing
IPaymentMethod
. PaymentProcessor
depends on abstraction (IPaymentMethod
) instead of concrete implementations.
When Should You Apply OCP?
- When you notice repeated modifications in a class due to new feature additions.
- When new functionalities require changing existing code instead of just adding new components.
- When you have long if-else or switch statements that determine behaviour based on a type or mode.
Final Thoughts.
The Open/Closed Principle (OCP) ensures that your system remains stable and easy to extend as new requirements arise. Instead of modifying existing code (which risks breaking things), we create new implementations that follow a shared contract.
Liskov Substitution Principle (LSP)
The Liskov Substitution Principle (LSP) states that: Subtypes must be substitutable for their base types without altering the correctness of the program.
In simpler terms: If a function expects an object of a base class, it should work correctly even if a derived class is passed instead.
Why does LSP matter?
The Liskov Substitution Principle (LSP) ensures that a program remains robust, maintainable, and predictable when using inheritance or polymorphism. Ignoring LSP can lead to unexpected behaviour, runtime errors, and broken code when working with subclasses.
Example: Shape Rendering System.
Imagine we are building a shape rendering system. We have a Rectangle
class to render a rectangle as given below.
1 2 3 4 5 6 7 8 9 10 |
public class Rectangle { public virtual int Width { get; set; } public virtual int Height { get; set; } public int GetArea() { return Width * Height; } } |
Let’s say we want to render squares too and can create a Square
class as given below.
1 2 3 4 5 6 7 8 9 10 11 12 |
public class Square : Rectangle { public override int Width { set { base.Width = base.Height = value; } } public override int Height { set { base.Width = base.Height = value; } } } |
The Square
class modifies the behavior of Rectangle
class, breaking the expected behavior.
If Rectangle
is expected to handle width and height independently, passing Square
into a function that modifies only one property will cause incorrect results.
Now, how can we apply the LSP rule to enhance our implementation?
Instead of forcing Square
to fit into Rectangle
, we extract a common interface.
1 2 3 4 |
public interface IShape { int GetArea(); } |
Now, our shape classes, both Rectangle and Square, will implement this interface independently.
Here’s the new implementation of the Rectangle
class.
1 2 3 4 5 6 7 8 9 10 |
public class Rectangle : IShape { public int Width { get; set; } public int Height { get; set; } public int GetArea() { return Width * Height; } } |
And here’s the new implementation of the Sqaure
class.
1 2 3 4 5 6 7 8 9 |
public class Square : IShape { public int SideLength { get; set; } public int GetArea() { return SideLength * SideLength; } } |
How is LSP helping here?
Rectangle
andSquare
are independent and implementIShape
without conflicting behaviour.- Any function that expects
IShape
can now handle both shapes correctly.
Final Thoughts
- Subclasses should behave like their base class without breaking functionality.
- If a subclass unexpectedly changes the behaviour, rethink the design.
- Use interfaces and composition instead of forcing inheritance.
Interface Segregation Principle (ISP)
Clients should not be forced to depend on interfaces they do not use.
What is ISP?
The Interface Segregation Principle (ISP) states that a class should not be forced to implement methods it doesn’t need.
Instead of creating large, bloated interfaces, we should split them into smaller, more specific interfaces containing only relevant methods for each implementing class.
Key idea: Avoid “fat” interfaces. Keep interfaces focused on specific behaviours.
Why does ISP Matter?
Imagine a system where multiple types of workers exist (e.g., Developers, Designers, Managers).
Now, we create a single interface for all employees:
1 2 3 4 5 6 7 |
public interface IEmployee { void Work(); void AttendMeetings(); void DesignUI(); void WriteCode(); } |
Now, do you see any problems with this interface?
Well, here are some of them.
- Managers don’t need
WriteCode()
orDesignUI()
. - Developers don’t need
DesignUI()
. - Designers don’t need
WriteCode()
.
Yet, every class must implement methods they don’t use, violating ISP.
Applying ISP.
Instead of forcing all employees to implement unnecessary methods, break the large interface into multiple focused interfaces:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
public interface IWorkable { void Work(); } public interface IAttendMeetings { void AttendMeetings(); } public interface ICode { void WriteCode(); } public interface IDesign { void DesignUI(); } |
Now, each class implements only the interfaces it needs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
public class Developer : IWorkable, IAttendMeetings, ICode { public void Work() => Console.WriteLine("Writing code..."); public void AttendMeetings() => Console.WriteLine("Attending scrum meeting..."); public void WriteCode() => Console.WriteLine("Developing features..."); } public class Designer : IWorkable, IAttendMeetings, IDesign { public void Work() => Console.WriteLine("Designing UI..."); public void AttendMeetings() => Console.WriteLine("Attending design review..."); public void DesignUI() => Console.WriteLine("Creating mockups..."); } public class Manager : IWorkable, IAttendMeetings { public void Work() => Console.WriteLine("Managing team..."); public void AttendMeetings() => Console.WriteLine("Leading meetings..."); } |
Benefits of ISP.
- No unnecessary method implementations—each class implements only what it needs.
- Easier to maintain—adding new roles doesn’t require modifying unrelated classes.
- Better separation of concerns—each interface has a single responsibility.
When Should You Apply the ISP Rule?
Scenario | Does ISP Apply? | Solution |
Large interface with many unrelated methods. | YES | Split it into smaller interfaces |
A class implements methods it doesn’t use | YES | Use only relevant interfaces |
Small, well-defined interfaces | NO | No need to split further |
One interface, one clear responsibility | NO | ISP is already followed |
Key Takeaways
- What ISP Solves?
- Prevents forcing classes to implement methods they don’t need.
- How to Fix?
- Split large interfaces into smaller, more focused ones.
- Why It Matters?
- Makes code more maintainable, readable, and flexible.
- Common Mistake?
- Creating “fat” interfaces with too many responsibilities.
Dependency Inversion Principle (DIP)
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.
What is DIP?
The Dependency Inversion Principle (DIP) helps design flexible and maintainable code by ensuring that high-level modules (business logic) do not depend directly on low-level modules (implementation details).
Instead, both should depend on abstractions (interfaces or abstract classes).
Key idea: Depend on abstractions, not concrete implementations.
Why Does DIP Matter?
Without DIP, changing low-level details forces modifications in high-level modules, making the system rigid, hard to extend, and difficult to test.
Applying DIP decouples dependencies, makes the system flexible, modular, and testable.
Applying DIP.
Imagine you want to create a notification system. You can create an EmailService
and Notification
classes roughly as given below.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
public class EmailService { public void SendEmail(string message) { Console.WriteLine($"Sending Email: {message}"); } } public class Notification { private EmailService _emailService = new EmailService(); public void Send(string message) { _emailService.SendEmail(message); } } |
Do you see problems with this implementation?
Well, here are some:
Notification
is tightly coupled toEmailService
—changing the notification method (e.g., SMS, Push Notification) requires modifying theNotification
class.- Difficult to test—We cannot easily replace
EmailService
with a mock for unit testing. - Not flexible—Adding new notification types (e.g.,
SMSService
) requires modifying existing code, violating OCP as well.
The solution is to depend on abstractions instead of concrete classes.
Create an interface (INotificationService
), and have both EmailService and SMSService implement it.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
public interface INotificationService { void Send(string message); } public class EmailService : INotificationService { public void Send(string message) { Console.WriteLine($"Sending Email: {message}"); } } public class SMSService : INotificationService { public void Send(string message) { Console.WriteLine($"Sending SMS: {message}"); } } // Notification class now depends on abstraction (INotificationService) public class Notification { private readonly INotificationService _notificationService; public Notification(INotificationService notificationService) { _notificationService = notificationService; } public void Send(string message) { _notificationService.Send(message); } } |
Now, we can pass any notification service dynamically:
1 2 3 4 5 |
Notification emailNotification = new Notification(new EmailService()); emailNotification.Send("Hello via Email!"); Notification smsNotification = new Notification(new SMSService()); smsNotification.Send("Hello via SMS!"); |
Benefits of DIP.
- Decoupled Code –
Notification
doesn’t directly depend on any specific notification service. - Easier to extend – Add
PushNotificationService
without modifying existing code. - Testability – Easily mock
INotificationService
for unit testing.
Dependency Injection (DIP in Practice)
Most modern frameworks, including ASP.NET Core, implement DIP using Dependency Injection (DI).
For example, the below code shows you can register and use a dependency in ASP.NET Core.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// Register services in DI container services.AddScoped<INotificationService, EmailService>(); // Inject in controllers public class NotificationController { private readonly INotificationService _notificationService; public NotificationController(INotificationService notificationService) { _notificationService = notificationService; } public void SendMessage(string message) { _notificationService.Send(message); } } |
Key Takeaways.
- What DIP Solves? – Prevents tight coupling between high-level and low-level modules.
- How to Fix? – Introduce interfaces/abstractions instead of depending on concrete classes.
- Why It Matters? – Makes code flexible, testable, and easy to extend.
- Common Mistake? – Directly instantiating dependencies (
new ClassName()
).