SOLID Without the Dogma: When to Break Each Principle
SOLID is a heuristic, not a checklist. Real-world examples of when each principle helps — and the specific cases where applying it hurts.
- Author
- Randhir Jassal
- Published
- Reading time
- 9 min read
S — Single Responsibility
The textbook line "a class should have one reason to change" is too abstract. The useful version: two different stakeholders should not change the same class.
// Bad: report layout + billing rules in one class
public class InvoiceReport {
public string Render() { /* layout */ }
public decimal CalculateTax() { /* billing rule */ }
}
Different teams (design, finance) edit this file. Conflicts. Split:
public class InvoiceCalculator { public decimal CalculateTax(); }
public class InvoiceRenderer { public string Render(InvoiceCalculation calc); }
When NOT to split: simple value objects with both data and 1-2 formatters. Splitting adds two files for no gain.
O — Open/Closed
Open for extension, closed for modification. In practice this means avoid switch-on-type in domain logic.
// Closed against new shapes — every new shape edits this switch:
public decimal Area(Shape s) => s switch {
Circle c => Math.PI * c.R * c.R,
Rectangle r => r.W * r.H,
_ => throw new()
};
Versus polymorphism:
public abstract record Shape { public abstract decimal Area(); }
public record Circle(decimal R) : Shape { public override decimal Area() => /*...*/; }
public record Rectangle(decimal W, decimal H) : Shape { public override decimal Area() => W*H; }
When to break it: when adding a new shape requires touching 12 unrelated files, the closure has cost. C# pattern matching with _ => throw and an exhaustiveness analyser is fine.
L — Liskov Substitution
A subtype must be substitutable for its base type without breaking callers. The famous violation: Square extends Rectangle overriding SetWidth to also set height.
The signal: a derived class throws NotSupportedException from a base method. That's a Liskov violation in disguise.
When you'll see it in real code: ORM lazy-load proxies. Don't fight it; just be aware that your MyEntity instance might actually be Castle.Proxies.MyEntityProxy.
I — Interface Segregation
A client shouldn't depend on methods it doesn't use. The classic violation:
public interface IRepository {
Task<T> Get(Guid id);
Task<List<T>> List();
Task Add(T item);
Task Update(T item);
Task Delete(Guid id);
}
A read-only service gets Add/Update/Delete it shouldn't be able to call. Split:
public interface IReader<T> { Task<T> Get(Guid id); Task<List<T>> List(); }
public interface IWriter<T> { Task Add(T item); Task Update(T item); Task Delete(Guid id); }
When to break it: in tests where you want one mock per repository, the all-in-one interface is simpler.
D — Dependency Inversion
High-level modules shouldn't depend on low-level modules; both should depend on abstractions. In DI-container land this is automatic: register IEmailGateway, inject everywhere.
The mistake people make: creating an interface for everything. StringBuilder doesn't need an interface. A class is just a class — an abstraction is one only when you want to vary the implementation.
Rule of thumb: an interface earns its existence when there's a real second implementation, or a test double that's not trivially mockable.
The summary
SOLID is a vocabulary for talking about design pain. When you feel the pain — diamond inheritance, switch-on-type, an interface no one implements twice — reach for the matching letter. Until then, keep the code simple.
Get the next issue
A short, curated email with the newest posts and questions.