Skip to content

SOLID Principles in C# — Complete Guide

DodaTech Updated 2026-06-28 9 min read

In this tutorial, you will learn about SOLID Principles in C#. We cover key concepts, practical examples, and best practices to help you master this topic.

Hook

SOLID is the foundation of maintainable object-oriented design. These five principles guide you in creating code that is easy to understand, extend, and refactor. C# and .NET provide features that support each principle, from interfaces to Dependency Injection. Mastering SOLID transforms good code into great architecture.

Learning Path

graph LR
  A[SOLID] --> B[SRP]
  A --> C[OCP]
  A --> D[LSP]
  A --> E[ISP]
  A --> F[DIP]
  B --> G[Single Responsibility]
  C --> H[Open/Closed]
  D --> I[Liskov Substitution]
  E --> J[Interface Segregation]
  F --> K[Dependency Inversion]
  style A fill:#4a90d9,color:#fff
  style B fill:#4a90d9,color:#fff
  style C fill:#4a90d9,color:#fff
  style D fill:#4a90d9,color:#fff
  style E fill:#4a90d9,color:#fff
  style F fill:#4a90d9,color:#fff

S: Single Responsibility Principle (SRP)

A class should have only one reason to change.

// BAD: Report class handles data, formatting, and persistence
public class BadReport
{
    public string Title { get; set; }
    public List<string> Data { get; set; }

    public void GenerateHtmlReport() { /* HTML formatting */ }
    public void GeneratePdfReport() { /* PDF formatting */ }
    public void SaveToDatabase() { /* Database logic */ }
    public void SendByEmail() { /* Email logic */ }
}

// GOOD: Each class has a single responsibility
public class Report
{
    public string Title { get; set; }
    public List<string> Data { get; set; }
}

public class ReportFormatter
{
    public string ToHtml(Report report) => $"<h1>{report.Title}</h1>";
    public string ToPdf(Report report) => $"PDF:{report.Title}";
}

public class ReportRepository
{
    public void Save(Report report) => Console.WriteLine("Saving to DB");
}

public class EmailService
{
    public void SendReport(string to, Report report) =>
        Console.WriteLine($"Emailing report to {to}");
}

O: Open/Closed Principle (OCP)

Classes should be open for extension but closed for modification.

// BAD: Adding a new shape requires modifying the calculator
public class BadAreaCalculator
{
    public double CalculateArea(object shape)
    {
        if (shape is Circle c)
            return Math.PI * c.Radius * c.Radius;
        if (shape is Rectangle r)
            return r.Width * r.Height;
        // Must modify this method to add new shapes!
        throw new NotSupportedException();
    }
}

// GOOD: Extend behavior through inheritance
public abstract class Shape
{
    public abstract double CalculateArea();
}

public class Circle : Shape
{
    public double Radius { get; set; }
    public override double CalculateArea() =>
        Math.PI * Radius * Radius;
}

public class Rectangle : Shape
{
    public double Width { get; set; }
    public double Height { get; set; }
    public override double CalculateArea() => Width * Height;
}

// New shape: no modification needed
public class Triangle : Shape
{
    public double Base { get; set; }
    public double Height { get; set; }
    public override double CalculateArea() => 0.5 * Base * Height;
}

public class AreaCalculator
{
    public double CalculateTotalArea(Shape[] shapes) =>
        shapes.Sum(s => s.CalculateArea());
}

L: Liskov Substitution Principle (LSP)

Derived classes must be substitutable for their base classes.

// BAD: Square violates LSP when inheriting from Rectangle
public class BadRectangle
{
    public virtual int Width { get; set; }
    public virtual int Height { get; set; }
    public int Area => Width * Height;
}

public class BadSquare : BadRectangle
{
    public override int Width
    {
        set { base.Width = value; base.Height = value; }
    }
    public override int Height
    {
        set { base.Width = value; base.Height = value; }
    }
}

// Client assumes Rectangle behavior
void ResizeToFit(BadRectangle rect)
{
    rect.Width = 5;
    rect.Height = 10;
    // Client expects Width=5, Height=10, Area=50
    // But with BadSquare: Width=10, Height=10, Area=100!
}

// GOOD: Use abstraction that both can implement
public interface IShape
{
    int Area { get; }
}

public readonly struct LspRectangle : IShape
{
    public int Width { get; }
    public int Height { get; }
    public int Area => Width * Height;

    public LspRectangle(int width, int height)
    {
        Width = width;
        Height = height;
    }
}

public readonly struct LspSquare : IShape
{
    public int Side { get; }
    public int Area => Side * Side;

    public LspSquare(int side) => Side = side;
}

I: Interface Segregation Principle (ISP)

Clients should not be forced to depend on interfaces they do not use.

// BAD: Fat interface forces all workers to implement irrelevant methods
public interface IWorker
{
    void Work();
    void Eat();
    void Sleep();
}

public class HumanWorker : IWorker
{
    public void Work() => Console.WriteLine("Working");
    public void Eat() => Console.WriteLine("Eating");
    public void Sleep() => Console.WriteLine("Sleeping");
}

