SOLID Principles in .NET: What They Mean and Why They Matter
SOLID is five principles for writing object-oriented code that's easy to extend without breaking existing behavior. They were named by Robert Martin and have become the foundational vocabulary of software design discussions. Every developer claims to know them, but in practice most codebases violate them quietly: giant classes that do everything, switch statements on type tags, interfaces with methods the implementer doesn't need, high-level modules that reach down and import low-level ones directly.
This article goes beyond the acronym. Each principle gets a real before-and-after in C# — not an abstract Animal/Dog example, but patterns you'd actually encounter in a backend API. Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion — what they mean, how they're violated, and what the code looks like when you fix them.
Single Responsibility Principle
A class should have one reason to change. Not one method, not one field — one reason. If two different stakeholders could request changes to the same class for different reasons, it has too many responsibilities. An OrderService that handles order placement, email notifications, invoice generation, and inventory updates changes when the business team changes order logic, when the email team changes templates, and when the finance team changes invoice formats. Three teams, three reasons, one class — a recipe for merge conflicts and hidden bugs.
The fix is not to create a hundred tiny classes for every line of code. It's to group behavior by who has reason to change it. Order placement logic changes with order business rules. Email logic changes with communication strategy. Invoice logic changes with finance requirements. Each belongs in its own service.
Quick reference
- Ask: 'What would make me change this class?' Multiple different answers = multiple responsibilities.
- A class with more than ~200 lines is a warning sign — not a rule, but worth checking.
- SRP at the module level too: a namespace/folder should have one reason to exist.
- Domain events (OrderPlacedEvent) are a great way to separate the core action from side effects.
- Services that 'just orchestrate' are fine — the responsibility is orchestration, not implementation.
Remember this
Group behavior by who has reason to change it, not by what data it touches. Use domain events to separate core business actions from notification and reporting side effects.
Open/Closed Principle
Software entities should be open for extension but closed for modification. When you add new behavior, you should not need to edit existing, tested code. The classic violation is a switch statement on a type tag that grows every time a new type is added — each addition risks breaking every existing case.
The fix is polymorphism: define an abstraction, implement it for each variant, and dispatch based on type through the type system, not a switch. New variants extend the system without touching existing implementations.
Quick reference
- OCP is usually achieved through the Strategy pattern (swappable algorithms) or Template Method (fixed skeleton, swappable steps).
- In .NET: interfaces, abstract classes, and DI container registration enable OCP at the service level.
- Use OCP when you can predict the dimension of change. Not every class needs to be open — premature abstraction is a real cost.
- The Decorator pattern extends behavior without modifying the original: wrap IEmailSender to add logging, retry, rate limiting.
- Configuration-driven extension: adding a new discount via config (not code) is a form of OCP.
Remember this
Replace switch-on-type with polymorphism. Define an interface for the variation point, implement a class per variant, dispatch through the type system.
Liskov Substitution Principle
If S is a subtype of T, objects of type S must be usable wherever objects of type T are expected, without changing the correctness of the program. In plain English: subtypes must keep the promises of their base type. If you override a method and throw an exception the base class never threw, or silently skip doing what the base class promised, you've violated LSP.
The violation shows up as 'if this is a FooBar, do something different' type checks on instances — code that should be polymorphic has devolved into explicit type inspection because the subtype broke the abstraction.
Quick reference
- LSP is often violated by throwing NotSupportedException in overrides — a code smell.
- Check your overrides: do they strengthen preconditions (require more than base)? That's a violation.
- Do they weaken postconditions (promise less than base)? That's a violation.
- Symptom: instanceof / is checks in polymorphic code. The caller shouldn't need to know the concrete type.
- Interface segregation (next principle) often solves LSP violations by splitting over-broad contracts.
Remember this
Subtypes must honor the contracts of their base type. If a subtype can't fulfill a method, the interface is too broad — split it.
Interface Segregation Principle
Clients should not be forced to depend on interfaces they don't use. A fat interface — one with many methods — forces every implementer to implement all of them, even the ones irrelevant to their use case. Implementers either throw NotImplementedException (an LSP violation) or leave them as empty stubs.
The fix is narrow, focused interfaces. Each client depends only on the methods it actually calls. A query handler doesn't need write methods. An audit logger doesn't need read methods. Split by what clients actually need, not by what the domain object supports.
Quick reference
- A good interface is small enough that every implementer implements every method without stubs.
- ISP complements SRP: SRP keeps classes focused, ISP keeps interfaces focused.
- In .NET with DI, narrow interfaces make testing trivial — mock only the methods the class under test calls.
- Role interfaces: define interfaces based on the role a class plays for its client, not on what it is.
- Marker interfaces (IDisposable, ICloneable) are legitimate — they express a capability, not a method set.
Remember this
Define interfaces from the caller's perspective. If an implementer has to throw NotImplementedException, the interface is too fat — split it.
Dependency Inversion Principle
High-level modules should not depend on low-level modules. Both should depend on abstractions. This is the principle behind dependency injection — instead of a business service creating its own database connection or HTTP client, those dependencies are injected via interfaces. The business service depends on an interface (abstraction) that the database or HTTP client implements (detail).
DIP doesn't just mean 'use interfaces.' The key word is inversion: the high-level module (OrderService) owns and defines the interface (IOrderRepository). The low-level module (EfOrderRepository) implements it. The dependency arrow points from the implementation toward the business layer, not the other way around.
Quick reference
- DIP is what makes dependency injection meaningful — DI without interfaces is just passing objects around.
- The interface belongs to the high-level module, not the low-level one. This is the 'inversion' in the name.
- AddScoped: one instance per HTTP request. AddTransient: new instance every time. AddSingleton: one for the app lifetime.
- Constructor injection is the standard in .NET — inject via constructor, not property or method.
- Test doubles: use FakeXxx for in-memory implementations in unit tests. Use Mock<IXxx> for verifying interactions.
- DIP enables the Clean Architecture layers: Application defines interfaces, Infrastructure implements them.
Remember this
High-level business modules define interfaces; low-level infrastructure implements them. Inject via constructor. This is what makes your business logic testable without a database.
SOLID principles are not rules to follow mechanically — they're heuristics for recognizing when code is heading toward a bad place. A class with five reasons to change is heading toward a maintenance nightmare. A switch statement that grows with every new business type is heading toward a fragile, coupled system. An interface that forces implementers to throw NotImplementedException is heading toward subtle runtime bugs.
In practice: Single Responsibility keeps classes focused. Open/Closed makes extension safe. Liskov keeps polymorphism trustworthy. Interface Segregation keeps contracts honest. Dependency Inversion keeps business logic testable. Apply them when you feel pain — when adding a feature requires editing unrelated code, when tests require a real database, when a switch statement is growing. That pain is the principles telling you something needs to change.
Related Articles
Explore this topic