30% OFF - Use code HANDSON30 for 30% off any course or Dometrain Pro! Browse courses →
  • Courses
  • Learning Paths
  • Blogs
  • Authors
  • Leaderboard
  • Dometrain Pro
  • Shopping Basket

    Your basket is empty

  • Business Portal
  • Introduction to SOLID Principles in C#

    August 11, 2025
    Listen to this blog 3m 46s
    Sign in to unlock the audio version of this article
    Sign in to listen

    You’ve seen SOLID mentioned in talks and code reviews. This post shows what each letter looks like in real C# and when it helps. No fluff, just small examples you can paste into a project.


    S — Single Responsibility Principle (SRP)

    One reason to change. A type should own one slice of behavior. When a class mixes I/O, validation, and orchestration, every change touches it and tests get noisy.

    Smell: a class that sends emails, saves to the database, and logs.

    // ❌ Violates SRP
    public sealed class OrderService
    {
        public void PlaceOrder(Order order)
        {
            // Validate
            if (order.Lines.Count == 0) 
                throw new InvalidOperationException("Empty order");
    
            // Save
            using var db = new SqlConnection("...");
            db.Execute("INSERT ...", order);
        }
    }
    

    Better: split responsibilities. Orchestration stays in OrderService, persistence and email move to small, focused types.

    public interface IOrderRepository
    {
        Task SaveAsync(Order order, CancellationToken ct = default);
    }
    
    public interface IEmailSender
    {
        Task SendAsync(string to, string subject, string body, CancellationToken ct = default);
    }
    
    public sealed class OrderService(IOrderRepository repo, IEmailSender email)
    {
        public async Task PlaceAsync(Order order, CancellationToken ct = default)
        {
            if (order.Lines.Count == 0) 
                throw new InvalidOperationException("Empty order");
    
            await repo.SaveAsync(order, ct);
            await email.SendAsync(order.CustomerEmail, "Thanks", "We got your order.", ct);
        }
    }
    

    Now each concern changes in isolation and testing is straightforward.


    O — Open/Closed Principle (OCP)

    Open for extension, closed for modification. When a rule changes, you add a new type rather than edit a big switch/if chain.

    Smell: a calculator that branches on type with if/else across the codebase.

    // ❌ Violates OCP
    public sealed class ShippingCost
    {
        public decimal Calculate(string countryCode, decimal weightKg) => countryCode switch
        {
            "SE" => 80m + weightKg * 5m,
            "DE" => 60m + weightKg * 4m,
            "US" => 120m + weightKg * 7m,
            _ => throw new NotSupportedException(countryCode),
        };
    }
    

    Better: a small strategy interface, new rules are new classes.

    public interface IShippingRule
    {
        bool Matches(string countryCode);
        decimal Cost(decimal weightKg);
    }
    
    public sealed class SwedenRule : IShippingRule
    {
        public bool Matches(string c) => c == "SE";
        public decimal Cost(decimal w) => 80m + w * 5m;
    }
    
    public sealed class GermanyRule : IShippingRule
    {
        public bool Matches(string c) => c == "DE";
        public decimal Cost(decimal w) => 60m + w * 4m;
    }
    
    public sealed class ShippingCalculator(IEnumerable<IShippingRule> rules)
    {
        public decimal Calculate(string countryCode, decimal weightKg)
        {
            var rule = rules.FirstOrDefault(r => r.Matches(countryCode))
                       ?? throw new NotSupportedException(countryCode);
            return rule.Cost(weightKg);
        }
    }
    

    Wire new countries by adding classes, not editing ShippingCalculator.

    var builder = WebApplication.CreateBuilder(args);
    builder.Services.AddScoped<IShippingRule, SwedenRule>();
    builder.Services.AddScoped<IShippingRule, GermanyRule>();
    builder.Services.AddScoped<ShippingCalculator>();
    

    L — Liskov Substitution Principle (LSP)

    Subtypes must honor the contract of their base. If a base says “you can call this method,” a subtype shouldn’t throw or no-op.

    Smell: deriving from a type just to reuse a few members, then throwing in methods you can’t support.

    // ❌ Violates LSP
    public class FileStorage
    {
        public virtual Task WriteAsync(string path, byte[] data) => File.WriteAllBytesAsync(path, data);
        public virtual Task<byte[]> ReadAsync(string path) => File.ReadAllBytesAsync(path);
    }
    
    public sealed class ReadOnlyStorage : FileStorage
    {
        public override Task WriteAsync(string path, byte[] data) =>
            throw new NotSupportedException("Read-only storage");
    }
    

    Any caller using FileStorage can validly call WriteAsync. Substituting ReadOnlyStorage breaks that promise.

    Better: split capabilities and implement only what you can fulfill.

    public interface IReadableStorage
    {
        Task<byte[]> ReadAsync(string path);
    }
    
    public interface IWritableStorage : IReadableStorage
    {
        Task WriteAsync(string path, byte[] data);
    }
    
    public sealed class LocalStorage : IWritableStorage
    {
        public Task<byte[]> ReadAsync(string path) => File.ReadAllBytesAsync(path);
        public Task WriteAsync(string path, byte[] data) => File.WriteAllBytesAsync(path, data);
    }
    
    public sealed class ArchiveStorage : IReadableStorage // read-only
    {
        public Task<byte[]> ReadAsync(string path) => File.ReadAllBytesAsync(path);
    }
    

    Callers choose the right abstraction up front and never hit a surprise NotSupportedException.


    I — Interface Segregation Principle (ISP)

    Small, focused interfaces. Clients shouldn’t depend on methods they don’t use.

    Smell: “God interfaces” that force implementers to stub or throw.

    // ❌ Violates ISP
    public interface IOrderProcessor
    {
        Task PlaceAsync(Order order);
        Task CancelAsync(Guid orderId);
        Task RefundAsync(Guid orderId);
        Task PrintPickingListAsync(Guid orderId);
    }
    
    public sealed class ThirdPartyFulfillment : IOrderProcessor
    {
        public Task PlaceAsync(Order o) => /* call API */ Task.CompletedTask;
        public Task CancelAsync(Guid id) => /* call API */ Task.CompletedTask;
        public Task RefundAsync(Guid id) => /* not supported */ throw new NotSupportedException();
        public Task PrintPickingListAsync(Guid id) => /* irrelevant */ Task.CompletedTask;
    }
    

    Better: split by use-case and depend on the minimum you need.

    public interface IOrderPlacement
    {
        Task PlaceAsync(Order order);
    }
    
    public interface IOrderCancellation
    {
        Task CancelAsync(Guid orderId);
    }
    
    public interface IOrderRefunds
    {
        Task RefundAsync(Guid orderId);
    }
    
    public sealed class ThirdPartyPlacement : IOrderPlacement, IOrderCancellation
    {
        public Task PlaceAsync(Order o) => Task.CompletedTask;
        public Task CancelAsync(Guid id) => Task.CompletedTask;
    }
    

    Consumers don’t see methods they’ll never call. Implementers don’t need fake bodies.


    D — Dependency Inversion Principle (DIP)

    High-level code depends on abstractions, not concretes. This keeps orchestration free from storage, transport, or library choices.

    Smell: a service new’s up its own dependencies, making tests and changes hard.

    // ❌ Violates DIP
    public sealed class InvoiceService
    {
        public Task<Invoice> GenerateAsync(Guid orderId)
        {
            var repo = new SqlOrderRepository("Server=..."); // hard dependency
            var order = repo.Get(orderId);
            // ...
            return Task.FromResult(new Invoice(orderId, DateTimeOffset.UtcNow));
        }
    }
    

    Better: depend on interfaces and inject implementations via the container.

    public interface IOrderReader
    {
        Task<Order> GetAsync(Guid id, CancellationToken ct = default);
    }
    
    public interface ITimeProvider
    {
        DateTimeOffset Now();
    }
    
    public sealed class SystemTime : ITimeProvider
    {
        public DateTimeOffset Now() => DateTimeOffset.UtcNow;
    }
    
    public sealed class InvoiceService(IOrderReader orders, ITimeProvider clock)
    {
        public async Task<Invoice> GenerateAsync(Guid orderId, CancellationToken ct = default)
        {
            var order = await orders.GetAsync(orderId, ct);
            return new Invoice(order.Id, clock.Now());
        }
    }
    

    Register with the built-in DI and keep your endpoints thin.

    var builder = WebApplication.CreateBuilder(args);
    
    builder.Services.AddScoped<IOrderReader, SqlOrderRepository>();
    builder.Services.AddSingleton<ITimeProvider, SystemTime>();
    builder.Services.AddScoped<InvoiceService>();
    
    var app = builder.Build();
    
    app.MapPost("/invoices/{orderId:guid}", async (Guid orderId, InvoiceService svc, CancellationToken ct) =>
    {
        var invoice = await svc.GenerateAsync(orderId, ct);
        return Results.Ok(invoice);
    });
    
    app.Run();
    

    Now tests can pass a fake IOrderReader and a fixed-time clock without booting a database or web host.


    How to spot drift from SOLID

    • A class keeps growing new fields and constructor parameters.
    • You stub or throw in derived classes or interface implementations.
    • Big switches on type or strings appear in multiple places.
    • You touch many files for one change.
    • Tests need real infrastructure to run.

    These are cues to apply one of the letters, not rules to follow blindly. Prefer small steps: extract an interface, move a method, split a file.


    Minimal fake clock (useful across SRP/DIP):

    public sealed class FakeTime(DateTimeOffset now) : ITimeProvider
    {
        public DateTimeOffset Value { get; set; } = now;
        public DateTimeOffset Now() => Value;
    }
    

    Repository example (read side):

    public sealed class SqlOrderRepository(IConfiguration cfg) : IOrderReader
    {
        private readonly string _cs = cfg.GetConnectionString("Default")
            ?? throw new InvalidOperationException("Missing connection string");
    
        public async Task<Order> GetAsync(Guid id, CancellationToken ct = default)
        {
            await using var con = new SqlConnection(_cs);
            // query with your library of choice
            return await Task.FromResult(new Order(id));
        }
    }
    

    Conclusion

    SOLID isn’t a checklist, it’s a set of small moves that reduce friction when you change code. Reach for it when you spot these signs:

    • You’re editing a class for unrelated reasons.
    • A “quick fix” needs changes across many files.
    • Adding a new rule means touching a switch in three places.
    • Tests are slow or brittle because a type does too much.

    Pick one seam that hurts today and make one improvement: split a responsibility, extract a strategy, trim an interface, or inject an abstraction. Ship the change. If the codebase moves faster next week, you’re on the right track.

    About the Author

    Nick Chapsas

    Nick Chapsas

    Nick Chapsas is a .NET & C# content creator, educator and a Microsoft MVP for Developer Technologies with years of experience in Software Engineering and Engineering Management.

    He has worked for some of the biggest companies in the world, building systems that served millions of users and tens of thousands of requests per second.

    Nick creates free content on YouTube and is the host of the Keep Coding Podcast.

    View all courses by Nick Chapsas

    What's New

    Getting Started: Model Context Protocol (MCP)
    course

    Getting Started: Model Context Protocol (MCP)

    Learn how to get started with the Model Context Protocol (MCP) and integrate it into your applications.

    Learn more about Getting Started: Model Context Protocol (MCP)
    Hands-On: Learn TypeScript
    course

    Hands-On: Learn TypeScript

    Learn TypeScript through hands-on coding exercises. Practice what you learn with interactive challenges designed for every level.

    Learn more about Hands-On: Learn TypeScript
    Hands-On: Learn JavaScript
    course

    Hands-On: Learn JavaScript

    Learn JavaScript through hands-on coding exercises. Practice what you learn with interactive challenges designed for every level.

    Learn more about Hands-On: Learn JavaScript
    Hands-On: Data Structures & Algorithms in C#
    course

    Hands-On: Data Structures & Algorithms in C#

    Master data structures and algorithms through hands-on coding exercises in C#. Free to enroll for 7 days!

    Learn more about Hands-On: Data Structures & Algorithms in C#
    Blogsmith.ai
    feature

    Blogsmith.ai

    Turn your videos into blogs and newsletters with AI. Check out our new product at blogsmith.ai.

    Learn more about Blogsmith.ai
    Leaderboard
    feature

    Leaderboard

    See how you stack up against other learners. Track your progress, climb the ranks, and compete with the Dometrain community.

    Learn more about Leaderboard
    Hands-On: Learn PostgreSQL
    course

    Hands-On: Learn PostgreSQL

    Learn PostgreSQL through hands-on coding exercises. Practice what you learn with interactive challenges designed for every level.

    Learn more about Hands-On: Learn PostgreSQL
    Free Hands-On: C# for Beginners
    course

    Free Hands-On: C# for Beginners

    Learn C# through hands-on coding exercises. Practice what you learn with interactive challenges designed for everyone, from beginners to experts.

    Learn more about Free Hands-On: C# for Beginners
    Getting Started: AI for .NET Developers
    course

    Getting Started: AI for .NET Developers

    Get started with integrating AI into your .NET applications effectively using the latest LLM best practices.

    Learn more about Getting Started: AI for .NET Developers
    Getting Started: Building .NET Applications on AWS
    course

    Getting Started: Building .NET Applications on AWS

    Learn how to build and deploy .NET applications on AWS using CDK, Lambda, DynamoDB, S3, and more.

    Learn more about Getting Started: Building .NET Applications on AWS
    What's new in C# 14
    blog

    What's new in C# 14

    This guide covers every new C# 14 feature, explains its benefits, and provides practical code examples to help you navigate how you can use them.

    Learn more about What's new in C# 14
    Let's Build It: AI Chatbot with RAG in .NET Using Your Data
    course

    Let's Build It: AI Chatbot with RAG in .NET Using Your Data

    Build a Retrieval-Augmented Generation (RAG) chatbot that can answer questions using your data.

    Learn more about Let's Build It: AI Chatbot with RAG in .NET Using Your Data
    From Zero to Hero: SignalR in .NET
    course

    From Zero to Hero: SignalR in .NET

    Enable enterprise-grade real-time communication for your web apps with SignalR.

    Learn more about From Zero to Hero: SignalR in .NET
    Deep Dive: Solution Architecture
    course

    Deep Dive: Solution Architecture

    Master solution architecture and turn business needs into scalable, maintainable systems.

    Learn more about Deep Dive: Solution Architecture
    Migrating: ASP.NET Web APIs to ASP.NET Core
    course

    Migrating: ASP.NET Web APIs to ASP.NET Core

    A step-by-step process to migrate ASP.NET Web APIs from .NET Framework to ASP.NET Core.

    Learn more about Migrating: ASP.NET Web APIs to ASP.NET Core
    Getting Started: Caching in .NET
    course

    Getting Started: Caching in .NET

    Let's make the hardest thing in programming easy for .NET software engineers.

    Learn more about Getting Started: Caching in .NET
    From Zero to Hero: Testing with xUnit in C#
    course

    From Zero to Hero: Testing with xUnit in C#

    Learn how to test any codebase in .NET with the latest version of xUnit, the industry-standard testing library.

    Learn more about From Zero to Hero: Testing with xUnit in C#
    Create a ChatGPT Console AI Chatbot in C#
    blog

    Create a ChatGPT Console AI Chatbot in C#

    This walkthrough is your hands-on entry point to create a basic C# console application that talks to ChatGPT using the OpenAI API.

    Learn more about Create a ChatGPT Console AI Chatbot in C#