
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
nullwhere 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
IOrderRepositoryin 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
NotImplementedExceptionin 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
NotSupportedExceptionin 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.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment