Skip to main content

SOLID Principles in C#: Complete Guide with Code Examples

Learn SOLID principles in C# with real-world code examples. Master SRP, OCP, LSP, ISP and DIP in .NET — read the guide and write cleaner code today.

If you have been writing C# for a while, you have probably opened a class with 2,000 lines, 40 dependencies, and a comment that says "don't touch this". SOLID principles in C# are the best-known way to stop code from getting like that. This guide covers all five principles (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion) with real-world C# code examples you can run in a modern .NET project. It also explains why each principle matters and where developers usually go wrong.

If you are a beginner learning object-oriented design, an intermediate developer looking for best practices, or a senior engineer getting ready for design interviews, this complete guide to SOLID principles will help you write C# code that is easier to test, extend, and maintain.

What Are SOLID Principles in C#?

SOLID is an acronym for five object-oriented design principles gathered by Robert C. Martin ("Uncle Bob") in the early 2000s. The acronym itself was coined by Michael Feathers.

  • 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)

These principles are not tied to any one language, but they suit C# especially well. C# has interfaces, abstract classes, generics, records, and a built-in dependency injection container in ASP.NET Core, so applying SOLID in .NET feels natural rather than forced.

Why SOLID Design Principles Matter

SOLID is not about style or looking clever. It targets three concrete costs in software development:

  • Change cost: how many files you have to touch to add a feature.
  • Test cost: how hard it is to unit test a class in isolation.
  • Risk: how likely a change in one place is to break something unrelated.

Each principle reduces coupling, which is how much one piece of code knows about another. Less coupling means smaller, safer changes.

1. Single Responsibility Principle (SRP) in C#

Definition: A class should have only one reason to change.

People often misread this as "a class should do only one thing." Martin's own later wording is clearer: a class should be responsible to one actor, meaning one group of people who might ask for changes. If both the finance team and the IT team can force changes to the same class, it has two responsibilities.

SRP Violation Example

using System.Net.Mail;
using System.Text.Json;

public class InvoiceService
{
    // Reason to change #1: tax/pricing rules (Finance)
    public decimal CalculateTotal(Invoice invoice) =>
        invoice.Lines.Sum(l => l.Quantity * l.UnitPrice) * 1.20m;

    // Reason to change #2: storage technology (IT/Infrastructure)
    public void Save(Invoice invoice) =>
        File.WriteAllText($"invoice-{invoice.Id}.json", JsonSerializer.Serialize(invoice));

    // Reason to change #3: communication channel (Marketing/Support)
    public void EmailCustomer(Invoice invoice)
    {
        using var client = new SmtpClient("smtp.example.com");
        client.Send("billing@example.com", invoice.CustomerEmail,
            "Your invoice", $"Total due: {CalculateTotal(invoice):C}");
    }
}

You cannot unit test CalculateTotal without also compiling code that depends on SMTP and the file system. Moving from files to SQL means editing the same class that holds your tax logic.

SRP Refactored

public record InvoiceLine(string Description, int Quantity, decimal UnitPrice);
public record Invoice(Guid Id, string CustomerEmail, IReadOnlyList<InvoiceLine> Lines);

public class InvoiceCalculator
{
    private const decimal TaxRate = 0.20m;

    public decimal CalculateTotal(Invoice invoice)
    {
        var subtotal = invoice.Lines.Sum(l => l.Quantity * l.UnitPrice);
        return subtotal * (1 + TaxRate);
    }
}

public interface IInvoiceRepository
{
    Task SaveAsync(Invoice invoice, CancellationToken ct = default);
}

public interface IInvoiceNotifier
{
    Task NotifyAsync(Invoice invoice, decimal total, CancellationToken ct = default);
}

// Orchestrates the workflow but owns no business rules itself
public class InvoiceProcessor(
    InvoiceCalculator calculator,
    IInvoiceRepository repository,
    IInvoiceNotifier notifier)
{
    public async Task ProcessAsync(Invoice invoice, CancellationToken ct = default)
    {
        var total = calculator.CalculateTotal(invoice);
        await repository.SaveAsync(invoice, ct);
        await notifier.NotifyAsync(invoice, total, ct);
    }
}

