📑 Contents
- What Does SOLID Mean?
- SOLID Is Not the Same as Design Patterns
- 1. Single Responsibility Principle — SRP
- 2. Open/Closed Principle — OCP
- 3. Liskov Substitution Principle — LSP
- 4. Interface Segregation Principle — ISP
- 5. Dependency Inversion Principle — DIP
- Dependency Inversion vs Dependency Injection
- SOLID and Design Patterns — The Practical Connection
- SOLID Does Not Mean "More Interfaces"
- SOLID Does Not Mean "Create More Classes"
- SOLID Does Not Mean "Remove Every if/else"
- SOLID Does Not Mean "Use Every Design Pattern"
- A Real-World Example: Notification System
- How to Identify SOLID Issues During Code Review
- A Practical SOLID Refactoring Workflow
- SOLID in a Typical .NET Application
- SOLID vs Design Patterns
- When Should You Not Apply SOLID Aggressively?
- A Simple SOLID Mental Model
- SOLID Design Pattern Quick Reference
-
Common SOLID Misconceptions
- Misconception 1: "SOLID means more interfaces."
- Misconception 2: "SOLID means every class should have one method."
- Misconception 3: "SOLID means no if/else statements."
- Misconception 4: "DIP and Dependency Injection are the same."
- Misconception 5: "Every SOLID problem requires a design pattern."
- Misconception 6: "Inheritance is always better than composition."
- Misconception 7: "Following SOLID guarantees good architecture."
- The Most Important Lesson
- Final Takeaways
- Practical SOLID Checklist
SOLID is one of the most discussed topics in object-oriented programming.
Most developers have heard of the five principles:
- S — Single Responsibility Principle
- O — Open/Closed Principle
- L — Liskov Substitution Principle
- I — Interface Segregation Principle
- D — Dependency Inversion Principle
But knowing what the letters stand for is very different from knowing how to apply them.
A developer may remember that SRP means "a class should have one reason to change" but still struggle to identify an SRP violation in a real application.
Similarly, developers often learn design patterns such as Strategy, Factory, Adapter, Decorator, and Repository, but may not understand how these patterns relate to SOLID.
This article takes a practical approach.
Instead of simply memorizing definitions, we will look at:
- The problem each SOLID principle tries to solve
- How that problem appears in real applications
- How to recognize the problem
- How to refactor the design
- Which design patterns or techniques can help
- Common misconceptions
- How the principles work together
The examples use C# and .NET, but the underlying concepts apply to object-oriented software in general.
What Does SOLID Mean?
SOLID is an acronym for five object-oriented design principles:
| Principle | Meaning | Practical Question |
|---|---|---|
| S | Single Responsibility Principle | Does this class have more than one reason to change? |
| O | Open/Closed Principle | Can I add new behavior without repeatedly modifying stable code? |
| L | Liskov Substitution Principle | Can a derived type safely replace its base type? |
| I | Interface Segregation Principle | Are implementations forced to depend on methods they do not need? |
| D | Dependency Inversion Principle | Does my business logic depend directly on implementation details? |
A simple way to remember them:
S → Keep responsibilities focused
O → Make changing behavior extensible
L → Make inheritance behaviorally trustworthy
I → Keep interfaces focused
D → Depend on abstractions
SOLID should not be treated as five rules that must be applied mechanically.
The goal is not to create more interfaces, classes, or abstractions.
The goal is to create software that is easier to:
- Understand
- Change
- Test
- Extend
- Maintain
SOLID Is Not the Same as Design Patterns
This distinction is important.
SOLID principles are design principles.
Design patterns are reusable approaches to recurring software design problems.
For example:
Design Problem
↓
Identify the underlying design principle
↓
Consider possible design approaches
↓
Choose a suitable pattern or technique
↓
Keep the solution as simple as possible
A design pattern does not automatically make code SOLID.
Likewise, SOLID does not require the use of design patterns.
Use SOLID to guide your design decisions. Use a design pattern when it provides a practical solution to the problem you actually have.
Also, not every pattern discussed in this article is a Gang of Four (GoF) design pattern. For example, Repository and Dependency Injection are commonly used design or architectural techniques in modern enterprise applications, but they are not GoF patterns.
1. Single Responsibility Principle — SRP
The Simple Explanation
The Single Responsibility Principle states:
A class should have one reason to change.
This is often misunderstood as:
"A class should contain only one method."
That is not what SRP means.
A class can contain multiple methods and still have a single responsibility.
The better question is:
Do the responsibilities in this class change for different reasons?
A Common Problem
Imagine an e-commerce application with this class:
public class OrderService
{
public void CreateOrder(Order order)
{
// Save order to database
}
public void ProcessPayment(Order order)
{
// Process payment
}
public void SendConfirmationEmail(Order order)
{
// Send email
}
public byte[] GenerateInvoice(Order order)
{
// Generate PDF invoice
return [];
}
}
At first glance, this may look convenient.
Everything related to an order is in one place.
But this class actually contains several responsibilities:
OrderService
|
+-- Order persistence
|
+-- Payment processing
|
+-- Email communication
|
+-- Invoice generation
Now consider the following changes:
- The database technology changes.
- The payment provider changes.
- The email provider changes.
- The invoice format changes.
These changes are driven by different concerns.
If all of them require modifying OrderService, the class has multiple reasons to change.
That is a warning sign for SRP.
Refactoring the Design
We can separate the responsibilities:
public interface IOrderRepository
{
Task SaveAsync(Order order);
}
public interface IPaymentService
{
Task ProcessAsync(Order order);
}
public interface INotificationService
{
Task SendOrderConfirmationAsync(Order order);
}
public interface IInvoiceService
{
Task<byte[]> GenerateAsync(Order order);
}
The application service can then coordinate the workflow:
public class OrderService
{
private readonly IOrderRepository _orderRepository;
private readonly IPaymentService _paymentService;
private readonly INotificationService _notificationService;
public OrderService(
IOrderRepository orderRepository,
IPaymentService paymentService,
INotificationService notificationService)
{
_orderRepository = orderRepository;
_paymentService = paymentService;
_notificationService = notificationService;
}
public async Task CreateOrderAsync(Order order)
{
await _orderRepository.SaveAsync(order);
await _paymentService.ProcessAsync(order);
await _notificationService.SendOrderConfirmationAsync(order);
}
}
Now the responsibilities are clearer:
OrderService
|
+── IOrderRepository
+── IPaymentService
+── INotificationService
OrderService coordinates the workflow instead of implementing every technical detail itself.
Design Patterns and Techniques That Can Support SRP
SRP does not require a particular design pattern.
However, several patterns or techniques can help separate responsibilities:
| Pattern / Technique | How it can help |
|---|---|
| Facade | Provides a simplified interface over multiple components |
| Strategy | Separates interchangeable business algorithms |
| Command | Encapsulates an operation |
| Decorator | Separates cross-cutting behavior |
| Mediator | Separates request handling responsibilities |
| Repository | Separates persistence concerns from business logic |
These are not "SRP patterns."
They are approaches that can help create a design with better separation of responsibilities.
Common SRP Mistake
A common misunderstanding is:
"Every class must do only one thing."
That can lead to unnecessary fragmentation.
For example, creating a separate class for every small method may produce a system with too many abstractions and dependencies.
Instead, ask:
Do these responsibilities change independently?
If they do, separating them may make the design easier to maintain.
If they do not, keeping them together may be perfectly reasonable.
2. Open/Closed Principle — OCP
The Simple Explanation
The Open/Closed Principle states:
Software entities should be open for extension but closed for modification.
A practical interpretation is:
Design areas of expected variation so that new behavior can be added without repeatedly changing stable, already-tested code.
This does not mean existing code can never be modified.
Requirements change, bugs need fixing, and designs evolve.
The principle is about avoiding unnecessary modification of stable code every time a new variation is introduced.
A Common Problem
Consider payment processing:
public class PaymentService
{
public void Process(string paymentType, decimal amount)
{
if (paymentType == "CreditCard")
{
// Credit card payment
}
else if (paymentType == "PayPal")
{
// PayPal payment
}
else if (paymentType == "UPI")
{
// UPI payment
}
}
}
Today we support:
- Credit Card
- PayPal
- UPI
Tomorrow we might add:
- Bank Transfer
- Apple Pay
- Google Pay
If the conditional keeps growing, every new payment method requires modifying the same class.
The problem is not that if or switch statements are inherently bad.
The problem is that independently changing behavior is becoming tightly coupled to one piece of code.
Applying the Strategy Pattern
We can introduce an abstraction:
public interface IPaymentStrategy
{
void Pay(decimal amount);
}
Then create separate implementations:
public class CreditCardPayment : IPaymentStrategy
{
public void Pay(decimal amount)
{
// Process credit card payment
}
}
public class UpiPayment : IPaymentStrategy
{
public void Pay(decimal amount)
{
// Process UPI payment
}
}
The service depends on the abstraction:
public class PaymentService
{
private readonly IPaymentStrategy _paymentStrategy;
public PaymentService(IPaymentStrategy paymentStrategy)
{
_paymentStrategy = paymentStrategy;
}
public void Process(decimal amount)
{
_paymentStrategy.Pay(amount);
}
}
Now a new payment method can be introduced:
public class BankTransferPayment : IPaymentStrategy
{
public void Pay(decimal amount)
{
// Process bank transfer
}
}
The existing payment implementations do not need to change.
Strategy Pattern and OCP
This is one of the clearest relationships between SOLID and design patterns:
Problem
↓
Multiple interchangeable behaviors
↓
Identify the variation
↓
Encapsulate each behavior
↓
Strategy Pattern
↓
Add new strategies without changing existing strategies
The Strategy Pattern is particularly useful when several algorithms or business rules implement the same conceptual operation.
Factory Pattern and OCP
A Factory can be useful when object creation is itself a concern.
For example:
public interface IPaymentProcessor
{
void Process(decimal amount);
}
Implementations:
IPaymentProcessor
|
+---- CreditCardProcessor
+---- UpiProcessor
+---- BankTransferProcessor
A simple factory might select an implementation:
public class PaymentProcessorFactory
{
public IPaymentProcessor Create(string type)
{
return type switch
{
"CreditCard" => new CreditCardProcessor(),
"UPI" => new UpiProcessor(),
"BankTransfer" => new BankTransferProcessor(),
_ => throw new ArgumentException("Unsupported payment type")
};
}
}
However, notice something important.
The factory itself must change when a new payment type is added.
Therefore, this factory is not automatically an example of a completely closed-for-modification design.
It can still be a useful solution if the creation logic is intentionally centralized and the trade-off is acceptable.
In larger .NET applications, dependency injection, keyed services, configuration-driven registration, or other approaches may sometimes provide a better fit.
The lesson is:
Do not use a pattern simply because its name appears in a SOLID discussion. Evaluate the actual design trade-off.
3. Liskov Substitution Principle — LSP
The Simple Explanation
The Liskov Substitution Principle states that:
A derived type should be usable wherever its base type is expected without violating the expectations of the consuming code.
In simpler terms:
If
Bis a subtype ofA, replacing anAwith aBshould not unexpectedly break the program's correctness or behavioral assumptions.
LSP is less about syntax and more about behavioral contracts.
The Classic Example
Consider:
public class Bird
{
public virtual void Fly()
{
Console.WriteLine("Flying");
}
}
Now imagine:
public class Penguin : Bird
{
public override void Fly()
{
throw new NotSupportedException();
}
}
We have created a problem.
The base class exposes Fly() as a behavior that consumers can call.
But Penguin cannot provide that behavior.
This code becomes problematic:
Bird bird = new Penguin();
bird.Fly();
The derived type cannot safely satisfy the expectations created by the base abstraction.
A Better Design
The problem is the abstraction.
Not every bird needs to support flying.
We can separate the concepts:
public abstract class Bird
{
public abstract void Move();
}
Then define flying separately:
public interface IFlyingBird
{
void Fly();
}
An eagle can implement it:
public class Eagle : Bird, IFlyingBird
{
public override void Move()
{
Fly();
}
public void Fly()
{
Console.WriteLine("Eagle is flying");
}
}
A penguin does not need to implement IFlyingBird:
public class Penguin : Bird
{
public override void Move()
{
Console.WriteLine("Penguin is walking");
}
}
The abstraction now better represents the actual capabilities of the types.
LSP in Enterprise Applications
The Bird/Penguin example is useful for understanding the principle, but enterprise applications often encounter LSP problems in more subtle ways.
Consider a payment processor:
public abstract class PaymentProcessor
{
public abstract void Process(decimal amount);
public abstract void Refund(decimal amount);
}
Suppose an implementation does not support refunds:
public class SomePaymentProcessor : PaymentProcessor
{
public override void Process(decimal amount)
{
// Process payment
}
public override void Refund(decimal amount)
{
throw new NotSupportedException();
}
}
If consumers of PaymentProcessor reasonably expect every implementation to support refunds, the abstraction is too broad.
A better design may separate the capabilities:
public interface IPaymentProcessor
{
void Process(decimal amount);
}
public interface IRefundProcessor
{
void Refund(decimal amount);
}
An implementation can then support the capabilities it actually provides.
This example also connects naturally to the Interface Segregation Principle.
LSP and Composition
A useful way to avoid problematic inheritance is to consider composition.
Instead of creating a complex hierarchy:
BaseProcessor
|
+-- ProcessorA
+-- ProcessorB
+-- ProcessorC
consider whether behavior can be composed:
Processor
|
+-- Strategy
+-- Validator
+-- Formatter
Composition is not a replacement for inheritance in every situation, but it can reduce problems caused by incorrect inheritance relationships.
Common LSP Warning Signs
Look carefully when you see:
throw new NotSupportedException();
inside an overridden method.
This is not automatically an LSP violation.
Sometimes an unsupported operation is legitimate.
But it should trigger a design question:
Does the base abstraction promise behavior that this implementation cannot provide?
Other warning signs include:
- Derived classes disabling base behavior
- Derived classes significantly changing the meaning of base methods
- Consumers checking concrete subtypes before using them
- Large type-checking blocks
- Frequent
ischecks against derived types - Special-case handling for particular subclasses
4. Interface Segregation Principle — ISP
The Simple Explanation
The Interface Segregation Principle states:
Clients should not be forced to depend on methods they do not use.
In simpler terms:
Prefer focused interfaces that represent meaningful capabilities over large interfaces containing unrelated operations.
A Common Problem
Consider an employee management system:
public interface IEmployee
{
void Work();
void Eat();
void AttendMeeting();
void WriteCode();
void ManageTeam();
}
Now imagine a developer:
public class Developer : IEmployee
{
public void Work()
{
}
public void Eat()
{
}
public void AttendMeeting()
{
}
public void WriteCode()
{
}
public void ManageTeam()
{
throw new NotSupportedException();
}
}
The developer is forced to implement ManageTeam() even though that capability is not part of the developer's responsibility.
This is a sign of a fat interface.
Split the Interface
Instead, define smaller, cohesive interfaces:
public interface IWorker
{
void Work();
}
public interface IDeveloper
{
void WriteCode();
}
public interface IManager
{
void ManageTeam();
}
Now:
public class Developer : IWorker, IDeveloper
{
public void Work()
{
}
public void WriteCode()
{
}
}
And:
public class EngineeringManager : IWorker, IManager
{
public void Work()
{
}
public void ManageTeam()
{
}
}
Each class depends only on the capabilities it needs.
ISP in an Enterprise Service
Imagine a large customer service interface:
public interface ICustomerService
{
Customer GetCustomer(int id);
void CreateCustomer(Customer customer);
void UpdateCustomer(Customer customer);
void DeleteCustomer(int id);
void SendMarketingEmail(int id);
void ExportCustomers();
}
Different consumers may need completely different capabilities.
Instead, we could separate them:
public interface ICustomerReader
{
Customer GetCustomer(int id);
}
public interface ICustomerWriter
{
void CreateCustomer(Customer customer);
void UpdateCustomer(Customer customer);
void DeleteCustomer(int id);
}
public interface ICustomerExporter
{
void ExportCustomers();
}
This creates smaller contracts that can be consumed independently.
Patterns That Can Support ISP
Some patterns can complement interface segregation:
- Adapter — exposes the interface a client actually needs
- Facade — provides a focused interface over a more complex subsystem
- Proxy — can provide a focused boundary around another component
Again, the pattern does not implement ISP automatically.
The important design decision is to ensure clients are not forced to depend on irrelevant operations.
Common ISP Mistake
Do not interpret ISP as:
"Every interface should contain exactly one method."
That is not the principle.
An interface can contain several methods when they represent one cohesive capability.
For example:
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(int id);
Task AddAsync(Order order);
Task UpdateAsync(Order order);
}
These operations may form a cohesive persistence contract.
The goal is cohesion, not an arbitrary number of methods.
5. Dependency Inversion Principle — DIP
The Simple Explanation
The Dependency Inversion Principle states:
High-level modules should not depend on low-level modules. Both should depend on abstractions.
It also states:
Abstractions should not depend on details. Details should depend on abstractions.
In simpler terms:
Business logic should not be tightly coupled to infrastructure details.
A Common Problem
Consider:
public class OrderService
{
private readonly SqlOrderRepository _repository;
public OrderService()
{
_repository = new SqlOrderRepository();
}
public void CreateOrder(Order order)
{
_repository.Save(order);
}
}
OrderService directly creates SqlOrderRepository.
The business service now knows:
- Which persistence implementation is being used
- How the repository is created
- That the repository is backed by SQL Server
This creates tight coupling.
Introduce an Abstraction
Define:
public interface IOrderRepository
{
void Save(Order order);
}
The infrastructure implementation can be:
public class SqlOrderRepository : IOrderRepository
{
public void Save(Order order)
{
// Save to SQL Server
}
}
Now the service depends on the abstraction:
public class OrderService
{
private readonly IOrderRepository _repository;
public OrderService(IOrderRepository repository)
{
_repository = repository;
}
public void CreateOrder(Order order)
{
_repository.Save(order);
}
}
The dependency direction becomes:
Before:
OrderService
|
↓
SqlOrderRepository
After:
OrderService
|
↓
IOrderRepository
↑
|
SqlOrderRepository
The high-level business logic no longer needs to know the concrete persistence implementation.
Dependency Inversion vs Dependency Injection
These concepts are closely related but are not the same thing.
Dependency Inversion Principle
DIP is a design principle.
It describes how high-level and low-level components should depend on abstractions.
Dependency Injection
Dependency Injection is a technique for providing dependencies to a class from outside the class.
For example:
public OrderService(IOrderRepository repository)
{
_repository = repository;
}
This is constructor injection.
In ASP.NET Core, the built-in dependency injection container can register the implementation:
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<OrderService>();
The framework can then provide SqlOrderRepository when OrderService is created.
So the relationship is:
DIP
↓
Design principle
Dependency Injection
↓
Implementation technique
Dependency Injection is one common way to implement a design that follows DIP.
SOLID and Design Patterns — The Practical Connection
Now let's connect the principles with patterns and techniques that can help address related design problems.
| SOLID Principle | Patterns / Techniques That May Help | Typical Design Problem |
|---|---|---|
| SRP | Command, Strategy, Decorator, Facade, Repository | Too many independent responsibilities |
| OCP | Strategy, Decorator, Template Method, Factory | New behavior repeatedly requires modifying existing code |
| LSP | Strategy, Adapter, Composition | Incorrect or overly restrictive inheritance |
| ISP | Adapter, Facade, Proxy | Clients depend on unnecessary operations |
| DIP | Dependency Injection, Adapter, Repository, Factory | Business logic depends directly on implementation details |
This is not a strict one-to-one mapping.
A pattern can support multiple SOLID principles, and a SOLID principle can be applied without using a design pattern.
For example:
Strategy
↓
Can support OCP
↓
Can also help SRP
↓
May also improve testability
Likewise:
Adapter
↓
Can help isolate an external dependency
↓
Can support DIP
↓
Can expose a focused interface
↓
Can support ISP
The correct approach is to start with the design problem, not the pattern name.
SOLID Does Not Mean "More Interfaces"
One of the biggest misconceptions about SOLID is:
"If I create interfaces everywhere, my code is SOLID."
Not necessarily.
Consider:
public interface IUserService
{
void CreateUser();
}
public class UserService : IUserService
{
public void CreateUser()
{
}
}
If the interface provides no meaningful abstraction or architectural boundary, it may simply add indirection.
Instead, ask:
Where do I actually need an abstraction?
Useful boundaries often include:
- External systems
- Infrastructure
- Business strategies
- Multiple implementations
- Testing boundaries
- Plugin-style architectures
- Components that are expected to vary independently
Abstraction has a cost.
Use it where it provides a meaningful benefit.
SOLID Does Not Mean "Create More Classes"
Another common mistake is over-fragmentation.
For example:
OrderService
OrderValidator
OrderCalculator
OrderMapper
OrderLogger
OrderFormatter
OrderHelper
OrderUtility
OrderManager
OrderProcessor
OrderHandler
Having many classes does not automatically mean the design is better.
A highly fragmented design can introduce:
- More dependencies
- More files
- More abstractions
- More cognitive overhead
- More difficult debugging
- More complex dependency graphs
The objective is appropriate separation, not maximum separation.
SOLID Does Not Mean "Remove Every if/else"
Another misconception is:
"SOLID means removing every if/else statement."
That's not true.
This can be perfectly reasonable:
if (order.Total > 1000)
{
ApplyDiscount(order);
}
The question is whether the conditional represents a stable rule or whether it is continuously growing with independently changing behavior.
For example:
if (paymentType == "CreditCard")
{
}
else if (paymentType == "UPI")
{
}
else if (paymentType == "PayPal")
{
}
else if (paymentType == "BankTransfer")
{
}
If this list continuously grows and each branch represents an independent behavior, the Strategy Pattern may be worth considering.
The pattern should be introduced because it improves the design, not simply because an if statement exists.
SOLID Does Not Mean "Use Every Design Pattern"
Design patterns are tools.
Using them everywhere can create unnecessary complexity.
For example, a simple calculation does not need:
Abstract Factory
↓
Factory Method
↓
Strategy
↓
Decorator
↓
Mediator
when this may be sufficient:
public decimal CalculateTax(decimal amount)
{
return amount * 0.18m;
}
A good engineer knows not only how to apply patterns, but also when not to apply them.
A Real-World Example: Notification System
Let's bring several SOLID principles together using a notification system.
Suppose an application needs to send:
- SMS
- Push notifications
A simple implementation might look like this:
public class NotificationService
{
public void Send(string type, string recipient, string message)
{
if (type == "Email")
{
// Send email
}
else if (type == "SMS")
{
// Send SMS
}
else if (type == "Push")
{
// Send push notification
}
}
}
It works.
But what happens when we add:
- Microsoft Teams
- Slack
The class keeps growing.
Introduce an Abstraction
public interface INotificationSender
{
Task SendAsync(string recipient, string message);
}
Email:
public class EmailNotificationSender : INotificationSender
{
public Task SendAsync(string recipient, string message)
{
// Send email
return Task.CompletedTask;
}
}
SMS:
public class SmsNotificationSender : INotificationSender
{
public Task SendAsync(string recipient, string message)
{
// Send SMS
return Task.CompletedTask;
}
}
Push:
public class PushNotificationSender : INotificationSender
{
public Task SendAsync(string recipient, string message)
{
// Send push notification
return Task.CompletedTask;
}
}
The application service can work with the abstraction:
public class NotificationService
{
private readonly INotificationSender _sender;
public NotificationService(INotificationSender sender)
{
_sender = sender;
}
public Task SendAsync(string recipient, string message)
{
return _sender.SendAsync(recipient, message);
}
}
The resulting design is:
NotificationService
|
↓
INotificationSender
↑
|
+-----+-----+------+
| | |
Email SMS Push
Several principles are represented here.
SRP
Each sender is responsible for one notification mechanism.
OCP
A new notification mechanism can be introduced as another implementation without changing the existing senders.
LSP
Each implementation should honor the behavioral contract defined by INotificationSender.
ISP
The interface contains only the operation needed by notification senders.
DIP
NotificationService depends on INotificationSender, not directly on an email, SMS, or push provider.
This is a good example of how SOLID principles can work together rather than being treated as five isolated rules.
How to Identify SOLID Issues During Code Review
Instead of checking the five principles mechanically, ask practical questions.
SRP Questions
- Does this class have unrelated responsibilities?
- Would different concerns cause different changes to this class?
- Does the class interact with too many unrelated technologies?
- Is the class becoming a "God class"?
OCP Questions
- Does adding a new behavior require modifying existing logic?
- Is a conditional structure continuously growing?
- Are new business rules repeatedly being added to the same class?
- Can the changing behavior be isolated?
LSP Questions
- Does a subclass throw
NotSupportedExceptionfor inherited behavior? - Does a subclass change the expected meaning of a base method?
- Do consumers need to check the concrete subtype before using it?
- Is inheritance actually representing the required behavioral relationship?
ISP Questions
- Are implementations forced to implement irrelevant methods?
- Is the interface becoming very large?
- Do different consumers use completely different subsets of the interface?
- Can the contract be divided into meaningful capabilities?
DIP Questions
- Does business logic directly instantiate infrastructure classes?
- Does a service depend directly on a database provider?
- Does business logic know about external SDK implementation details?
- Can an infrastructure implementation be replaced without changing business logic?
A Practical SOLID Refactoring Workflow
You do not need to redesign an entire application whenever you discover a SOLID issue.
A practical workflow is:
Step 1 — Identify What Changes
Ask:
What is likely to change independently?
Examples:
Payment provider
Notification channel
Database
Pricing algorithm
Authentication mechanism
File storage provider
Step 2 — Identify the Coupling
Ask:
Which component knows too much about this changing behavior?
Step 3 — Identify the Responsibility
Ask:
Can this behavior be separated without making the design unnecessarily complex?
Step 4 — Introduce an Abstraction When It Adds Value
For example:
public interface IPaymentProcessor
{
Task ProcessAsync(decimal amount);
}
Step 5 — Choose a Pattern If It Fits
For example:
Multiple interchangeable algorithms
↓
Strategy Pattern
External system has an incompatible API
↓
Adapter Pattern
Object creation is complex or needs encapsulation
↓
Factory Pattern
Step 6 — Keep the Design Simple
Do not introduce five abstractions when one abstraction solves the problem.
SOLID in a Typical .NET Application
A typical enterprise application may have a structure similar to:
API Controller
|
↓
Application Service
|
+------------+------------+
| | |
↓ ↓ ↓
Repository Payment Notification
| Strategy Service
↓ |
Database +--+--+
| |
Card UPI
This does not mean every .NET application should have exactly this structure.
It simply demonstrates how the principles can work together.
SRP
Components have focused responsibilities.
OCP
New payment strategies can be introduced without modifying existing payment strategies.
LSP
Payment implementations should honor the abstraction's behavioral contract.
ISP
Consumers can depend on focused interfaces.
DIP
Application-level code depends on abstractions rather than infrastructure implementations.
SOLID vs Design Patterns
| SOLID Principles | Design Patterns |
|---|---|
| Design principles | Reusable design approaches |
| Help guide design decisions | Help solve recurring design problems |
| More abstract | More concrete |
| Help identify desirable design characteristics | Provide implementation techniques |
| Can be applied without design patterns | Can be used independently of SOLID |
| Focus on maintainability and flexibility | Focus on recurring structural or behavioral problems |
A useful mental model is:
SOLID tells you what characteristics to aim for. Design patterns provide techniques that may help you achieve those characteristics.
When Should You Not Apply SOLID Aggressively?
SOLID is valuable, but context matters.
For a small application with a simple and stable requirement:
Simple problem
↓
Simple implementation
may be better than:
Simple problem
↓
Multiple interfaces
↓
Multiple factories
↓
Multiple strategies
↓
Complex dependency graph
For a larger application with:
- Multiple teams
- Frequent requirement changes
- External integrations
- Complex business rules
- Long-term maintenance
- Significant automated testing requirements
appropriate abstractions can provide greater value.
The right design depends on:
- Complexity
- Change frequency
- Number of implementations
- Team size
- Testing requirements
- Expected lifetime of the software
- Operational requirements
SOLID is a design guide, not a requirement to maximize abstraction.
A Simple SOLID Mental Model
When reviewing a class, ask these five questions:
1. S — Does this class have too many independent reasons to change?
2. O — Do I repeatedly modify this code whenever a new behavior is introduced?
3. L — Can every implementation genuinely honor the behavioral contract
of its abstraction?
4. I — Is the interface asking implementations to support things they
don't need?
5. D — Does my business logic depend directly on infrastructure or
concrete implementations?
These questions are often more useful during code reviews than simply memorizing five definitions.
SOLID Design Pattern Quick Reference
| Principle | Common Design Smell | Possible Refactoring | Patterns / Techniques That May Help |
|---|---|---|---|
| SRP | One class has unrelated responsibilities | Extract cohesive responsibilities | Command, Strategy, Decorator, Facade, Repository |
| OCP | New behavior repeatedly requires modifying existing code | Encapsulate variation behind abstractions | Strategy, Decorator, Template Method, Factory |
| LSP | Derived type cannot honor the base contract | Redesign the abstraction or prefer composition | Strategy, Adapter, Composition |
| ISP | Large interface with irrelevant methods | Split into cohesive capabilities | Adapter, Facade, Proxy |
| DIP | Business logic directly depends on infrastructure | Introduce appropriate abstractions | Dependency Injection, Adapter, Repository, Factory |
This table is a practical association, not a strict rule.
There is no requirement that a particular SOLID principle must always be implemented using a particular pattern.
Common SOLID Misconceptions
Misconception 1: "SOLID means more interfaces."
Reality: Interfaces are useful when they represent meaningful abstractions or boundaries.
Misconception 2: "SOLID means every class should have one method."
Reality: SRP is about reasons to change, not method count.
Misconception 3: "SOLID means no if/else statements."
Reality: Conditional logic is perfectly valid. The question is whether independently changing behavior is becoming tightly coupled.
Misconception 4: "DIP and Dependency Injection are the same."
Reality: DIP is a design principle. Dependency Injection is one technique that can help implement it.
Misconception 5: "Every SOLID problem requires a design pattern."
Reality: Sometimes a simple refactoring is the best solution.
Misconception 6: "Inheritance is always better than composition."
Reality: Inheritance is useful when the subtype genuinely satisfies the behavioral contract of the base abstraction. Composition can be preferable when behavior needs to vary independently.
Misconception 7: "Following SOLID guarantees good architecture."
Reality: SOLID addresses important object-oriented design concerns, but good architecture also requires consideration of performance, security, reliability, scalability, maintainability, operational complexity, and business requirements.
The Most Important Lesson
When developers learn SOLID, it is tempting to memorize statements such as:
SRP → One responsibility
OCP → Open for extension, closed for modification
LSP → Subtypes must be substitutable
ISP → Small interfaces
DIP → Depend on abstractions
These definitions are useful, but they are only the starting point.
A more useful engineering mindset is:
What is changing?
↓
What is coupled to that change?
↓
Is the responsibility cohesive?
↓
Is the abstraction meaningful?
↓
Can the dependency be inverted?
↓
Would a design pattern simplify the solution?
↓
Is the resulting design actually easier to maintain?
That is where SOLID becomes practical.
Final Takeaways
SOLID is not about writing more code.
It is about making change easier to manage.
The five principles can be summarized as:
SRP
→ Keep responsibilities focused.
OCP
→ Isolate expected variation so new behavior can be added with
minimal modification to stable code.
LSP
→ Make abstractions behaviorally trustworthy.
ISP
→ Keep contracts focused on meaningful capabilities.
DIP
→ Keep high-level business logic independent from implementation details.
Design patterns and techniques can support these principles:
SRP → Command, Strategy, Decorator, Facade, Repository
OCP → Strategy, Decorator, Template Method, Factory
LSP → Strategy, Adapter, Composition
ISP → Adapter, Facade, Proxy
DIP → Dependency Injection, Adapter, Repository, Factory
But there is an even more important lesson:
Don't start with a design pattern. Start with the problem.
Ask:
- What is changing?
- What is tightly coupled to that change?
- Can the responsibility be separated?
- Is an abstraction actually useful?
- Would a design pattern simplify the solution?
- Does the resulting design reduce complexity rather than increase it?
Good software design is not about following SOLID mechanically.
It is about creating a codebase where responsibilities are clear, dependencies are manageable, expected changes are easier to accommodate, and complexity is kept under control.
The best SOLID design is not the one with the most abstractions.
It is the one that makes the important changes easier without introducing unnecessary complexity.
Practical SOLID Checklist
Before committing a significant design change, ask:
â–¡ Does each component have a clear and cohesive responsibility?
â–¡ Can expected variations be added without repeatedly changing stable code?
â–¡ Can implementations safely honor the contracts of their abstractions?
â–¡ Are interfaces focused on meaningful capabilities?
â–¡ Does business logic avoid unnecessary dependencies on infrastructure details?
â–¡ Am I introducing an abstraction because I need it, rather than because
SOLID tells me to?
â–¡ Am I using a design pattern because it solves a real problem?
â–¡ Has the solution become simpler or more maintainable?
â–¡ Have I avoided unnecessary layers and abstractions?
If the answer to these questions is clear, you are applying SOLID as an engineering principle, rather than simply memorizing its definitions.
Top comments (0)