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:

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:

  1. Persistence (SaveToDatabase) is a database concern.
  2. Notification (SendEmailNotification) is a messaging concern.
  3. 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.

Database interactions such as save, read, or delete belong in a repository class.

Create a separate class for sending notifications.

A separate class for slug generation ensures better testability.

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, not BlogPost.
  • 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.

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.

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.

Let’s say we want to render squares too and can create a Square class as given below.

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.

Now, our shape classes, both Rectangle and Square, will implement this interface independently.

Here’s the new implementation of the Rectangle class.

And here’s the new implementation of the Sqaure class.

How is LSP helping here?

  • Rectangle and Squareare independent and implement IShape 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:

Now, do you see any problems with this interface?

Well, here are some of them.

  • Managers don’t need WriteCode() or DesignUI().
  • 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:

Now, each class implements only the interfaces it needs:

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?

ScenarioDoes ISP Apply?Solution
Large interface with many unrelated methods.YESSplit it into smaller interfaces
A class implements methods it doesn’t useYESUse only relevant interfaces
Small, well-defined interfacesNONo need to split further
One interface, one clear responsibilityNOISP 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.

Do you see problems with this implementation?

Well, here are some:

  1. Notification is tightly coupled to EmailService—changing the notification method (e.g., SMS, Push Notification) requires modifying the Notification class.
  2. Difficult to test—We cannot easily replace EmailService with a mock for unit testing.
  3. 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.

Now, we can pass any notification service dynamically:

Benefits of DIP.

  1. Decoupled CodeNotification doesn’t directly depend on any specific notification service.
  2. Easier to extend – Add PushNotificationService without modifying existing code.
  3. 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.

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()).