Each class now has a single reason to change. The example uses C# 12 primary constructors, which remove the boilerplate that used to make small, focused classes feel tedious to write.

2. Open/Closed Principle (OCP) in C#

Definition: Software entities should be open for extension but closed for modification.

You should be able to add new behavior without editing existing, tested code. A common sign of an OCP violation is a switch or if/else chain on a "type" field that keeps growing.

OCP Violation Example

public class PriceCalculator
{
    public decimal Calculate(decimal amount, string customerType) => customerType switch
    {
        "Regular" => amount,
        "Premium" => amount * 0.90m,
        "Employee" => amount * 0.70m,
        // Every new promotion means editing this class and re-testing all of it
        _ => amount
    };
}

OCP Refactored with the Strategy Pattern

public record Customer(string Name, string Type, bool IsFirstOrder);

public interface IDiscountRule
{
    bool AppliesTo(Customer customer);
    decimal Apply(decimal amount);
}

public class PremiumDiscount : IDiscountRule
{
    public bool AppliesTo(Customer c) => c.Type == "Premium";
    public decimal Apply(decimal amount) => amount * 0.90m;
}

public class EmployeeDiscount : IDiscountRule
{
    public bool AppliesTo(Customer c) => c.Type == "Employee";
    public decimal Apply(decimal amount) => amount * 0.70m;
}

// New requirement? Add a class. No existing code changes.
public class FirstOrderDiscount : IDiscountRule
{
    public bool AppliesTo(Customer c) => c.IsFirstOrder;
    public decimal Apply(decimal amount) => amount - 10m;
}

public class PriceCalculator(IEnumerable<IDiscountRule> rules)
{
    public decimal Calculate(decimal amount, Customer customer)
    {
        var rule = rules.FirstOrDefault(r => r.AppliesTo(customer));
        return Math.Max(0, rule?.Apply(amount) ?? amount);
    }
}

In ASP.NET Core, register every rule and the container injects them all as IEnumerable<IDiscountRule>:

builder.Services.AddSingleton<IDiscountRule, PremiumDiscount>();
builder.Services.AddSingleton<IDiscountRule, EmployeeDiscount>();
builder.Services.AddSingleton<IDiscountRule, FirstOrderDiscount>();
builder.Services.AddSingleton<PriceCalculator>();

Why it matters: tested code stays untouched, and new behavior lives in new files. That keeps merge conflicts and regressions down on large teams.

3. Liskov Substitution Principle (LSP) in C#

Definition: Objects of a derived type must be usable in place of their base type without breaking the correctness of the program.

LSP is the principle developers understand least. The textbook Rectangle/Square example is fine, but the violation you will actually meet in production usually looks like this: a subclass that throws NotSupportedException.

LSP Violation Example

public abstract class PaymentMethod
{
    public abstract string Charge(decimal amount);
    public abstract void Refund(string transactionId, decimal amount);
}

public class CreditCardPayment : PaymentMethod
{
    public override string Charge(decimal amount) => $"cc_{Guid.NewGuid():N}";
    public override void Refund(string transactionId, decimal amount)
        => Console.WriteLine($"Refunded {amount:C} to card for {transactionId}");
}

public class GiftCardPayment : PaymentMethod
{
    public override string Charge(decimal amount) => $"gc_{Guid.NewGuid():N}";

    // Surprise! Any code that trusts PaymentMethod will crash here.
    public override void Refund(string transactionId, decimal amount)
        => throw new NotSupportedException("Gift cards cannot be refunded.");
}

The base class promised that every payment method can be refunded, and GiftCardPayment breaks that promise. Every caller now has to know about the concrete subtype, which defeats the purpose of polymorphism.

LSP Refactored

public interface IPaymentMethod
{
    string Charge(decimal amount);
}

public interface IRefundablePayment : IPaymentMethod
{
    void Refund(string transactionId, decimal amount);
}

public class CreditCardPayment : IRefundablePayment
{
    public string Charge(decimal amount) => $"cc_{Guid.NewGuid():N}";
    public void Refund(string transactionId, decimal amount)
        => Console.WriteLine($"Refunded {amount:C} to card for {transactionId}");
}

