This is the last post in the C# series, and it is the one that ties the rest together. SOLID gets taught as five unrelated rules with contrived shape-and-animal examples, which is why most people can recite the acronym and not apply it. In practice all five are aimed at one thing: making change cheap. Code you never change does not need SOLID; code you change weekly lives or dies by it.
Every example here uses the same book system the series has been building, and each principle connects back to a post that covered its mechanism.
S: Single Responsibility
A class should have one reason to change.
Not “do one thing”: that phrase is too vague to act on. One reason to change is testable: list who would ask for this class to be modified.
public class BookService // three reasons to change
{
public void Purchase(int bookId)
{
var book = _db.Query<Book>("SELECT ..."); // the DBA changes the schema
var total = book.Price * 1.2m; // finance changes the VAT rate
_smtp.Send(book.Buyer, $"Thanks for {book.Title}");// marketing changes the email
}
}
Three departments can force a change to this one file. They will collide in the same pull request, and a test for the VAT calculation has to stand up a database and an SMTP server.
public class BookService
{
private readonly IBookRepository _repository; // data access changes there
private readonly IPriceCalculator _pricing; // tax rules change there
private readonly IEmailSender _email; // messaging changes there
public void Purchase(int bookId)
{
var book = _repository.Get(bookId);
var total = _pricing.CalculateTotal(book);
_email.SendPurchaseConfirmation(book, total);
}
}
Now the VAT change touches one small, directly testable class. BookService is left with its actual job: sequencing the steps.
Where it goes wrong: taken too far you get a hundred one-method classes and a call graph nobody can follow. The unit of responsibility is a reason to change, not a line of code. If two things always change together, they belong together.
O: Open/Closed
Open for extension, closed for modification.
Adding a feature should mean adding code, not editing working code.
public decimal GetDiscount(Book book)
{
if (book.Category == "Textbook") return 0.10m;
else if (book.Category == "Sale") return 0.25m;
else if (book.Category == "Rare") return 0m;
return 0.05m; // edited for every new category
}
Every new category means reopening a tested method, and the risk is not the new branch: it is the four existing ones you might disturb.
public interface IDiscountRule
{
bool AppliesTo(Book book);
decimal Discount { get; }
}
public class TextbookDiscount : IDiscountRule
{
public bool AppliesTo(Book book) => book.Category == "Textbook";
public decimal Discount => 0.10m;
}
public class DiscountCalculator
{
private readonly IEnumerable<IDiscountRule> _rules;
public DiscountCalculator(IEnumerable<IDiscountRule> rules) => _rules = rules;
public decimal GetDiscount(Book book) =>
_rules.FirstOrDefault(r => r.AppliesTo(book))?.Discount ?? 0.05m;
}
A new rule is a new class plus one registration line. DiscountCalculator is never touched again, and .NET’s container injects IEnumerable<IDiscountRule> with every registered implementation automatically, which makes this pattern almost free.
The mechanisms are the ones from earlier posts: polymorphism, interfaces, and delegates when a whole class is overkill.
Where it goes wrong: you cannot make everything extensible, and pre-building extension points for changes that never arrive is speculative complexity. Wait for the second reason to change, then abstract. An if with two branches is fine; the same if growing every sprint is the signal.
L: Liskov Substitution
A subtype must be usable anywhere its base type is expected, without surprising the caller.
This is the one people memorise and cannot apply, because the textbook example is a square and a rectangle. Here is a version you will actually meet:
public class BookRepository
{
public virtual void Save(Book book) { ... }
}
public class ReadOnlyBookRepository : BookRepository
{
public override void Save(Book book) => throw new NotSupportedException(); // violation
}
Every method written against BookRepository can now blow up depending on which instance it received. The caller did nothing wrong: the subtype broke the promise the base type made.
The tells are consistent: an override that throws instead of working, that needs more from its inputs than the base did, that delivers less than the base promised, or that does nothing where the base did something.
The fix is almost always that the hierarchy is wrong. Split the contract:
public interface IBookReader { Book Get(int id); }
public interface IBookWriter { void Save(Book book); }
Now “read-only” is expressible in the type system, and a caller that needs to save asks for IBookWriter and cannot be handed something that will throw.
Liskov is the principle that makes polymorphism trustworthy. Without it, callers start type-checking defensively, and the whole benefit evaporates.
I: Interface Segregation
No client should be forced to depend on methods it does not use.
public interface IBookRepository
{
Book Get(int id);
void Save(Book book);
void Delete(int id);
void BulkImport(Stream csv);
void Reindex();
Task<Report> GenerateSalesReport(DateRange range);
}
A controller that only reads one book now depends on all six. Its test double must stub all six. Adding a method to the interface breaks every implementation, including three fakes in the test project that never needed it.
public interface IBookReader { Book Get(int id); }
public interface IBookWriter { void Save(Book book); void Delete(int id); }
public interface IBookImporter { void BulkImport(Stream csv); }
public interface IBookReports { Task<Report> GenerateSalesReport(DateRange range); }
One class can still implement all four: segregation is about how consumers depend, not about how many classes you have. The constructor of each consumer now states precisely what it uses, and, as the interfaces post noted, the useful rule of thumb is to define the interface where it is consumed, in the shape the consumer needs, rather than as a mirror of some implementation class.
IEnumerable<T> is the framework’s own best example: one method, so everything in .NET can implement it and every LINQ operator can consume it.
D: Dependency Inversion
Depend on abstractions, not concretions. High-level modules should not depend on low-level ones.
This is the dependency injection post stated as a principle, and the distinction is worth being precise about: dependency inversion is the principle, dependency injection is the technique.
public class BookService
{
private readonly SqlBookRepository _repository = new(); // business logic depends on SQL Server
}
public class BookService
{
private readonly IBookRepository _repository; // depends on an abstraction
public BookService(IBookRepository repository) => _repository = repository;
}
The word inversion is the interesting part, and it is easy to miss. It is not just “use an interface”. It is about who owns the abstraction. Normally the low-level module defines what it offers and the high-level module adapts. Inverted, the high-level module declares the interface it needs, in its own project, and the low-level module implements it:
Domain project: IBookRepository, Book, BookService
Infrastructure project: SqlBookRepository : IBookRepository
The infrastructure project references the domain, never the reverse. The arrow of dependency now points inward, towards the business rules, which is precisely what clean architecture, hexagonal architecture and ports-and-adapters all describe. Your domain can be compiled, tested and understood without EF Core, without a database, without ASP.NET.
Using them without overdoing them
The honest caveat: SOLID is a set of heuristics, and applied mechanically it produces the enterprise-Java caricature: twelve interfaces, four factories, and a day spent finding where a string is actually formatted.
How I use them in practice:
- Do not apply them pre-emptively. Write the simple version. When something changes for the second time, refactor towards the principle that would have made it cheap. The second change is the signal; the first is just a change.
- Treat pain as the diagnostic. Hard to test → dependency inversion. Editing a working file for every new case → open/closed. Merge conflicts in one class → single responsibility. Fake implementations full of
NotImplementedException→ interface segregation. Callers checking types → Liskov. - Remember they serve change. A script that runs once and a config parser that has not changed in three years do not need any of this.
And note that four of the five reduce to the same instinct: depend on the smallest, most stable thing that does what you need. Small interfaces, honest subtypes, abstractions owned by the consumer, classes with one audience. Once you see that, SOLID stops being five rules and becomes one habit.
The same idea outside C#
Java (Netflix, Spring). SOLID came from the Java and C++ object-oriented world, Robert Martin assembled the five in the early 2000s, and Spring is arguably the largest working demonstration of dependency inversion in existence.
Go (Google). No inheritance, so Liskov is nearly moot, but interface segregation is culture: “the bigger the interface, the weaker the abstraction” is a Go proverb, and single-method interfaces like io.Reader are everywhere. Go also puts interfaces in the consuming package by default, which is dependency inversion enforced by convention.
Rust. Traits are segregated interfaces by nature, and generics plus trait bounds give open/closed extension with no runtime cost. No inheritance means no Liskov violations to write.
Functional languages (F#, Elixir, Haskell). SOLID’s goals appear as different mechanisms: single responsibility becomes small pure functions, open/closed becomes higher-order functions and pattern matching, dependency inversion becomes passing functions as parameters. The delegates post showed that a Func<T,bool> is often a one-method interface with less ceremony: that is the same principle, cheaper.
JavaScript (Meta). React’s move from class components to hooks was, in effect, a large-scale swap of inheritance for composition, and it made the same argument SOLID does: small pieces, composed, with dependencies passed in.
Summary
- SOLID exists to make change cheap. Code that never changes does not need it.
- S: one reason to change per class. Count the departments who could demand an edit.
- O: add behaviour by adding code, not editing working code. Interfaces, polymorphism and delegates are the mechanisms.
- L: a subtype must honour the base type’s promises. Overrides that throw, restrict inputs, or do nothing are violations, and the fix is usually to split the contract.
- I: consumers should depend only on what they use. Define interfaces in the shape the consumer needs, not as a mirror of the implementation.
- D: depend on abstractions, and let the high-level module own them. The arrow of dependency should point towards your business rules.
- Apply them in response to real pain, at the second reason to change, not speculatively.
- Underneath all five: depend on the smallest, most stable thing that does the job.
Interview questions and answers
1. What does SOLID stand for?
Single responsibility, Open/closed, Liskov substitution, Interface segregation, and Dependency inversion. They are design principles for object-oriented code, aimed collectively at making software cheaper to change without breaking what already works.
2. What is the single responsibility principle, really?
That a class should have one reason to change, one audience or concern that could require it to be modified. It is not “a class should do one thing”, which is too vague; the useful test is to ask who would request a change to this file, and to split it if the answer names more than one group.
3. How do you satisfy the open/closed principle in C#?
By putting variation behind an abstraction so new behaviour arrives as a new type rather than an edit. In practice: an interface with multiple implementations selected polymorphically, a strategy injected as a delegate, or a collection of rule objects resolved from the container so a new rule is a new class plus a registration.
4. Give a realistic Liskov substitution violation.
A read-only repository deriving from a writable one and throwing NotSupportedException from Save. Any code written against the base type can now fail depending on which instance it was given. The general tells are overrides that throw, that demand more of their inputs than the base, that return less than the base promised, or that silently do nothing.
5. How is interface segregation different from single responsibility?
Single responsibility is about why a class changes. Interface segregation is about what a consumer is forced to depend on. A class may legitimately implement several interfaces; the principle says its callers should each see only the subset they use, so that changes to one area do not ripple into unrelated consumers and test doubles.
6. What is the difference between dependency inversion and dependency injection?
Dependency inversion is the principle that both high- and low-level code should depend on abstractions, with the high-level module owning the abstraction. Dependency injection is the technique of supplying those dependencies from outside, usually via the constructor. You can use injection without inverting anything, by injecting concrete types.
7. Why is it called dependency inversion?
Because it reverses the usual direction of the dependency. Normally business logic references the data access layer. Inverted, the interface is declared in the domain project and implemented in the infrastructure project, so infrastructure depends on the domain and not the reverse. That is the structural idea behind clean and hexagonal architecture.
8. Can SOLID be over-applied?
Yes. Applied speculatively it produces layers of abstraction with one implementation each, interfaces that exist to satisfy a rule, and code where following a single request through the system takes twenty files. The principles are responses to demonstrated change, so the right time to apply one is the second time something changes for the same reason.
9. Which SOLID principle does dependency injection in ASP.NET Core most directly support?
Dependency inversion, by letting classes declare abstractions in their constructors while the composition root chooses implementations. It also enables open/closed, since injecting IEnumerable<IRule> lets new behaviour be added by registering a new type, and it supports single responsibility by making it cheap to split a class into collaborators.
10. How do SOLID principles apply in a language without inheritance, such as Go or Rust?
Liskov largely disappears, since there are no subtypes to substitute incorrectly. The other four remain, expressed through interfaces and traits: small consumer-defined interfaces for segregation, trait bounds and higher-order functions for open/closed, and passing dependencies as parameters for inversion. That they survive the removal of inheritance shows they are principles about coupling, not about class hierarchies.