Module 1 · Foundations: Patterns, SOLID and Judgment · Lesson 2 of 20
SOLID in Modern C#: The Five Principles as the Foundation for Every Pattern, With a Real Refactor
Learning outcome
You can state each SOLID principle precisely, spot its violation in real C# code, refactor a "god" service into small collaborators with constructor injection, explain the Liskov substitution trap in IList<T> versus IReadOnlyList<T>, and connect each principle to the patterns that implement it (Strategy and Decorator for Open/Closed, Adapter for Dependency Inversion, and so on).
Intuition
SOLID is not five rules to obey; it is five ways to keep the cost of change low. Each principle answers one question:
- Single Responsibility: how many reasons does this class have to change?
- Open/Closed: can I add a variant without editing working code?
- Liskov Substitution: can every implementation be used wherever its abstraction is expected, without surprises?
- Interface Segregation: does each client depend only on what it uses?
- Dependency Inversion: does the policy depend on abstractions that it owns, rather than on infrastructure details?
Most design patterns are concrete recipes for one or more of these principles. If you understand SOLID, patterns stop being a list to memorise.
flowchart LR
SRP["S: one reason to change"] --> Facade["Facade, Mediator, Command handlers"]
OCP["O: extend without editing"] --> Strat["Strategy, Decorator, Chain"]
LSP["L: substitutable subtypes"] --> Tmpl["Template Method, State"]
ISP["I: small role interfaces"] --> Adp["Adapter, Proxy"]
DIP["D: depend on abstractions"] --> Fac["Factory, DI, Adapter"]Deep dive
- Single Responsibility (SRP). Robert C. Martin's wording is "a module should be responsible to one, and only one, actor". It is about reasons to change, not about "doing one thing". Pricing rules change when marketing asks; storage changes when the DBA asks; notifications change when the product team asks. One class serving three actors gets three kinds of change requests and three kinds of merge conflicts.
- Open/Closed (OCP). "Open for extension, closed for modification." In C# this usually means: the code that varies sits behind an interface, and new behaviour arrives as a new class registered in DI. Do not over-apply it: you cannot make code closed against every change, only against the kinds of change you predict or have already seen.
- Liskov Substitution (LSP). A subtype must honour the contract of its base type: preconditions cannot be strengthened, postconditions cannot be weakened, invariants must hold, and no new exceptions should surprise callers. The BCL has a capability-based contract:
ReadOnlyCollection<T>implementsIList<T>, reportsIsReadOnly, and throws the documentedNotSupportedExceptionfromAdd. This is not a violation of that documented contract; callers cannot assume everyIList<T>is writable. The modern fix is the narrower interfaceIReadOnlyList<T>(added in .NET Framework 4.5). - Interface Segregation (ISP). Clients should not be forced to depend on methods they do not use. Prefer
IOrderReaderandIOrderWriter(or one interface per use case) over a 30-methodIOrderRepository. In the BCL,IReadOnlyCollection<T>,IReadOnlyList<T>andIReadOnlyDictionary<TKey,TValue>are examples of segregated interfaces. - Dependency Inversion (DIP). "High-level modules should not depend on low-level modules; both should depend on abstractions." The important detail interviewers probe: the abstraction is owned by the high-level policy.
IOrderNotifierbelongs to the ordering domain; an SMTP adapter implements it. If the interface mirrors the SMTP library's API, you have only moved the dependency, not inverted it. - DIP is not DI. Dependency Injection is a technique (pass dependencies in, usually via the constructor). Inversion of Control is the broader idea (a framework calls you). Dependency Inversion is a design principle about the direction of source-code dependencies. You can do DI and still violate DIP.
- Modern C# helps:
- primary constructors (C# 12) remove constructor-injection boilerplate;
- records give value semantics for messages and DTOs;
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(.NET 8) keeps guard clauses short;- collection expressions (
[a, b]) keep composition roots readable.
Before: the problem in code
One method validates, prices, stores and notifies. It breaks SRP (three actors), OCP (a new customer tier edits the method) and DIP (it writes to a file and the console directly, so it cannot be tested without side effects).
namespace Before;
// One class that validates, prices, saves and emails. Every new rule edits this file.
public sealed class OrderService
{
public decimal PlaceOrder(string customerType, decimal subtotal, string email)
{
if (subtotal <= 0) throw new ArgumentOutOfRangeException(nameof(subtotal));
decimal discount;
if (customerType == "gold") discount = subtotal * 0.10m;
else if (customerType == "staff") discount = subtotal * 0.30m;
else discount = 0m; // a new tier means editing this method (OCP)
var total = subtotal - discount;
File.AppendAllText("orders.csv", $"{email},{total}\n"); // hard-wired storage (DIP)
Console.WriteLine($"SMTP to {email}: total {total}"); // hard-wired notification (SRP)
return total;
}
}Code example
The refactor gives each reason to change its own type. OrderService keeps only the policy (the order of steps) and depends on three small interfaces it owns.
// SOLID refactor of Before.OrderService: each reason to change gets its own type,
// and the high-level policy depends only on abstractions.
var repo = new InMemoryOrderRepository();
var notifier = new RecordingNotifier();
var service = new OrderService(
[new GoldDiscount(), new StaffDiscount()], // OCP: add a tier by adding a class
repo, notifier);
Console.WriteLine($"{service.PlaceOrder(new Order("a@x.test", "gold", 200m)):0.00}");
Console.WriteLine($"{service.PlaceOrder(new Order("b@x.test", "staff", 200m)):0.00}");
Console.WriteLine($"{service.PlaceOrder(new Order("c@x.test", "regular", 200m)):0.00}");
Console.WriteLine($"saved={repo.Count} notified={notifier.Sent.Count}");
// IList<T> permits read-only implementations; callers must not assume Add is supported.
IList<int> list = new List<int> { 1, 2 }.AsReadOnly();
try { list.Add(3); }
catch (NotSupportedException) { Console.WriteLine("LSP: ReadOnlyCollection<T> as IList<T> throws on Add"); }
IReadOnlyList<int> honest = [1, 2];
Console.WriteLine($"ISP: IReadOnlyList<int> exposes no Add; Count={honest.Count}");
public sealed record Order(string Email, string CustomerType, decimal Subtotal);
// ISP: small, role-specific interfaces.
public interface IDiscountRule { bool AppliesTo(Order o); decimal Discount(Order o); }
public interface IOrderRepository { void Save(Order o, decimal total); }
public interface IOrderNotifier { void OrderPlaced(Order o, decimal total); }
public sealed class GoldDiscount : IDiscountRule
{
public bool AppliesTo(Order o) => o.CustomerType == "gold";
public decimal Discount(Order o) => o.Subtotal * 0.10m;
}
public sealed class StaffDiscount : IDiscountRule
{
public bool AppliesTo(Order o) => o.CustomerType == "staff";
public decimal Discount(Order o) => o.Subtotal * 0.30m;
}
// SRP: OrderService only coordinates; DIP: it receives abstractions.
public sealed class OrderService(IEnumerable<IDiscountRule> rules, IOrderRepository repo, IOrderNotifier notifier)
{
private readonly IReadOnlyList<IDiscountRule> _rules = rules.ToList();
public decimal PlaceOrder(Order order)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(order.Subtotal);
var discount = _rules.FirstOrDefault(r => r.AppliesTo(order))?.Discount(order) ?? 0m;
var total = order.Subtotal - discount;
repo.Save(order, total);
notifier.OrderPlaced(order, total);
return total;
}
}
public sealed class InMemoryOrderRepository : IOrderRepository
{
private readonly List<(Order, decimal)> _rows = [];
public int Count => _rows.Count;
public void Save(Order o, decimal total) => _rows.Add((o, total));
}
public sealed class RecordingNotifier : IOrderNotifier
{
public List<string> Sent { get; } = [];
public void OrderPlaced(Order o, decimal total) => Sent.Add($"{o.Email}:{total}");
}Output (.NET 8, Release)
180.00 140.00 200.00 saved=3 notified=3 LSP: ReadOnlyCollection<T> as IList<T> throws on Add ISP: IReadOnlyList<int> exposes no Add; Count=2
Code walkthrough
GoldDiscounttakes 10% of 200, so the first order totals 180.00;StaffDiscounttakes 30%, giving 140.00; the regular customer matches no rule and pays 200.00.- Adding a "platinum" tier is a new
IDiscountRuleclass plus one line in the composition root.OrderServiceis not edited: that is OCP. InMemoryOrderRepositoryandRecordingNotifierreplace the file and the SMTP call. All 3 orders were saved and notified without touching the disk: that is DIP paying off in tests.- The LSP demo calls
Addthrough anIList<int>that is really aReadOnlyCollection<int>and getsNotSupportedException.IReadOnlyList<int>has noAdd, so the compiler prevents the mistake; itsCountis 2.
Real-world .NET usage
- ASP.NET Core is built on DIP: your controllers depend on
ILogger<T>,IOptions<T>and your own interfaces; the host wires concrete types at startup. IEnumerable<IDiscountRule>injection is the standard way to get "all registered implementations": the same mechanism ASP.NET Core uses forIStartupFilter, health checks and authorization handlers.- Minimal APIs and vertical-slice architectures apply SRP per use case: one handler per request rather than one service per entity.
Trade-offs
| Principle | Over-applied looks like | Healthy middle ground |
|---|---|---|
| SRP | One class per method, impossible to navigate | One class per actor or use case |
| OCP | Plugin architecture for code that never varies | Interfaces where variation is real or already happened twice |
| LSP | Deep inheritance trees with overrides that throw | Prefer composition; narrow interfaces |
| ISP | Dozens of one-method interfaces | Interfaces shaped by what each client calls |
| DIP | An interface for every class, including DTOs | Abstractions at boundaries (I/O, time, external services) |
Failure modes
- Interfaces that mirror the implementation one-to-one (
IOrderServicewith every public method ofOrderService), created only by habit. - "SRP" interpreted as "one method per class", which multiplies types without reducing coupling.
- Overrides that throw
NotImplementedException: an LSP violation that the compiler cannot catch. - Depending on
DateTime.Now,File,HttpClientor static singletons inside business logic, so tests become slow or flaky. InjectTimeProvider(.NET 8) for time. - Leaky abstractions: an
IRepositoryreturningIQueryable<T>exposes the ORM to every caller.
Interview angle
A strong answer to "Explain SOLID" gives one sentence per principle and one concrete C# example, then shows judgment: "I apply OCP where change is predictable, like discount rules; I do not wrap a DTO in an interface." Expect follow-ups:
- "Does an unsupported operation necessarily violate LSP?" → No.
IList<T>permits read-only implementations andStreampermitsCanSeek == false; the documented capability checks and exceptions are part of their contracts. A violation would break a guarantee the abstraction actually makes. - "What is the difference between DIP and DI?" → principle about dependency direction versus a technique for supplying dependencies.
- "Can you over-apply SOLID?" → yes; show the trade-off table above.
Practice questions
- What does "one reason to change" mean in practice?
Answer: One actor or stakeholder group whose requests change the class. If marketing changes discount rules and operations changes storage, those belong in different classes so their changes do not collide.
- How does the Strategy pattern relate to the Open/Closed Principle?
Answer: Strategy puts each variant behind an interface; the context depends on the interface, so a new variant is a new class and the context stays closed to modification. With DI, registration adds the variant.
- Why is
Square : Rectangle(with settable width and height) an LSP violation?
Answer: Callers of Rectangle expect width and height to vary independently (setting width keeps height). A Square must change both, so code that is correct for Rectangle breaks for Square. Model them as separate immutable types or as records without inheritance.
- Who should own the interface in Dependency Inversion?
Answer: The high-level policy (the domain or application layer). The infrastructure implements it through an adapter. That is what makes the dependency arrow point inwards.
- Your team wraps every class in an interface. Is that DIP?
Answer: Not necessarily. DIP is about depending on abstractions at volatile boundaries. Interfaces for stable, dependency-free classes add indirection without benefit; mocks of them also make tests fragile.
- How would you make code that uses
DateTime.UtcNowtestable in .NET 8?
Answer: Inject TimeProvider and call GetUtcNow(). Production registers TimeProvider.System; tests use a fake (for example FakeTimeProvider from Microsoft.Extensions.TimeProvider.Testing) and advance time explicitly.
Revision checklist
- SRP = one actor; OCP = add by new code; LSP = no surprises for callers; ISP = small client-shaped interfaces; DIP = policy owns the abstraction.
- DIP ≠ DI ≠ IoC.
IReadOnlyList<T>overIList<T>when callers must not mutate.- Abstractions at boundaries: I/O, time, external services.
Related on TechInterviewNotes
Analogy
A professional kitchen
The head chef (policy) decides the order of the meal; the pastry station, the grill and the dishwasher (details) each have one job. A new dessert does not require rebuilding the grill (OCP). Any trained pastry chef can cover the station (LSP). The chef asks the grill for "medium-rare", not for a gas pressure value (DIP).
Quick reference
SOLID smells and fixes
- Method with many
if (type == ...)branches → Strategy (OCP). - Class edited by three teams → split by actor (SRP).
- Override throws
NotSupportedException→ narrower interface or composition (LSP, ISP). new SqlConnection(...)in business logic → inject an abstraction the domain owns (DIP).DateTime.UtcNowin logic →TimeProvider.