Articles › Software Architecture

SOLID Principles in C# with Real-World Examples

Learn the five SOLID principles — SRP, OCP, LSP, ISP and DIP — through practical C# examples, with before/after code and advice on when not to over-apply them.

SOLID is a set of five design principles that help you write object-oriented code that is easy to understand, change and test. They were popularized by Robert C. Martin, and although they're over twenty years old, they're still the vocabulary every serious .NET developer uses in code reviews. Let's go through each principle with practical C# — and talk honestly about when not to apply them.

S — Single Responsibility Principle

A class should have only one reason to change.

"Responsibility" means a reason to change — usually tied to a single actor or concern. Consider this class:

// ❌ Three reasons to change: business rules, persistence, email format
public class InvoiceService
{
    public decimal CalculateTotal(Invoice invoice) =>
        invoice.Lines.Sum(l => l.Quantity * l.UnitPrice) * 1.09m;

    public void Save(Invoice invoice)
    {
        using var connection = new SqlConnection("...");
        // INSERT INTO Invoices ...
    }

    public void SendByEmail(Invoice invoice)
    {
        var body = $"<h1>Invoice #{invoice.Number}</h1>...";
        // SmtpClient...
    }
}

A tax change, a database migration and a new email template all touch the same file. Split them:

public class InvoiceCalculator
{
    private const decimal TaxRate = 0.09m;
    public decimal Total(Invoice invoice) =>
        invoice.Lines.Sum(l => l.Quantity * l.UnitPrice) * (1 + TaxRate);
}

public interface IInvoiceRepository { Task AddAsync(Invoice invoice, CancellationToken ct); }

public interface IInvoiceMailer { Task SendAsync(Invoice invoice, CancellationToken ct); }

Each class is smaller, easier to test, and changes for exactly one reason.

O — Open/Closed Principle

Software entities should be open for extension, but closed for modification.

You should be able to add behavior without editing code that already works.

// ❌ Every new shipping method means editing this switch
public decimal ShippingCost(Order order) => order.ShippingMethod switch
{
    "post"    => 50_000,
    "express" => 120_000,
    "pickup"  => 0,
    _ => throw new NotSupportedException()
};

Use polymorphism instead:

public interface IShippingStrategy
{
    string Key { get; }
    decimal Cost(Order order);
}

public class PostShipping : IShippingStrategy
{
    public string Key => "post";
    public decimal Cost(Order order) => 50_000;
}

public class ExpressShipping : IShippingStrategy
{
    public string Key => "express";
    public decimal Cost(Order order) => order.Weight > 5 ? 180_000 : 120_000;
}

public class ShippingCalculator(IEnumerable<IShippingStrategy> strategies)
{
    public decimal Cost(Order order) =>
        strategies.Single(s => s.Key == order.ShippingMethod).Cost(order);
}

Adding a new method is now a new class plus one DI registration. Existing, tested code stays untouched.

L — Liskov Substitution Principle

Subtypes must be substitutable for their base types.

If code works with a base class, it must keep working with any derived class — without surprises. The textbook violation:

public class Rectangle
{
    public virtual int Width { get; set; }
    public virtual int Height { get; set; }
    public int Area => Width * Height;
}

public class Square : Rectangle
{
    public override int Width  { set { base.Width = base.Height = value; } }
    public override int Height { set { base.Width = base.Height = value; } }
}

void Resize(Rectangle r)
{
    r.Width = 5;
    r.Height = 4;
    Debug.Assert(r.Area == 20); // ❌ fails for Square (16)
}

A square is a rectangle mathematically, but not behaviorally. Warning signs of LSP violations:

  • overrides that throw NotImplementedException or NotSupportedException;
  • derived classes that strengthen preconditions ("this only works if…");
  • if (x is SpecialType) checks in code that should be polymorphic.

The fix is usually a better abstraction: model Shape with an Area and make Rectangle and Square siblings, or prefer composition over inheritance.

I — Interface Segregation Principle

Clients should not be forced to depend on methods they do not use.

// ❌ A "fat" interface
public interface IRepository<T>
{
    Task<T?> GetAsync(int id);
    Task<List<T>> ListAsync();
    Task AddAsync(T entity);
    Task UpdateAsync(T entity);
    Task DeleteAsync(int id);
    Task<byte[]> ExportToExcelAsync();
    Task BulkImportAsync(Stream csv);
}

A read-only report class now depends on deletion and import methods, and every implementation must provide them. Split by capability:

public interface IReadRepository<T> { Task<T?> GetAsync(int id); Task<List<T>> ListAsync(); }
public interface IWriteRepository<T> { Task AddAsync(T entity); Task DeleteAsync(int id); }
public interface IExcelExporter<T> { Task<byte[]> ExportAsync(); }

Small, focused interfaces are easier to implement, mock and understand.

D — Dependency Inversion Principle

High-level modules should not depend on low-level modules. Both should depend on abstractions.

// ❌ Business logic depends directly on infrastructure
public class RegistrationService
{
    private readonly SqlUserStore _store = new();
    private readonly KavenegarSmsClient _sms = new("api-key");
}
// ✅ Business logic owns the abstractions; infrastructure implements them
public interface IUserStore { Task AddAsync(User user, CancellationToken ct); }
public interface ISmsSender { Task SendAsync(string phone, string text, CancellationToken ct); }

public class RegistrationService(IUserStore store, ISmsSender sms)
{
    public async Task RegisterAsync(User user, CancellationToken ct)
    {
        await store.AddAsync(user, ct);
        await sms.SendAsync(user.Phone, "Welcome!", ct);
    }
}

In ASP.NET Core, the DI container wires concrete implementations to these abstractions. This is also the core idea behind Clean Architecture: the domain and application layers define interfaces, and the infrastructure layer implements them.

How the principles work together

Principle Main benefit
SRP Small, focused classes that change for one reason
OCP New features without editing tested code
LSP Reliable polymorphism, no surprising subclasses
ISP Lean contracts, easy mocks
DIP Swappable infrastructure, testable business logic

When not to over-apply SOLID

SOLID is a tool, not a goal. Over-applying it produces dozens of one-method interfaces, abstractions with a single implementation "just in case", and code that's harder to navigate than the problem deserves.

Practical guidelines:

  • Introduce an abstraction when you have a real reason: a second implementation, a test seam, or an external system.
  • Don't split a 40-line class into five files on day one; refactor when it starts having multiple reasons to change.
  • Prefer readable code over textbook purity. The goal is code that's cheap to change.

Summary

SOLID helps you design classes that are focused (SRP), extensible (OCP), safely polymorphic (LSP), lean in their contracts (ISP) and decoupled from infrastructure (DIP). Applied with judgment, these principles make .NET code easier to test and evolve — which is ultimately what software design is about.

FAQ

Is SOLID only for object-oriented languages?

It was described in OOP terms, but the ideas — single purpose, extension points, small contracts, depending on abstractions — apply to functional and modular code too.

Does every class need an interface?

No. Create interfaces for boundaries: infrastructure, external services, and places where you need substitution in tests. Plain domain classes and DTOs usually don't need them.

Which principle matters most in practice?

SRP and DIP usually give the biggest return: smaller classes and business logic that doesn't depend on databases or frameworks.