← Back to Blog

SOLID Principles in C#: Writing Maintainable and Scalable Code

As your C# projects grow, one of the biggest challenges isn’t just writing code that works - it’s writing code that stays clean, understandable, and easy to change. Anyone can make something that “just works” today, but without structure, that same code can become a nightmare tomorrow. That’s where the SOLID principles come in.

SOLID is a set of five design principles that help you build software that’s robust, flexible, and maintainable. First introduced by Robert C. Martin (aka Uncle Bob), these principles are now considered essential best practices in object-oriented programming. Whether you’re building enterprise systems, APIs, or even small utilities, SOLID can dramatically improve the quality of your code.

In this article, we’ll walk through each of the SOLID principles using practical C# examples. We’ll look at common violations, how to refactor them, and why each principle matters. By the end, you’ll have a solid (pun intended) grasp of how to apply these ideas in real-world projects.

Single Responsibility Principle (SRP)

The Single Responsibility Principle says that a class should have only one reason to change. In other words, it should do one thing - and do it well.

Sounds simple, right? But in practice, it’s easy to let classes grow into “God objects” that handle everything from business logic to database access to UI formatting.

public class Employee
{
    public void CalculatePay() { /* Payroll logic */ }
    public void SaveToDatabase() { /* Database logic */ }
    public void GenerateReport() { /* Report logic */ }
}

This class mixes payroll, persistence, and reporting. If you change how employees are saved, you risk breaking payroll. If reporting formats change, you’re touching the same class again. It’s fragile and hard to test.

Let’s refactor it:

public class Employee
{
    public string Name { get; set; }
}

public class PayrollProcessor
{
    public void CalculatePay(Employee employee) { /* Payroll logic */ }
}

public class EmployeeRepository
{
    public void Save(Employee employee) { /* Database logic */ }
}

public class ReportGenerator
{
    public void Generate(Employee employee) { /* Reporting logic */ }
}

Now each class has a single responsibility. Changes are localized, and testing becomes easier.

If your class description includes the word “and,” it probably has too many responsibilities. Split it up.

Open/Closed Principle (OCP)

The Open/Closed Principle says that software should be open for extension but closed for modification. You should be able to add new behavior without changing existing code.

public class DiscountCalculator
{
    public double CalculateDiscount(string customerType, double amount)
    {
        if (customerType == "Regular")
            return amount * 0.1;
        else if (customerType == "VIP")
            return amount * 0.2;
        return 0;
    }
}

Every time you add a new customer type, you modify this method. That’s risky and violates OCP.

Let’s refactor using polymorphism:

public interface IDiscountStrategy
{
    double ApplyDiscount(double amount);
}

public class RegularDiscount : IDiscountStrategy
{
    public double ApplyDiscount(double amount) => amount * 0.1;
}

public class VipDiscount : IDiscountStrategy
{
    public double ApplyDiscount(double amount) => amount * 0.2;
}

public class DiscountCalculator
{
    public double CalculateDiscount(IDiscountStrategy strategy, double amount)
    {
        return strategy.ApplyDiscount(amount);
    }
}

Now you can add new discount types without touching DiscountCalculator. That’s OCP in action.

OCP doesn’t mean “never change code.” It means design your system so common changes don’t require modifying stable code.

Liskov Substitution Principle (LSP)

The Liskov Substitution Principle says that subclasses should be substitutable for their base classes. If your code works with a base class, it should also work with any subclass - without surprises.

public class Bird
{
    public virtual void Fly() { /* Flying logic */ }
}

public class Ostrich : Bird
{
    public override void Fly()
    {
        throw new NotImplementedException(); // Ostriches can’t fly
    }
}

This breaks LSP. If you pass an Ostrich to a method expecting a Bird, it might crash.

A better design:

public abstract class Bird { }

public interface IFlyable
{
    void Fly();
}

public class Sparrow : Bird, IFlyable
{
    public void Fly() { /* Flying logic */ }
}

public class Ostrich : Bird { /* Cannot fly */ }

Now only birds that can fly implement IFlyable. No surprises.

