SOLID Principles
By the end of this lesson
Apply each principle where it helps, and name the cost of overusing it.
SOLID is five principles, each written as a response to a specific kind of pain someone had already experienced. Read that way they are useful. Read as a checklist to satisfy, they produce codebases that are harder to work in than the ones they were meant to improve.
Every one of the five can be overshot, and the overshoot is not a mild inefficiency — it is a real cost paid on every read and every change. So each principle below comes with the pain it addresses, a concrete case, and the shape it takes when pushed past the point of usefulness.
The five, in plain language, with a case from an orders and invoicing application:
- Single responsibility
- A class should have one reason to change. InvoiceTotals calculates amounts; it does not also render the PDF and email it. The pain it addresses: a class that three different teams edit for three unrelated reasons, where every change risks the other two.
- Open for extension, closed for modification
- You should be able to add a case without editing code that already works. A new customer tier arrives as a new class rather than a new branch in a switch statement that appears in nine places. The pain: the fourth of those nine switches, the one nobody remembered to update.
- Liskov substitution
- Anything written against a base type must keep working when handed a subtype. If ArchivedEmployee inherits Employee but throws from ChangeSalary, then every method taking an Employee is now conditionally correct. The pain: a type check or a try/catch appearing at each call site to work around one subtype.
- Interface segregation
- A consumer should not have to depend on members it never uses. A payroll export that only reads employees should not depend on an interface that also deletes them. The pain: a test double with eleven methods, ten of which throw NotImplementedException.
- Dependency inversion
- High-level policy depends on an abstraction rather than on a concrete detail. ApproveInvoiceHandler takes IInvoiceRepository, not EfInvoiceRepository. The pain: needing a database running to test a rule about approval thresholds. The next lesson covers this one on its own, because it is the one that changes what is possible rather than only what is tidy.
public decimal DiscountFor(Customer customer, decimal orderNet)
{
switch (customer.Tier)
{
case CustomerTier.Standard:
return 0m;
case CustomerTier.Trade:
return orderNet * 0.05m;
case CustomerTier.Wholesale:
return orderNet >= 5_000m
? orderNet * 0.12m
: orderNet * 0.08m;
default:
throw new NotSupportedException($"No discount rule for tier {customer.Tier}.");
}
}- Adding a fourth tier means editing this method. On its own that is a small change and a reasonable one.
- The problem starts when the same decision is made in more than one place: quoting, order pricing, invoice generation and the sales report. Now one new tier is four edits, and the cost of missing one is a customer being charged differently on their invoice than on their quote.
- The throw in the default case is worth keeping whichever design you choose. A tier with no rule should fail loudly rather than silently produce a zero discount.
public interface IDiscountPolicy
{
CustomerTier Tier { get; }
decimal DiscountFor(decimal orderNet);
}
public sealed class WholesaleDiscount : IDiscountPolicy
{
public CustomerTier Tier => CustomerTier.Wholesale;
public decimal DiscountFor(decimal orderNet) =>
orderNet >= 5_000m ? orderNet * 0.12m : orderNet * 0.08m;
}
public sealed class DiscountCalculator(IEnumerable<IDiscountPolicy> policies)
{
private readonly Dictionary<CustomerTier, IDiscountPolicy> _byTier =
policies.ToDictionary(policy => policy.Tier);
public decimal DiscountFor(Customer customer, decimal orderNet) =>
_byTier.TryGetValue(customer.Tier, out IDiscountPolicy? policy)
? policy.DiscountFor(orderNet)
: throw new NotSupportedException($"No discount rule for tier {customer.Tier}.");
}- Each tier's rule is a class. Adding one means adding a file and registering it; no existing rule is touched, and no existing test can break as a result.
- The calculator receives every registered policy and indexes them by tier. In ASP.NET Core, registering several implementations of one interface and injecting IEnumerable of that interface is built-in behaviour, so there is no extra plumbing.
- Wholesale keeps its threshold inside its own class, which means a change to that threshold has exactly one location and one set of tests.
- Be honest about the cost: the rules are now spread across four files, and no single screen shows you all of them. With three tiers that have not changed in five years, the switch is easier to read and the switch is the better answer.
Each principle has a version that helps and a version that damages. The difference is usually how early you applied it:
| Where it helps | Where it turns into damage | |
|---|---|---|
| Single responsibility | Splitting a class that two teams edit for unrelated reasons, so their changes stop colliding | A class per method. Two hundred one-method classes, none of which tells you what a feature does, and a call chain eight files deep |
| Open/closed | Cases that genuinely arrive on a regular basis become new classes instead of new branches | Extension points built for variations that never arrive. An abstract base class with one subclass, added in case there was a second |
| Liskov substitution | Subtypes honour the base contract, so callers need no type checks | Base contracts weakened until they promise nothing meaningful, so the abstraction cannot be relied on at all |
| Interface segregation | A fat data-access interface split along the lines its consumers actually use — readers separate from writers | One interface per method. Wiring a single class needs eight registrations, and finding the implementation of anything takes three jumps |
| Dependency inversion | Slow, external or non-deterministic dependencies behind interfaces, so policy is testable | An interface over every class including pure calculations, doubling the file count and adding a layer of indirection with no seam worth having |
Summary
- Each principle answers a specific pain; knowing the pain tells you when the principle applies
- All five can be overshot, and the overshoot costs you on every read and change
- Introduce abstractions at the second real case rather than the first imagined one
- Single responsibility means one reason to change, not one method
- In review, argue from the change you are making cheaper, not from the name of the principle
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Think about it
Segregation or fragmentation?
An employee module defines IEmployeeReader, IEmployeeWriter, IEmployeeSearcher and IEmployeeArchiver. All four are implemented by one class, EfEmployeeStore, and every consumer takes all four in its constructor.
Is this interface segregation? What would have to be true for it to be?
Show solution
No. Segregation is about what consumers depend on, and here every consumer depends on all of it — with four registrations instead of one and four files instead of one to open when you want to know what the store does.
It would become segregation if consumers genuinely differed. A payroll export that only reads, taking IEmployeeReader, cannot accidentally delete an employee, and its test double needs two methods rather than eleven. That is a real gain, and it is visible in the constructor of the consumer.
The general rule: segregate when you can point at a consumer that wants less. Splitting first and hoping a narrow consumer shows up gives you the cost immediately and the benefit maybe.
There is a defensible middle position worth naming — a read interface and a write interface, because reading and writing are the split that consumers most often actually want.
Challenge
Judge one switch
Find a switch statement or if/else chain on a type or status in code you maintain. Answer three questions about it: how many places make the same decision, how often has a case been added in the last year, and does each case contain logic worth testing on its own?
Decide whether to convert it to separate classes, and write down the reason.
Show solution
There is no single right answer, which is the point of the exercise. The three questions are the ones that decide it.
Convert when the same decision appears in several places, or cases are added regularly, or each case holds enough logic that you want to test it alone. Any one of those can be enough; together they make it clear.
Leave it alone when the decision happens once, the cases have been stable for years, and each branch is a line or two. A switch has one genuine advantage that the class-per-case design gives up: every case is visible at once, in order, on one screen.
If you convert, keep the failure behaviour. A dictionary lookup that returns a default for an unknown case will hide a missing rule; one that throws will tell you about it on the first request.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.