public class RobotWorker : IWorker
{
    public void Work() => Console.WriteLine("Working");
    public void Eat() => throw new NotSupportedException(); // Robots don't eat!
    public void Sleep() => throw new NotSupportedException(); // Robots don't sleep!
}

// GOOD: Segregated interfaces
public interface IWorkable
{
    void Work();
}

public interface IFeedable
{
    void Eat();
}

public interface ISleepable
{
    void Sleep();
}

public class GoodHumanWorker : IWorkable, IFeedable, ISleepable
{
    public void Work() => Console.WriteLine("Working");
    public void Eat() => Console.WriteLine("Eating");
    public void Sleep() => Console.WriteLine("Sleeping");
}

public class GoodRobotWorker : IWorkable
{
    public void Work() => Console.WriteLine("Working");
}

D: Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level modules. Both should depend on abstractions.

// BAD: High-level depends directly on low-level
public class BadOrderService
{
    private readonly SqlServerDatabase _database;

    public BadOrderService()
    {
        _database = new SqlServerDatabase(); // Tight coupling
    }

    public void PlaceOrder(Order order)
    {
        _database.SaveOrder(order);
    }
}

public class SqlServerDatabase
{
    public void SaveOrder(Order order) =>
        Console.WriteLine("Saving to SQL Server");
}

// GOOD: Both depend on abstraction
public interface IOrderRepository
{
    void Save(Order order);
}

public class OrderService
{
    private readonly IOrderRepository _repository;
    private readonly INotificationService _notification;

    public OrderService(IOrderRepository repository,
        INotificationService notification)
    {
        _repository = repository;
        _notification = notification;
    }

    public async Task PlaceOrder(Order order)
    {
        _repository.Save(order);
        await _notification.SendAsync("order@system.com", "New order placed");
    }
}

public class SqlServerOrderRepository : IOrderRepository
{
    public void Save(Order order) =>
        Console.WriteLine("Saving to SQL Server");
}

public class MongoDbOrderRepository : IOrderRepository
{
    public void Save(Order order) =>
        Console.WriteLine("Saving to MongoDB");
}

// Configuration decides implementation
// services.AddScoped<IOrderRepository, SqlServerOrderRepository>();

Applying SOLID Together

A practical example combining all principles.

// SRP: Each class has one job
public interface IEmployeeRepository
{
    Task<Employee> GetByIdAsync(int id);
}

public interface IPayrollCalculator
{
    decimal CalculatePay(Employee employee);
}

public interface IPaymentProcessor
{
    Task ProcessPaymentAsync(Employee employee, decimal amount);
}

// OCP: New pay types via extension
public abstract class PayCalculator
{
    public abstract decimal Calculate(Employee employee);
}

public class SalaryCalculator : PayCalculator
{
    public override decimal Calculate(Employee e) => e.AnnualSalary / 12;
}

public class HourlyCalculator : PayCalculator
{
    public override decimal Calculate(Employee e) => e.HoursWorked * e.HourlyRate;
}

// LSP: Substitutable calculators
public class PayrollService
{
    private readonly IEmployeeRepository _repo;
    private readonly PayCalculator _calculator;
    private readonly IPaymentProcessor _processor;

    public PayrollService(IEmployeeRepository repo, PayCalculator calculator,
        IPaymentProcessor processor)
    {
        _repo = repo;
        _calculator = calculator;
        _processor = processor;
    }

    public async Task ProcessPayroll(int employeeId)
    {
        var employee = await _repo.GetByIdAsync(employeeId);
        var pay = _calculator.Calculate(employee);
        await _processor.ProcessPaymentAsync(employee, pay);
    }
}

// ISP: Segregated reporting interfaces
public interface IBasicReport { string Generate(); }
public interface IDetailedReport { string GenerateDetails(); }
public interface IExportableReport { Task ExportAsync(string path); }

// DIP: All dependencies injected through constructors
// Entire system is testable and flexible

Common Mistakes

  1. SRP taken too far: Over-splitting creates many tiny classes. Find the right granularity for your context.

  2. OCP with premature abstraction: Do not abstract for hypothetical future requirements. Apply OCP when you actually need to extend.

  3. LSP violations with inheritance hierarchies: Favor Composition Over Inheritance. Use interfaces instead of base classes when possible.

  4. ISP ignored in Api Design: Large interfaces create coupling. Design small, focused interfaces that represent specific capabilities.

  5. DIP without DI container: DIP requires constructor injection. Using new inside classes creates hard dependencies.

Practice Questions

  1. Refactor a class that handles both database operations and email notifications to follow SRP.

  2. Design a plugin system using OCP where new file format handlers can be added without modifying existing code.

  3. Identify and fix LSP violations in a class hierarchy of Bird, FlyingBird, and Penguin.

  4. Challenge: Build a complete SOLID-compliant order processing pipeline with validation, pricing, inventory, payment, and notification.

FAQ