public class GiftCardPayment : IPaymentMethod
{
    public string Charge(decimal amount) => $"gc_{Guid.NewGuid():N}";
}

public class RefundService
{
    public bool TryRefund(IPaymentMethod payment, string transactionId, decimal amount)
    {
        if (payment is IRefundablePayment refundable)
        {
            refundable.Refund(transactionId, amount);
            return true;
        }
        return false; // An honest, explicit outcome instead of a runtime exception
    }
}

A quick LSP checklist for C# subclasses:

  • Don't strengthen preconditions (for example, rejecting inputs the base type accepts).
  • Don't weaken postconditions (for example, returning null where the base type guarantees a value).
  • Don't throw new exception types that callers of the base type can't expect.
  • Preserve the base type's invariants.

4. Interface Segregation Principle (ISP) in C#

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

"Fat" interfaces push implementers into writing empty methods or throwing exceptions, and as you just saw, that often leads straight to an LSP violation. ISP and LSP tend to show up together.

ISP Violation Example

public interface IMultiFunctionDevice
{
    void Print(string document);
    void Scan(string document);
    void Fax(string document, string number);
}

public class BasicPrinter : IMultiFunctionDevice
{
    public void Print(string document) => Console.WriteLine($"Printing {document}");
    public void Scan(string document) => throw new NotImplementedException();
    public void Fax(string document, string number) => throw new NotImplementedException();
}

ISP Refactored

public interface IPrinter { void Print(string document); }
public interface IScanner { void Scan(string document); }
public interface IFax     { void Fax(string document, string number); }

public class BasicPrinter : IPrinter
{
    public void Print(string document) => Console.WriteLine($"Printing {document}");
}

public class OfficeMachine : IPrinter, IScanner, IFax
{
    public void Print(string document) => Console.WriteLine($"Printing {document}");
    public void Scan(string document) => Console.WriteLine($"Scanning {document}");
    public void Fax(string document, string number) => Console.WriteLine($"Faxing to {number}");
}

// This class depends only on what it actually needs
public class ReportPublisher(IPrinter printer)
{
    public void Publish(string report) => printer.Print(report);
}

Why it matters: smaller interfaces are easier to mock in unit tests, easier to implement, and they reduce unnecessary recompilation and coupling across assemblies. The .NET base class library does the same thing: IEnumerable<T>, IReadOnlyCollection<T>, and IList<T> are separate interfaces so each consumer can ask for the least it needs.

5. Dependency Inversion Principle (DIP) in C#

Definition: High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details.

Don't confuse DIP with dependency injection (DI). DIP is the design principle. DI is one technique for applying it, and ASP.NET Core's built-in container makes that technique easy.

DIP Violation Example

public class OrderService
{
    // Business logic hard-wired to infrastructure
    private readonly SqlOrderRepository _repository = new();
    private readonly SmtpEmailSender _email = new();

    public async Task PlaceOrderAsync(Order order)
    {
        await _repository.SaveAsync(order);
        await _email.SendAsync(order.CustomerEmail, "Order confirmed");
    }
}

You can't test OrderService without a real database and mail server, and swapping SQL Server for Cosmos DB means rewriting business logic.

DIP Refactored

public record Order(Guid Id, string CustomerEmail, decimal Total);

// Abstractions are owned by the high-level (domain) layer
public interface IOrderRepository
{
    Task SaveAsync(Order order, CancellationToken ct = default);
}

public interface IEmailSender
{
    Task SendAsync(string to, string subject, CancellationToken ct = default);
}

public class OrderService(IOrderRepository repository, IEmailSender email)
{
    public async Task PlaceOrderAsync(Order order, CancellationToken ct = default)
    {
        ArgumentNullException.ThrowIfNull(order);
        await repository.SaveAsync(order, ct);
        await email.SendAsync(order.CustomerEmail, "Order confirmed", ct);
    }
}

// Low-level details live in the infrastructure layer
public class SqlOrderRepository : IOrderRepository
{
    public Task SaveAsync(Order order, CancellationToken ct = default)
    {
        Console.WriteLine($"Saved order {order.Id} to SQL");
        return Task.CompletedTask;
    }
}

Wire it up in Program.cs:

builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
builder.Services.AddScoped<OrderService>();

Unit testing is now simple. This xUnit test uses hand-written fakes and needs no mocking library:

public class FakeOrderRepository : IOrderRepository
{
    public List<Order> Saved { get; } = new();
    public Task SaveAsync(Order order, CancellationToken ct = default)
    {
        Saved.Add(order);
        return Task.CompletedTask;
    }
}

public class FakeEmailSender : IEmailSender
{
    public List<string> SentTo { get; } = new();
    public Task SendAsync(string to, string subject, CancellationToken ct = default)
    {
        SentTo.Add(to);
        return Task.CompletedTask;
    }
}

public class OrderServiceTests
{
    [Fact]
    public async Task PlaceOrder_SavesOrder_AndSendsConfirmation()
    {
        var repo = new FakeOrderRepository();
        var email = new FakeEmailSender();
        var service = new OrderService(repo, email);
        var order = new Order(Guid.NewGuid(), "jane@example.com", 49.99m);

        await service.PlaceOrderAsync(order);

        Assert.Single(repo.Saved);
        Assert.Contains("jane@example.com", email.SentTo);
    }
}

SOLID Principles in C#: Best Practices

  • Refactor toward SOLID; don't start with it. Apply a principle when you feel real pain, such as a second implementation, a hard-to-test class, or a growing switch statement.
  • Let the domain own its interfaces. Put IOrderRepository in your core or domain project, not in the infrastructure project. That placement is what makes the dependency "inverted."
  • Prefer composition over inheritance. Most LSP problems disappear when you compose small interfaces instead of building deep class hierarchies.
  • Use records for data and classes for behavior. Immutable records keep responsibilities clear and help avoid LSP bugs caused by mutable state.
  • Use SOLID as a code review vocabulary. A comment like "This class has two reasons to change" is more precise and actionable than "this feels messy."

Common Pitfalls When Applying SOLID

Over-Engineering

Creating an IStringFormatter interface with exactly one implementation that will never change adds indirection with no benefit. Abstractions cost something too. Add them when there is a real second implementation, a testing need, or a clear boundary such as I/O, external APIs, or time (TimeProvider in .NET 8 and later is a good example).

Confusing SRP with "One Method Per Class"

Splitting everything into tiny classes scatters related logic across dozens of files. SRP is about cohesion: keep together the things that change together.

Header Interfaces

Generating an interface that simply mirrors every public method of a class (IUserService for UserService) often recreates the fat interface that ISP warns against. Design interfaces around what the client needs.

Service Locator Instead of DI

Calling serviceProvider.GetService<T>() deep inside business logic hides dependencies and undermines DIP. Use constructor injection so dependencies stay explicit.

SOLID Principles Interview Questions (Quick Answers)

  • What is the difference between DIP and dependency injection? DIP is a design principle about the direction of dependencies. DI is a technique for supplying dependencies from outside a class.
  • Which principle does throwing NotImplementedException in an override usually violate? LSP, and often ISP as well.
  • How does the Strategy pattern relate to SOLID? It is the classic way to satisfy OCP, and it depends on DIP.
  • Can you over-apply SOLID? Yes. Unnecessary abstractions make code harder to read. Balance SOLID with YAGNI and KISS.

Conclusion: Key Takeaways on SOLID Principles in C#

Applied with judgment, SOLID principles in C# keep a codebase adaptable instead of fragile. Here is a summary:

  • SRP: Give each class one reason to change, meaning one actor who can request changes.
  • OCP: Add behavior with new classes (strategies) instead of editing tested code.
  • LSP: Subtypes must keep the promises of their base types. A NotSupportedException in an override is a warning sign.
  • ISP: Prefer small, client-focused interfaces over fat ones.
  • DIP: Make business logic depend on abstractions, and wire in the concrete classes with ASP.NET Core dependency injection.

Start small. Pick the hardest-to-test class in your current project and apply one principle this week, most likely SRP or DIP. You will see the benefits in your next pull request. For more C# tutorials, design patterns, and .NET best practices, explore more guides here on csharp-coder.com.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...