SOLID Principles
1. What is SOLID?
SOLID is a set of five object-oriented design principles used to create software that is:
- Easy to understand
- Easy to maintain
- Easy to extend
- Easy to test
- Less tightly coupled
- More flexible when requirements change
SOLID is especially important in large applications such as ASP.NET Core Web API, MVC, enterprise applications, Clean Architecture, and microservices.
SOLID stands for:
| Letter | Principle | Main idea |
|---|---|---|
| S | Single Responsibility Principle | One class → one responsibility |
| O | Open/Closed Principle | Extend behavior without modifying existing code |
| L | Liskov Substitution Principle | Child types must safely replace parent types |
| I | Interface Segregation Principle | Prefer small, focused interfaces |
| D | Dependency Inversion Principle | Depend on abstractions, not concrete classes |
Why do we need SOLID?
Consider a large application with hundreds of classes.
Without good design principles, you can easily end up with:
Small requirement change
↓
Modify one class
↓
Another feature breaks
↓
Modify several dependent classes
↓
More testing
↓
More bugs
SOLID tries to reduce this problem.
The basic goal is:
A change in one part of the application should have minimum impact on unrelated parts.
SOLID doesn't mean creating an interface and separate class for everything. It provides design guidelines for deciding how responsibilities and dependencies should be structured.
S — Single Responsibility Principle (SRP)
What is SRP?
Single Responsibility Principle says:
A class should have only one reason to change.
A simpler way to remember it:
One class should focus on one responsibility.
This does not literally mean that a class can contain only one method.
A class may have many methods as long as those methods belong to the same responsibility.
Why do we need SRP?
Suppose we have:
OrderService
and it is responsible for:
OrderService
├── Validate Order
├── Calculate Total
├── Save Order to Database
├── Send Email
├── Generate PDF
└── Write Log
This class has too many reasons to change.
For example:
- Database changes → modify
OrderService - Email provider changes → modify
OrderService - PDF format changes → modify
OrderService - Business calculation changes → modify
OrderService
That makes the class harder to maintain.
SRP suggests separating these responsibilities:
OrderService
→ Order processing
OrderRepository
→ Database operations
EmailService
→ Email operations
InvoiceService
→ Invoice/PDF operations
LoggingService
→ Logging
Now each component has a more focused responsibility.
Important clarification
SRP is often described as:
"A class should do only one thing."
That is useful for remembering the principle, but the more accurate definition is:
A class should have one reason to change.
For example:
EmployeeService
├── CreateEmployee()
├── UpdateEmployee()
├── DeleteEmployee()
└── GetEmployee()
This does not automatically violate SRP.
All these operations can belong to the same responsibility:
Employee management
Advantages of SRP
- Smaller and cleaner classes
- Easier maintenance
- Easier unit testing
- Easier debugging
- Changes are more isolated
- Less chance of breaking unrelated functionality
- Better separation of concerns
SRP Key Point
One class → one responsibility → one primary reason to change.
O — Open/Closed Principle (OCP)
What is OCP?
Open/Closed Principle says:
Software entities should be open for extension but closed for modification.
At first, this sounds contradictory.
It simply means:
Open for extension
↓
New behavior can be added
Closed for modification
↓
Existing stable code should not need repeated changes
Why do we need OCP?
Imagine a notification system:
NotificationService
├── Email
└── SMS
Later the customer asks:
Add WhatsApp
You modify the existing service.
Later:
Add Push Notification
Again modify it.
Later:
Add Teams Notification
Again modify it.
The problem is that every new requirement forces us to modify already working code.
Every modification creates a risk of introducing regression bugs.
OCP encourages a design where new behavior can be introduced by adding new implementations, rather than repeatedly changing existing business logic.
Conceptually:
INotification
↑
┌───────┼────────┐
│ │ │
Email SMS WhatsApp
Later:
INotification
↑
┌───────┼──────────┬────────┐
│ │ │ │
Email SMS WhatsApp Push
The new capability is introduced primarily through extension.
How is OCP normally achieved?
Common techniques include:
- Interfaces
- Abstract classes
- Polymorphism
- Composition
- Dependency Injection
- Strategy Pattern
- Factory Pattern
- Decorator Pattern
But simply using an interface does not automatically mean the code follows OCP. The overall design must allow new behavior without requiring repeated modifications to stable code.
Does "closed for modification" mean never modify existing code?
No.
Existing code will obviously need modification for:
- Bug fixes
- Refactoring
- Requirement changes
- Security fixes
OCP is about designing expected variations so that new variants don't require repeatedly rewriting stable logic.
Advantages of OCP
- Easier feature expansion
- Lower regression risk
- Better extensibility
- Existing tested code remains more stable
- Supports plug-in style architecture
- Works well with polymorphism and DI
OCP Key Point
Add new behavior primarily by extending the design rather than repeatedly changing stable code.
L — Liskov Substitution Principle (LSP)
What is LSP?
Liskov Substitution Principle says:
An object of a derived type should be usable wherever its base type is expected without breaking the expected behavior.
Simplified:
A child class must behave correctly when used as its parent type.
This principle is mainly concerned with inheritance and substitutability.
Simple Concept
Suppose:
Vehicle
↑
|
Car
If the system expects a Vehicle, passing a Car should work correctly.
Vehicle expected
↑
|
Car
✓
The application shouldn't suddenly fail merely because a derived implementation was substituted.
Why do we need LSP?
Inheritance represents an is-a relationship.
For example:
Car is a Vehicle
But inheritance isn't correct merely because two objects appear related.
Suppose we design:
Bird
└── Fly()
Then:
Bird
↑
├── Sparrow
└── Penguin
The problem is:
Sparrow.Fly() ✓
Penguin.Fly() ✗
A penguin is biologically a bird, but it cannot satisfy the behavioral contract implied by a base Bird that requires every bird to fly.
If Penguin.Fly() throws something such as:
NotSupportedException
then code that expects every Bird to support Fly() can fail.
The abstraction itself is poorly designed.
A better conceptual model could separate capabilities:
Bird
↑
├── Sparrow
└── Penguin
IFlyingBird
↑
└── Sparrow
Now only objects that can actually provide flying behavior implement that capability.
LSP is about behavior, not just compilation
This is important.
The following relationship may compile:
Parent
↑
Child
But that doesn't prove LSP is satisfied.
The child must respect the behavioral expectations of the parent.
For example, it should not unexpectedly:
- Reject valid operations supported by the parent contract
- Produce incompatible results
- Throw unsupported-operation exceptions for required behavior
- Introduce stronger requirements that callers didn't need before
- Violate guarantees made by the abstraction
Advantages of LSP
- Safer inheritance
- Reliable polymorphism
- Fewer runtime surprises
- Better abstractions
- Derived classes behave consistently
- Easier testing
- Encourages proper inheritance hierarchies
LSP Key Point
If B is a subtype of A, B should safely work wherever A is expected.
I — Interface Segregation Principle (ISP)
What is ISP?
Interface Segregation Principle says:
Clients should not be forced to depend on methods they do not use.
Simplified:
Prefer small, focused interfaces instead of one large interface containing unrelated operations.
Why do we need ISP?
Imagine:
IEmployee
├── Work()
├── CalculateSalary()
├── ApproveLeave()
├── GenerateReport()
├── ManageEmployees()
└── ConductInterview()
Now suppose we have:
Developer
The developer may need:
Work()
CalculateSalary()
but not:
ApproveLeave()
ManageEmployees()
ConductInterview()
If the class is forced to implement all of them, the interface is too broad.
This is sometimes called a fat interface.
ISP suggests splitting it into focused interfaces:
IWorker
└── Work()
IPayable
└── CalculateSalary()
ILeaveApprover
└── ApproveLeave()
IReportGenerator
└── GenerateReport()
IRecruiter
└── ConductInterview()
A class implements only the capabilities it actually needs.
Another way to understand ISP
Without ISP
IEmployee
|
┌────────────┼────────────┐
↓ ↓ ↓
Developer Manager HR
Everyone is forced to depend on
all interface members.
With ISP
Developer
↓
IWorker
Manager
↓
IWorker
ILeaveApprover
HR
↓
IRecruiter
Dependencies become more precise.
Why are large interfaces problematic?
Suppose:
IService
contains 20 methods.
A particular class requires only two of them.
It is still coupled to the entire interface contract.
Changes to unrelated parts of that interface can affect implementations and consumers unnecessarily.
ISP reduces this unnecessary coupling.
Advantages of ISP
- Smaller interfaces
- Less unnecessary dependency
- Cleaner implementations
- Better maintainability
- Easier testing and mocking
- Classes implement only relevant capabilities
- Reduces empty/dummy implementations
- Encourages cohesive contracts
ISP Key Point
Don't force a class or client to depend on interface members it doesn't need.
D — Dependency Inversion Principle (DIP)
What is DIP?
Dependency Inversion Principle says:
High-level modules should not depend directly on low-level modules. Both should depend on abstractions.
Also:
Abstractions should not depend on implementation details; implementation details should depend on abstractions.
In simple terms:
Depend on interfaces/abstractions rather than directly coupling business logic to concrete implementations.
Why do we need DIP?
Imagine:
OrderService
↓
SqlOrderRepository
OrderService directly depends on a particular database implementation.
Later you want:
MongoOrderRepository
or:
ApiOrderRepository
The business service may need changes because it is tightly coupled to SqlOrderRepository.
Instead:
OrderService
↓
IOrderRepository
↑
|
SqlOrderRepository
Now the high-level business logic knows the abstraction:
IOrderRepository
not the implementation detail:
SqlOrderRepository
Later:
IOrderRepository
↑
┌────────┼─────────┐
│ │ │
SQL Mongo API
The business layer remains much less coupled to the storage technology.
High-Level vs Low-Level Modules
This terminology is important for interviews.
High-level module
Contains important application/business decisions.
Examples:
OrderService
PaymentService
EmployeeService
CheckoutService
Low-level module
Handles technical implementation details.
Examples:
SQL database
Email sender
File storage
Logging provider
External API client
DIP encourages:
High-Level
↓
Abstraction
↑
Low-Level
instead of:
High-Level
↓
Low-Level
DIP vs Dependency Injection
These two are related but not the same thing.
| DIP | Dependency Injection |
|---|---|
| Design principle | Implementation technique |
| Says how dependencies should be designed | Says how dependencies can be supplied |
| Encourages depending on abstractions | DI container can create and inject dependencies |
| Part of SOLID | Built into ASP.NET Core |
For example, conceptually:
DIP
↓
OrderService depends on IOrderRepository
DI
↓
ASP.NET Core injects SqlOrderRepository
as the implementation of IOrderRepository
So Dependency Injection helps implement Dependency Inversion, but using DI alone does not automatically mean a design follows DIP.
Advantages of DIP
- Loose coupling
- Easier unit testing
- Easier replacement of implementations
- Better maintainability
- Better extensibility
- Supports Dependency Injection
- Works well with Clean Architecture
- Business logic is less dependent on infrastructure details
DIP Key Point
Business logic should depend on abstractions rather than concrete infrastructure implementations.
How All Five Principles Work Together
Consider an order-processing application.
Order System
│
┌─────────────────┼─────────────────┐
↓ ↓ ↓
Order Service Payment Service Notification
│ │ │
↓ ↓ ↓
Repository Payment Gateway Email/SMS
SOLID helps organize it:
| Principle | Question to ask |
|---|---|
| SRP | Does this class have too many responsibilities/reasons to change? |
| OCP | Can I add a new variant without repeatedly changing stable code? |
| LSP | Can implementations/subtypes safely substitute for the abstraction? |
| ISP | Is this interface forcing consumers to depend on unnecessary members? |
| DIP | Is high-level business logic tightly coupled to implementation details? |
Easy Way to Remember SOLID
S → Separate responsibilities
O → Open to extension
L → Legitimate substitution
I → Interfaces should be focused
D → Depend on abstractions
Or think about it this way:
SOLID
│
├── S → One responsibility
│
├── O → Extend without repeatedly changing stable code
│
├── L → Child safely replaces parent
│
├── I → Small focused interfaces
│
└── D → Depend on abstractions
Advantages of SOLID Overall
1. Maintainability
Changes are more localized and easier to understand.
2. Extensibility
New functionality can often be introduced with fewer changes to existing code.
3. Testability
Smaller responsibilities and abstraction-based dependencies make unit testing easier.
4. Loose Coupling
Classes know less about concrete implementation details.
5. Reusability
Focused components can often be reused more easily.
6. Reduced Regression Risk
Changes are less likely to unexpectedly affect unrelated functionality.
7. Better Team Development
Clear boundaries make large codebases easier for multiple developers to work on.
8. Supports Modern Architecture
SOLID concepts align well with architectures and techniques such as:
- Clean Architecture
- Layered Architecture
- Hexagonal Architecture
- Dependency Injection
- Repository pattern
- Strategy pattern
- Factory pattern
- Microservices
Important Interview Key Points
- SOLID is a set of five object-oriented design principles.
- SOLID is primarily about maintainability, extensibility, testability, and managing coupling.
- SRP: A class should have one primary reason to change.
- OCP: Design software so expected new variants can be added mainly through extension rather than repeated modification.
- LSP: A subtype should be able to replace its base type without violating the expected contract.
- ISP: Don't force clients to depend on interface members they don't need.
- DIP: High-level business logic should depend on abstractions rather than concrete implementation details.
- DIP and Dependency Injection are not the same. DI is one technique that can help implement DIP.
- SOLID principles are guidelines, not rigid rules. Over-applying them can create unnecessary interfaces, classes, and complexity.
- In ASP.NET Core, interfaces + Dependency Injection + focused services are common mechanisms for applying several SOLID principles.
One-line revision
S — One responsibility
O — Extend without repeatedly modifying stable code
L — Subtypes must be safely substitutable
I — Keep interfaces focused
D — Depend on abstractions, not implementation details.