SOLID principles, with small C# examples
SOLID is five heuristics for keeping code changeable. They are not rules of nature, and applied mechanically they produce more code, not better code. The useful question for each one is: what kind of change does this make cheap?
S — Single responsibility
A class should have one reason to change. "One reason" means one actor: finance, operations, the API contract. A class that calculates a price and also writes an audit row has two.
// Two reasons to change: pricing rules and persistence.
public sealed class InvoiceService
{
public decimal Total(Invoice invoice) { /* tax rules */ }
public void Save(Invoice invoice) { /* EF Core */ }
}
// Split: each class has one reason.
public sealed class InvoiceCalculator : IInvoiceCalculator
{
public decimal Total(Invoice invoice) { /* tax rules */ }
}
public sealed class InvoiceRepository(AppDbContext db) : IInvoiceRepository
{
public Task SaveAsync(Invoice invoice, CancellationToken ct) =>
db.Invoices.AddAsync(invoice, ct).AsTask();
}
This is not about small classes. A fifty-line method that does one thing is fine; a ten-line method that mixes policy and I/O is not.
O — Open/closed
You should be able to add behaviour without editing the code that already works. In practice this means replacing long switch statements over a type code with polymorphism — but only at the point where new cases are actually arriving.
public interface IDiscount
{
bool AppliesTo(Order order);
decimal Apply(decimal subtotal);
}
public sealed class SeasonalDiscount : IDiscount { /* ... */ }
public sealed class LoyaltyDiscount : IDiscount { /* ... */ }
public sealed class PricingService(IEnumerable<IDiscount> discounts)
{
public decimal FinalTotal(Order order, decimal subtotal) =>
discounts.Where(d => d.AppliesTo(order))
.Aggregate(subtotal, (total, d) => d.Apply(total));
}
A new discount is a new class and a registration. Nothing else is touched. If there is exactly one discount and no plan for another, the interface is speculative — wait for the second case.
L — Liskov substitution
A subtype must be usable anywhere its base type is expected, without surprises. The classic violation is a derived class that throws on a method the base promises to support.
public abstract class DocumentStore
{
public abstract Task SaveAsync(Document doc, CancellationToken ct);
public abstract Task<Document?> LoadAsync(Guid id, CancellationToken ct);
}
// Breaks the contract: callers of DocumentStore expect Save to work.
public sealed class ReadOnlyStore : DocumentStore
{
public override Task SaveAsync(Document doc, CancellationToken ct) =>
throw new NotSupportedException();
}
The fix is not a flag. Split the contract: IDocumentReader and IDocumentWriter, and let the read-only adapter implement only the first. This is also where interface segregation starts.
I — Interface segregation
No client should be forced to depend on methods it does not use. Fat interfaces produce fake implementations and pointless throwing methods in tests.
// Too broad: a reporting screen needs one method but depends on six.
public interface IOrderService
{
Task<Order> GetAsync(Guid id);
Task CreateAsync(Order order);
Task CancelAsync(Guid id);
Task RefundAsync(Guid id, decimal amount);
Task<IReadOnlyList<OrderSummary>> ListAsync(DateOnly day);
Task ExportAsync(Stream stream);
}
// Split by role.
public interface IOrderReader { Task<IReadOnlyList<OrderSummary>> ListAsync(DateOnly day); }
public interface IOrderWriter { Task CreateAsync(Order order); }
public interface IOrderRefunds { Task RefundAsync(Guid id, decimal amount); }
Keep the split by consumer, not by method count. Two interfaces used by the same two callers should probably be one.
D — Dependency inversion
High-level policy should not depend on low-level detail; both should depend on an abstraction. This is the principle behind the onion, and the one that pays off most often.
// Depends on a concrete SMTP client: hard to test, hard to swap.
public sealed class WelcomeMailer
{
private readonly SmtpClient _smtp = new("mail.internal", 25);
public Task SendAsync(string to) => _smtp.SendAsync(to, "Welcome");
}
// Depends on a port owned by the application.
public interface IEmailSender
{
Task SendAsync(string to, string subject, string body, CancellationToken ct = default);
}
public sealed class WelcomeMailer(IEmailSender email)
{
public Task SendAsync(string to, CancellationToken ct) =>
email.SendAsync(to, "Welcome aboard", "...", ct);
}
The interface lives next to the code that uses it, not next to the implementation. That is what makes it a boundary rather than a wrapper.
When to stop
- Do not introduce an interface until there is a second implementation or a real seam (time, I/O, external service).
- Do not split a class on speculation; split it when a change forces you to touch unrelated code.
- Prefer duplication over the wrong abstraction. Two similar ten-line methods are cheaper than one parameterised monster.
SOLID describes pressure points, not a checklist. If applying a principle makes the next change harder, you have applied it in the wrong place.