Do I have to follow all SOLID principles all the time?

No. SOLID is a guideline, not a rule. Apply principles pragmatically based on project complexity and team context.

What is the most important SOLID principle?

Dependency Inversion (DIP) has the largest impact on architecture. It enables loose coupling and testability throughout the system.

How do SOLID principles relate to design patterns?

SOLID principles are the foundation. Design patterns are specific implementations that often follow SOLID. For example, Strategy pattern follows OCP.

Can SOLID be applied to functional code?

Yes, the concepts translate. SRP applies to functions. OCP applies to function composition. DIP applies to higher-order functions.

How do I teach my team SOLID?

Start with code reviews focusing on one principle at a time. Use real examples from your codebase to demonstrate violations and improvements.

Mini Project: SOLID Payroll System

Build a complete payroll processing system that adheres to SOLID.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

// SRP: Separate concerns
public record Employee(int Id, string Name, decimal AnnualSalary,
    decimal HoursWorked, decimal HourlyRate, EmployeeType Type);

public enum EmployeeType { Salary, Hourly }

// OCP: Open for extension
public interface IPayStrategy
{
    decimal Calculate(Employee employee);
}

public class SalaryPayStrategy : IPayStrategy
{
    public decimal Calculate(Employee e) => Math.Round(e.AnnualSalary / 12, 2);
}

public class HourlyPayStrategy : IPayStrategy
{
    public decimal Calculate(Employee e) =>
        Math.Round(e.HoursWorked * e.HourlyRate, 2);
}

public class PayStrategyFactory
{
    private static readonly Dictionary<EmployeeType, IPayStrategy> Strategies = new()
    {
        [EmployeeType.Salary] = new SalaryPayStrategy(),
        [EmployeeType.Hourly] = new HourlyPayStrategy()
    };

    public IPayStrategy GetStrategy(EmployeeType type) =>
        Strategies.TryGetValue(type, out var strategy)
            ? strategy
            : throw new ArgumentException($"Unknown type: {type}");
}

// ISP: Segregated interfaces
public interface IEmployeeRepository
{
    Task<List<Employee>> GetAllAsync();
}

public interface IPaymentRepository
{
    Task SavePaymentAsync(int employeeId, decimal amount, DateTime date);
}

public interface INotificationService
{
    Task NotifyAsync(string recipient, string message);
}

// DIP: Depend on abstractions
public class PayrollProcessor
{
    private readonly IEmployeeRepository _repo;
    private readonly IPaymentRepository _payments;
    private readonly INotificationService _notifications;
    private readonly PayStrategyFactory _strategyFactory;

    public PayrollProcessor(
        IEmployeeRepository repo,
        IPaymentRepository payments,
        INotificationService notifications,
        PayStrategyFactory strategyFactory)
    {
        _repo = repo;
        _payments = payments;
        _notifications = notifications;
        _strategyFactory = strategyFactory;
    }

    public async Task ProcessMonthlyPayroll()
    {
        var employees = await _repo.GetAllAsync();

        foreach (var employee in employees)
        {
            var strategy = _strategyFactory.GetStrategy(employee.Type);
            var pay = strategy.Calculate(employee);

            await _payments.SavePaymentAsync(employee.Id, pay, DateTime.UtcNow);
            await _notifications.NotifyAsync(employee.Name,
                $"Your monthly pay of ${pay} has been processed.");
        }
    }
}

// Concrete implementations
public class InMemoryEmployeeRepo : IEmployeeRepository
{
    public Task<List<Employee>> GetAllAsync() => Task.FromResult(new List<Employee>
    {
        new(1, "Alice", 120000, 0, 0, EmployeeType.Salary),
        new(2, "Bob", 0, 80, 50, EmployeeType.Hourly)
    });
}

public class ConsolePaymentRepo : IPaymentRepository
{
    public Task SavePaymentAsync(int id, decimal amount, DateTime date)
    {
        Console.WriteLine($"Saved payment: Employee {id}, ${amount}, {date:d}");
        return Task.CompletedTask;
    }
}

public class ConsoleNotification : INotificationService
{
    public Task NotifyAsync(string recipient, string message)
    {
        Console.WriteLine($"NOTIFY {recipient}: {message}");
        return Task.CompletedTask;
    }
}

// Usage
var processor = new PayrollProcessor(
    new InMemoryEmployeeRepo(),
    new ConsolePaymentRepo(),
    new ConsoleNotification(),
    new PayStrategyFactory());

await processor.ProcessMonthlyPayroll();

Output:

Saved payment: Employee 1, $10000.00, 6/28/2026
NOTIFY Alice: Your monthly pay of $10000.00 has been processed.
Saved payment: Employee 2, $4000.00, 6/28/2026
NOTIFY Bob: Your monthly pay of $4000.00 has been processed.

SOLID principles transform C# code from fragile, tightly-coupled systems into flexible, maintainable architectures. Combined with .NET features like dependency injection and interfaces, these five principles will guide you in building professional-grade software.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro