DEV Community

Cover image for SOLID Principles in C#: A Practical Guide with Real-World Examples and Design Patterns
Chethan Ramaswamy
Chethan Ramaswamy

Posted on

SOLID Principles in C#: A Practical Guide with Real-World Examples and Design Patterns

📑 Contents

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 [];
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode
public interface IPaymentService
{
    Task ProcessAsync(Order order);
}
Enter fullscreen mode Exit fullscreen mode
public interface INotificationService
{
    Task SendOrderConfirmationAsync(Order order);
}
Enter fullscreen mode Exit fullscreen mode
public interface IInvoiceService
{
    Task<byte[]> GenerateAsync(Order order);
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the responsibilities are clearer:

OrderService
     |
     +── IOrderRepository
     +── IPaymentService
     +── INotificationService
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Then create separate implementations:

public class CreditCardPayment : IPaymentStrategy
{
    public void Pay(decimal amount)
    {
        // Process credit card payment
    }
}
Enter fullscreen mode Exit fullscreen mode
public class UpiPayment : IPaymentStrategy
{
    public void Pay(decimal amount)
    {
        // Process UPI payment
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now a new payment method can be introduced:

public class BankTransferPayment : IPaymentStrategy
{
    public void Pay(decimal amount)
    {
        // Process bank transfer
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Implementations:

IPaymentProcessor
       |
       +---- CreditCardProcessor
       +---- UpiProcessor
       +---- BankTransferProcessor
Enter fullscreen mode Exit fullscreen mode

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")
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

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 B is a subtype of A, replacing an A with a B should 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");
    }
}
Enter fullscreen mode Exit fullscreen mode

Now imagine:

public class Penguin : Bird
{
    public override void Fly()
    {
        throw new NotSupportedException();
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

Then define flying separately:

public interface IFlyingBird
{
    void Fly();
}
Enter fullscreen mode Exit fullscreen mode

An eagle can implement it:

public class Eagle : Bird, IFlyingBird
{
    public override void Move()
    {
        Fly();
    }

    public void Fly()
    {
        Console.WriteLine("Eagle is flying");
    }
}
Enter fullscreen mode Exit fullscreen mode

A penguin does not need to implement IFlyingBird:

public class Penguin : Bird
{
    public override void Move()
    {
        Console.WriteLine("Penguin is walking");
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode
public interface IRefundProcessor
{
    void Refund(decimal amount);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

consider whether behavior can be composed:

Processor
    |
    +-- Strategy
    +-- Validator
    +-- Formatter
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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 is checks 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();
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode
public interface IDeveloper
{
    void WriteCode();
}
Enter fullscreen mode Exit fullscreen mode
public interface IManager
{
    void ManageTeam();
}
Enter fullscreen mode Exit fullscreen mode

Now:

public class Developer : IWorker, IDeveloper
{
    public void Work()
    {
    }

    public void WriteCode()
    {
    }
}
Enter fullscreen mode Exit fullscreen mode

And:

public class EngineeringManager : IWorker, IManager
{
    public void Work()
    {
    }

    public void ManageTeam()
    {
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

Different consumers may need completely different capabilities.

Instead, we could separate them:

public interface ICustomerReader
{
    Customer GetCustomer(int id);
}
Enter fullscreen mode Exit fullscreen mode
public interface ICustomerWriter
{
    void CreateCustomer(Customer customer);
    void UpdateCustomer(Customer customer);
    void DeleteCustomer(int id);
}
Enter fullscreen mode Exit fullscreen mode
public interface ICustomerExporter
{
    void ExportCustomers();
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

The infrastructure implementation can be:

public class SqlOrderRepository : IOrderRepository
{
    public void Save(Order order)
    {
        // Save to SQL Server
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

The dependency direction becomes:

Before:

OrderService
     |
     ↓
SqlOrderRepository


After:

OrderService
     |
     ↓
IOrderRepository
     ↑
     |
SqlOrderRepository
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode

The framework can then provide SqlOrderRepository when OrderService is created.

So the relationship is:

DIP
 ↓
Design principle

Dependency Injection
 ↓
Implementation technique
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Likewise:

Adapter
   ↓
Can help isolate an external dependency
   ↓
Can support DIP
   ↓
Can expose a focused interface
   ↓
Can support ISP
Enter fullscreen mode Exit fullscreen mode

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()
    {
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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")
{
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

when this may be sufficient:

public decimal CalculateTax(decimal amount)
{
    return amount * 0.18m;
}
Enter fullscreen mode Exit fullscreen mode

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:

  • Email
  • 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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

It works.

But what happens when we add:

  • WhatsApp
  • Microsoft Teams
  • Slack

The class keeps growing.


Introduce an Abstraction

public interface INotificationSender
{
    Task SendAsync(string recipient, string message);
}
Enter fullscreen mode Exit fullscreen mode

Email:

public class EmailNotificationSender : INotificationSender
{
    public Task SendAsync(string recipient, string message)
    {
        // Send email
        return Task.CompletedTask;
    }
}
Enter fullscreen mode Exit fullscreen mode

SMS:

public class SmsNotificationSender : INotificationSender
{
    public Task SendAsync(string recipient, string message)
    {
        // Send SMS
        return Task.CompletedTask;
    }
}
Enter fullscreen mode Exit fullscreen mode

Push:

public class PushNotificationSender : INotificationSender
{
    public Task SendAsync(string recipient, string message)
    {
        // Send push notification
        return Task.CompletedTask;
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

The resulting design is:

NotificationService
        |
        ↓
INotificationSender
        ↑
        |
  +-----+-----+------+
  |           |      |
Email        SMS    Push
Enter fullscreen mode Exit fullscreen mode

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 NotSupportedException for 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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Step 5 — Choose a Pattern If It Fits

For example:

Multiple interchangeable algorithms
        ↓
Strategy Pattern
Enter fullscreen mode Exit fullscreen mode
External system has an incompatible API
        ↓
Adapter Pattern
Enter fullscreen mode Exit fullscreen mode
Object creation is complex or needs encapsulation
        ↓
Factory Pattern
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

may be better than:

Simple problem
     ↓
Multiple interfaces
     ↓
Multiple factories
     ↓
Multiple strategies
     ↓
Complex dependency graph
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

But there is an even more important lesson:

Don't start with a design pattern. Start with the problem.

Ask:

  1. What is changing?
  2. What is tightly coupled to that change?
  3. Can the responsibility be separated?
  4. Is an abstraction actually useful?
  5. Would a design pattern simplify the solution?
  6. 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?
Enter fullscreen mode Exit fullscreen mode

If the answer to these questions is clear, you are applying SOLID as an engineering principle, rather than simply memorizing its definitions.


programming

csharp

dotnet

solid

designpatterns

softwarearchitecture

Top comments (0)