SOLID & Pragmatic Trade-offs
Key Points
- SOLID is a useful sanity check, not a religion. Senior judgment includes when to deviate.
- SRP (Single Responsibility) — class has one reason to change. Easy to over-apply: don't split classes that genuinely cohere.
- OCP (Open/Closed) — open for extension, closed for modification. Achieved via interfaces + DI; not via inheritance hierarchies.
- LSP (Liskov) — subtypes substitutable for their base. Most classes shouldn't be inherited; favor composition.
- ISP (Interface Segregation) — small focused interfaces. Modern .NET embraces this (
IReadOnlyList<T>vsIList<T>). - DIP (Dependency Inversion) — depend on abstractions. The DI container makes this almost free.
- Other principles to know: YAGNI, DRY (with rule of three), KISS, "rule of three" before reuse, "make it work, make it right, make it fast".
Concepts (deep dive)
SRP — Single Responsibility
"A class should have one reason to change."
// ❌ Mixed responsibilities
public class OrderService
{
public void PlaceOrder(...) { /* domain logic */ }
public string FormatOrderEmail(Order o) { /* presentation */ }
public void SendEmail(string body) { /* infrastructure */ }
}
// ✅ Split by reason to change
public class OrderPlacement { /* domain logic */ }
public class OrderEmailFormatter { /* presentation */ }
public class EmailService { /* infrastructure */ }
Caveat: "single responsibility" is fuzzy. A Customer aggregate has many methods (Activate, Suspend, ChangeAddress, ...) — one responsibility (managing customer state) but many methods. Splitting per method is over-fragmentation.
OCP — Open/Closed
"Open for extension, closed for modification."
// ❌ Closed: every new type requires a switch update
public string Format(Animal a) => a.Type switch
{
"Dog" => "Woof", "Cat" => "Meow",
_ => throw new()
};
// ✅ Open: add a new derived type without changing existing code
public abstract class Animal { public abstract string Speak(); }
public class Dog : Animal { public override string Speak() => "Woof"; }
public class Cat : Animal { public override string Speak() => "Meow"; }
Modern equivalent: strategy pattern via DI. Register implementations; resolve via key or IEnumerable<T>.
Sealed classes are fine — OCP is about extending behavior via interfaces/composition, not inheritance.
LSP — Liskov Substitution
"Subtypes must be substitutable for their base type." Anything that works with Animal should work with Dog.
// ❌ LSP violation
public class Rectangle { public virtual int Width { get; set; } public virtual int Height { get; set; } }
public class Square : Rectangle
{
public override int Width { set { base.Width = base.Height = value; } }
public override int Height { set { base.Width = base.Height = value; } }
}
// A function expecting "set Width to 5; set Height to 3; assert Area == 15" fails for Square
If your base class has invariants subtypes can't honor, the hierarchy is wrong. Often: prefer composition over inheritance.
ISP — Interface Segregation
"Many small interfaces beat one big one."
// ❌ Fat interface
public interface IBigService
{
Order GetOrder(int id);
void SaveOrder(Order o);
void SendEmail(string body);
Task UploadFile(byte[] data);
}
// ✅ Segregated
public interface IOrderRepo { Order Get(int id); void Save(Order o); }
public interface IEmailSender { void SendEmail(string body); }
public interface IFileUploader { Task UploadAsync(byte[] data); }
Small interfaces let consumers depend on only what they use. Mocking, DI, testability all benefit.
DIP — Dependency Inversion
"Depend on abstractions, not concretions."
// ❌ Concrete dependency
public class OrderService
{
private readonly SqlOrderRepo _repo = new(); // tightly coupled
}
// ✅ Inverted
public class OrderService(IOrderRepo repo) { /* ... */ }
The DI container does this for you. Modern .NET makes DIP almost free.
YAGNI — You Aren't Gonna Need It
Don't build for hypothetical futures. The cost: extra abstraction now, that may never pay off.
// ❌ "Maybe we'll need to swap providers"
public interface ILoggingProvider { ... }
public interface ILoggingProviderFactory { ILoggingProvider Create(...); }
// 3 levels of abstraction for one Serilog logger
// ✅ Use ILogger<T> directly
public class C(ILogger<C> log) { /* ... */ }
Prove the need (rule of three or actual switching) before extracting an abstraction.
DRY — Don't Repeat Yourself
"Once and only once" is overstated. Two slightly-similar pieces of code often should duplicate; forcing them into a shared abstraction creates harmful coupling.
Rule of three: see the same logic in three places → extract. Two: tolerate.
KISS — Keep It Simple
"Simple" means: easy to read, easy to change, easy to delete. Cleverness fails this test.
When to violate SOLID
- Performance hot paths — sometimes a single fat class beats five small ones for cache locality.
- Tiny apps / prototypes — abstraction tax exceeds the benefit.
- Library boundaries — public APIs trade flexibility for stability.
The senior view: SOLID is a vocabulary. Use it as a checklist when reviewing; don't lock the team into religious adherence.
Pragmatic checklist
When designing a class:
- Why does this class exist? (SRP)
- What changes are likely? Are they easy? (OCP)
- Does each method do one thing?
- Are dependencies injected? (DIP)
- Are the interfaces small enough to mock? (ISP)
- Is the inheritance (if any) sensible? (LSP)
- Could a junior dev understand and modify this? (KISS)
- Am I building for actual or imagined needs? (YAGNI)
Code: correct vs wrong
❌ Wrong: God class
public class OrderService
{
public void Place(...) { /* DB; email; logging; metrics */ }
public void Cancel(...) { /* same */ }
public Report GetReport(...) { /* same */ }
}
✅ Correct: split
Split into OrderPlacement, OrderCancellation, OrderReporting — each focused.
❌ Wrong: speculative interface
✅ Correct: only when need exists
When a real second implementation appears, extract.
❌ Wrong: violating LSP
public class Rectangle { /* has Width, Height with invariant: independent */ }
public class Square : Rectangle { /* breaks invariant */ }
✅ Correct: composition
Design patterns for this topic
Pattern 1 — "DI everywhere — DIP for free"
- Intent: depend on abstractions.
Pattern 2 — "Small focused interfaces"
- Intent: ISP; testability.
Pattern 3 — "Composition over inheritance"
- Intent: flexibility; avoid LSP traps.
Pattern 4 — "Rule of three before DRY-extracting"
- Intent: avoid premature abstraction.
Pattern 5 — "Sealed by default"
- Intent: explicit extension points; not accidental.
Pros & cons / trade-offs
| Principle | Pros | Cons |
|---|---|---|
| SRP | Cohesive classes | Easy to over-fragment |
| OCP | Extensibility | Often unnecessary |
| LSP | Sound hierarchies | Few hierarchies are needed |
| ISP | Small interfaces | More types |
| DIP | Testable | Adds layers |
| YAGNI | No waste | Hard to know what "you'll need" |
| DRY | No duplication | Premature abstraction worse |
When to use / when to avoid
- Use SOLID as a vocabulary for design discussions and code reviews.
- Use DI by default for non-trivial services.
- Avoid speculative abstractions. Prove need before extracting.
- Avoid religious application — SOLID violations sometimes are correct.
Interview Q&A
Q1. What's SRP? Single Responsibility: a class should have one reason to change. Easy to over-apply.
Q2. What's the difference between OCP and DIP? OCP: extend without modifying. DIP: depend on abstractions. Often achieved together via DI.
Q3. Liskov violation example? Square : Rectangle, where Square breaks Rectangle's "Width and Height are independent" invariant.
Q4. Why prefer composition over inheritance? Inheritance creates rigid hierarchies that often violate LSP. Composition is more flexible.
Q5. When is DRY harmful? When you force two similar but conceptually distinct code paths into the same abstraction, coupling them. Future changes to one ripple to the other.
Q6. What's YAGNI? "You Aren't Gonna Need It." Don't build features (or abstractions) for hypothetical futures.
Q7. What's the rule of three? See the same logic in three places before extracting. Two: tolerate the duplication.
Q8. Should every class be sealed? Reasonable default. Forces explicit extension; avoids accidental subclassing.
Q9. Are interfaces always better than abstract classes? No. Abstract class can carry implementation. Interface is purely a contract. Use whichever fits — both have their place.
Q10. When should you violate SOLID? When the abstraction tax exceeds the benefit (small apps, hot paths, public APIs that need stability).
Gotchas / common mistakes
- ⚠️ Religious SOLID — fragmenting code beyond comprehension.
- ⚠️ Premature DRY — coupling unrelated logic.
- ⚠️ Inheritance for code reuse — composition is usually better.
- ⚠️ Service locator pattern disguised as DIP.
- ⚠️ God interface — many methods on one interface.