LSP is about preserving behavior. Subclasses must honor the expectations set by their base types.

Interface Segregation Principle (ISP)

The Interface Segregation Principle says that clients shouldn’t be forced to depend on methods they don’t use. In other words, keep interfaces small and focused.

public interface IWorker
{
    void Work();
    void Eat();
}

public class Robot : IWorker
{
    public void Work() { /* Work logic */ }
    public void Eat() { throw new NotImplementedException(); } // Robots don’t eat
}

The Robot class is forced to implement Eat(), even though it doesn’t need it.

Let’s fix it:

public interface IWorkable
{
    void Work();
}

public interface IFeedable
{
    void Eat();
}

public class Human : IWorkable, IFeedable
{
    public void Work() { /* Work logic */ }
    public void Eat() { /* Eating logic */ }
}

public class Robot : IWorkable
{
    public void Work() { /* Work logic */ }
}

Now each class implements only what it needs. Interfaces are clean and focused.

If an interface has more than 3-4 members, consider splitting it. Lean interfaces lead to cleaner code.

Dependency Inversion Principle (DIP)

The Dependency Inversion Principle says that high-level modules shouldn’t depend on low-level modules. Both should depend on abstractions.

public class SqlDatabase
{
    public void SaveData(string data) { /* Save to SQL */ }
}

public class DataProcessor
{
    private SqlDatabase _db = new SqlDatabase();
    public void Process(string data)
    {
        // Process data
        _db.SaveData(data);
    }
}

DataProcessor depends directly on SqlDatabase. That’s rigid and hard to test.

Let’s refactor:

public interface IDatabase
{
    void SaveData(string data);
}

public class SqlDatabase : IDatabase
{
    public void SaveData(string data) { /* Save to SQL */ }
}

public class DataProcessor
{
    private readonly IDatabase _db;

    public DataProcessor(IDatabase db)
    {
        _db = db;
    }

    public void Process(string data)
    {
        // Process data
        _db.SaveData(data);
    }
}

Now DataProcessor depends on an abstraction. You can inject any implementation - SQL, NoSQL, or a mock.

DIP is the foundation of Dependency Injection (DI), a pattern heavily used in ASP.NET Core and other frameworks.

Putting It All Together

Let’s build a small system that applies all five SOLID principles. Imagine we want to process orders and notify customers.

public interface INotifier
{
    void Notify(string message);
}

public class EmailNotifier : INotifier
{
    public void Notify(string message) => Console.WriteLine($"Email: {message}");
}

public class SmsNotifier : INotifier
{
    public void Notify(string message) => Console.WriteLine($"SMS: {message}");
}

public class Order
{
    public int Id { get; set; }
}

public class OrderProcessor
{
    private readonly INotifier _notifier;

    public OrderProcessor(INotifier notifier) => _notifier = notifier;

    public void Process(Order order)
    {
        // SRP: Process order
        // OCP: Can add new notification types without changing this class
        _notifier.Notify($"Order {order.Id} processed");
    }
}

This design follows:

  • SRP: Each class has one job
  • OCP: New notifiers can be added without modifying OrderProcessor
  • LSP: Any INotifier can replace another
  • ISP: INotifier is small and focused
  • DIP: OrderProcessor depends on an abstraction

Common Pitfalls and Misconceptions

  • Over-engineering: Don’t split everything into tiny classes just for the sake of it.
  • Premature abstraction: Let real requirements drive your design.
  • Forgetting context: A small console app may not need full SOLID treatment.
  • Mixing concerns: Refactor regularly to keep responsibilities clear.

Summary

The SOLID principles help you write code that’s easier to understand, test, and extend. They’re not rules - they’re guidelines. Use them thoughtfully.

  • SRP: Keep classes focused.
  • OCP: Design for extension.
  • LSP: Subclasses should behave like their base types.
  • ISP: Favor small interfaces.
  • DIP: Depend on abstractions.

Start small. Apply SRP by splitting large classes. Use OCP to design for extension. Respect LSP by thinking about behavior. Keep interfaces lean with ISP. And embrace DIP by depending on abstractions.