You are currently viewing SOLID Principles in C#: A Beginner-Friendly Guide with Modern .NET Examples

SOLID Principles in C#: A Beginner-Friendly Guide with Modern .NET Examples

SOLID principles in C# are five design rules that help you write code that is easy to change, grow, and test. Whether you are building a small console app or a large system with .NET 10, these rules will change how you think about your code. In this guide, you will learn what each rule means, see hands-on code using modern C#, and find out how SOLID ties into clean design.

Table of Contents

What Are SOLID Principles?

Robert C. Martin came up with these five rules in the early 2000s. Since then, they have been a key part of good object-oriented code. In fact, the name SOLID is short for five ideas: Single Task, Open/Closed, Liskov Swap, Interface Split, and Dependency Flip. Each one solves a real problem you will face as your code base grows.

Think of SOLID as guard rails on a road. They don’t tell you where to drive. Instead, they keep you from going off the edge. As a result, your code stays clean and easy to work with. Microsoft’s design rules for .NET apps share many of these same ideas.

For C# devs on modern .NET, these rules matter a lot. For example, the built-in DI system, interface patterns, and records all push you toward SOLID code. In fact, you may already use some of these rules and not know it. So let’s look at each one with real code.

Single Responsibility Principle (SRP)

This rule says a class should have just one reason to change. In other words, each class should do one thing and do it well. Consequently, this is the most natural of all the SOLID principles in C#. Yet it is the one devs break the most.

Why SRP Matters

When a class does too many things, a change to one part can break the other. For example, picture a class that handles orders and sends emails. If you tweak the email layout, you might break the order logic. On top of that, testing gets harder. You can’t test one job on its own when two jobs share the same class.

A Hands-On Example

For example, look at this class that breaks SRP. It does order math and saves data:

// Breaks SRP: two reasons to change
public class OrderService
{
    public decimal CalculateTotal(Order order)
    {
        decimal total = 0;
        foreach (var item in order.Items)
        {
            total += item.Price * item.Quantity;
        }
        return total;
    }

    public void SaveToDatabase(Order order)
    {
        using var context = new AppDbContext();
        context.Orders.Add(order);
        context.SaveChanges();
    }
}

Now let’s split this into two small classes, each with one job:

// Follows SRP: each class has one job
public class OrderCalculator
{
    public decimal CalculateTotal(Order order)
    {
        return order.Items.Sum(item => item.Price * item.Quantity);
    }
}

public class OrderRepository
{
    private readonly AppDbContext _context;

    public OrderRepository(AppDbContext context)
    {
        _context = context;
    }

    public void Save(Order order)
    {
        _context.Orders.Add(order);
        _context.SaveChanges();
    }
}

After this split, OrderCalculator only changes when math logic changes. Similarly, OrderRepository only changes when save logic changes. As a result, each class is focused and easy to test. If you are using new C# 14 features, you can likewise make these classes even shorter with body members and primary constructors (added in C# 12).

Open/Closed Principle (OCP)

This rule says your code should be open for new features but closed for edits. In short, you should be able to add new things to your app without changing code that already works. Indeed, this rule is the base of plugin designs and the strategy pattern.

Why OCP Matters

Each time you edit working code to add new needs, you risk bugs. Furthermore, if many features rely on the same class, one small edit can cause a chain of breaks. However, the Open/Closed rule guards against this. Instead, it pushes you to add new classes rather than edit old ones.

A Hands-On Example

Say you need to work out discounts for different buyer types. Here is a way that breaks OCP:

// Breaks OCP: must edit this method for every new buyer type
public class DiscountCalculator
{
    public decimal GetDiscount(string customerType, decimal orderTotal)
    {
        if (customerType == "Regular")
            return orderTotal * 0.05m;
        else if (customerType == "Premium")
            return orderTotal * 0.10m;
        else if (customerType == "VIP")
            return orderTotal * 0.20m;

        return 0;
    }
}

Adding a new buyer type means you must edit GetDiscount. Instead, let’s use an interface so we can follow OCP:

// Follows OCP: add new classes, don't edit old ones
public interface IDiscountStrategy
{
    decimal CalculateDiscount(decimal orderTotal);
}

public class RegularDiscount : IDiscountStrategy
{
    public decimal CalculateDiscount(decimal orderTotal) => orderTotal * 0.05m;
}

public class PremiumDiscount : IDiscountStrategy
{
    public decimal CalculateDiscount(decimal orderTotal) => orderTotal * 0.10m;
}

public class VipDiscount : IDiscountStrategy
{
    public decimal CalculateDiscount(decimal orderTotal) => orderTotal * 0.20m;
}

public class DiscountCalculator
{
    private readonly IDiscountStrategy _strategy;

    public DiscountCalculator(IDiscountStrategy strategy)
    {
        _strategy = strategy;
    }

    public decimal GetDiscount(decimal orderTotal) => _strategy.CalculateDiscount(orderTotal);
}

Now, when you need a new buyer type, you just make a new class that uses IDiscountStrategy. As a result, the old DiscountCalculator stays the same. Moreover, this works great with .NET’s DI system, which can pick the right class at runtime.

Liskov Substitution Principle (LSP)

Barbara Liskov, a well-known computer scientist, gave this rule its name. It says that a child class should be able to stand in for its parent class. The app must still work the right way. In plain terms, if your code works with a base type, it must also work with any child of that type.

Why LSP Matters

When you break LSP, you get runtime bugs. Your code might build and pass basic tests. However, it could fail in the real world when a child class acts in a way the parent would not. As a result, devs stop trusting the type system. They then add if-checks all over, which makes a mess of the code.

The Classic Rectangle Problem

The go-to LSP example is a Square that extends Rectangle:

// Breaks LSP: Square changes how Rectangle works
public class Rectangle
{
    public virtual int Width { get; set; }
    public virtual int Height { get; set; }

    public int CalculateArea() => Width * Height;
}

public class Square : Rectangle
{
    public override int Width
    {
        set { base.Width = value; base.Height = value; }
    }

    public override int Height
    {
        set { base.Width = value; base.Height = value; }
    }
}

This breaks LSP. For instance, setting the width of a Square also sets the height. That is not how a Rectangle works. Therefore, code that expects width and height to be free of each other will get wrong results.

A better path is to use a shared interface:

// Follows LSP: each shape finds its own area
public interface IShape
{
    int CalculateArea();
}

public record Rectangle(int Width, int Height) : IShape
{
    public int CalculateArea() => Width * Height;
}

public record Square(int Side) : IShape
{
    public int CalculateArea() => Side * Side;
}

See how we use C# records here. Records can’t be changed once made, which stops the kind of setter-based LSP bugs shown above. On top of that, the IShape interface means any shape can find its area without knowing about the other shape’s state.

Interface Segregation Principle (ISP)

This rule says no class should be forced to use methods it does not need. Rather than making one big, fat interface, you should split it into small, focused ones. This rule is close to SRP, but it deals with how you design your interfaces.

Why ISP Matters

Big interfaces glue things together that don’t belong. For example, picture a class that has ten methods but only needs three. Those seven extra methods just sit there and add weight. Furthermore, a change to one of the extra methods forces a rebuild. The class does not even care about that method. As a result, it wastes time and can cause bugs.

A Hands-On Example

Here is a bloated interface that breaks ISP:

// Breaks ISP: forces classes to add methods they don't need
public interface IWorker
{
    void Work();
    void Eat();
    void Sleep();
    void AttendMeeting();
}

public class Robot : IWorker
{
    public void Work() { /* robot works */ }
    public void Eat() => throw new NotSupportedException();
    public void Sleep() => throw new NotSupportedException();
    public void AttendMeeting() => throw new NotSupportedException();
}

The Robot class must add Eat and Sleep methods that make no sense for a robot. Throwing errors like this is a code smell. It almost always points to an ISP problem. Let’s fix it:

// Follows ISP: small, focused interfaces
public interface IWorkable
{
    void Work();
}

public interface IFeedable
{
    void Eat();
}

public interface IRestable
{
    void Sleep();
}

public class HumanWorker : IWorkable, IFeedable, IRestable
{
    public void Work() { /* human works */ }
    public void Eat() { /* human eats */ }
    public void Sleep() { /* human sleeps */ }
}

public class Robot : IWorkable
{
    public void Work() { /* robot works */ }
}

Now each class only uses the interfaces that fit. Robot only cares about IWorkable. The HumanWorker uses all three. This is also more flexible since you can mix and match as needed. Take a look at static abstract interface members in C# 11 to see how modern C# takes this even further.

Dependency Inversion Principle (DIP)

This is the last piece of the SOLID puzzle. Indeed, it is perhaps the most useful for modern .NET work. It says two things. First, high-level code should not depend on low-level code. Instead, both should depend on contracts. Second, contracts should not depend on details. Likewise, details should depend on contracts.

Why DIP Matters

Without DIP, your core logic gets glued to things like databases, file systems, and web APIs. As a result, your code becomes hard to test. You can’t swap in fakes or mocks. Furthermore, if you change your database, you’d have to rewrite your core logic. That consequently kills the whole point of having layers.

A Hands-On Example

Here is a tightly bound alert service that breaks DIP:

// Breaks DIP: high-level class depends on low-level class
public class EmailSender
{
    public void SendEmail(string to, string message)
    {
        // SMTP logic here
    }
}

public class NotificationService
{
    private readonly EmailSender _emailSender = new();

    public void NotifyUser(string userId, string message)
    {
        var email = GetUserEmail(userId);
        _emailSender.SendEmail(email, message);
    }

    private string GetUserEmail(string userId) => "user@example.com";
}

Now let’s apply DIP by adding a contract between them:

// Follows DIP: both sides depend on the contract
public interface IMessageSender
{
    Task SendAsync(string to, string message);
}

public class EmailSender : IMessageSender
{
    public async Task SendAsync(string to, string message)
    {
        // SMTP logic here
        await Task.CompletedTask;
    }
}

public class SmsSender : IMessageSender
{
    public async Task SendAsync(string to, string message)
    {
        // SMS API logic here
        await Task.CompletedTask;
    }
}

public class NotificationService
{
    private readonly IMessageSender _sender;

    public NotificationService(IMessageSender sender)
    {
        _sender = sender;
    }

    public async Task NotifyUserAsync(string userId, string message)
    {
        var contact = GetUserContact(userId);
        await _sender.SendAsync(contact, message);
    }

    private string GetUserContact(string userId) => "user@example.com";
}

NotificationService no longer cares if it sends emails, texts, or push alerts. It only knows about the IMessageSender contract. You can set up the right class in .NET’s DI system and swap it without touching your core code.

How SOLID Feeds into Clean Architecture

Clean architecture, made famous by Robert C. Martin, splits code into rings. Your core models sit in the middle. Use cases wrap around them. Then come adapters, and last the outer frameworks. The key rule is simple: things point inward. Outer layers know about inner ones, but not the other way around.

Each SOLID rule maps to a clean design goal. SRP keeps your core classes focused. OCP lets you add new use cases without changing old rules. LSP makes sure your contracts work the same across all classes. ISP keeps those contracts lean. And DIP is the backbone of the whole thing. It lets inner layers stay blind to outer layer details. The original clean architecture post by Robert C. Martin goes deeper into these layer lines.

In a typical ASP.NET Core app, this might look like this folder layout:

// Domain layer (core): pure logic, no outside needs
MyApp.Domain/
    Entities/
    Interfaces/       // IOrderRepository, IMessageSender
    Services/         // OrderCalculator (SRP)

// Application layer: use cases and workflow
MyApp.Application/
    UseCases/
    DTOs/

// Infrastructure layer (outer): real-world hookups
MyApp.Infrastructure/
    Repositories/     // OrderRepository implements IOrderRepository (DIP)
    Services/         // EmailSender implements IMessageSender (DIP)
    Data/

Your domain layer sets up contracts (DIP). Meanwhile, your outer layer builds the real classes. The middle layer then ties it all together. As a result, it’s easy to swap your database, change your email tool, or add new alert channels. None of that touches your core logic. For example, if you build APIs with .NET, things like rate limiting can go in the outer layer without changing the core. That’s OCP at work.

SOLID and Dependency Injection in .NET

Dependency injection (DI) is not a SOLID rule by itself. But it is the tool that makes DIP work in .NET. The built-in DI system in ASP.NET Core wires up your classes at runtime. Your code never has to build its own needs.

Here is how you’d set up the alert example from before:

// In Program.cs (minimal API style)
var builder = WebApplication.CreateBuilder(args);

// Register the contract with its real class
builder.Services.AddScoped<IMessageSender, EmailSender>();
builder.Services.AddScoped<NotificationService>();

var app = builder.Build();

app.MapPost("/notify", async (NotificationService service) =>
{
    await service.NotifyUserAsync("user123", "Your order has shipped!");
    return Results.Ok();
});

app.Run();

With one line of code, you can switch from EmailSender to SmsSender across your whole app. That is the power of DIP plus .NET’s DI system. Moreover, you can also register more than one class and use patterns like strategy or factory to pick the right one.

Testing gets much easier too. In your unit tests, you can plug in a mock:

// Unit test with a mock
public class NotificationServiceTests
{
    [Fact]
    public async Task NotifyUserAsync_CallsSender_WithCorrectMessage()
    {
        var mockSender = new Mock<IMessageSender>();
        var service = new NotificationService(mockSender.Object);

        await service.NotifyUserAsync("user123", "Hello!");

        mockSender.Verify(s => s.SendAsync(It.IsAny<string>(), "Hello!"), Times.Once);
    }
}

Without DIP, this kind of testing would not be possible. Instead, you’d need real email servers in your tests. In practice, most .NET devs see DI as a must-have alongside SOLID. The official .NET DI docs cover the DI system in detail. Similarly, if you have been looking at required properties in C# 11, you’ll see how they make sure all needs are met when a class is built.

Common Mistakes When Applying SOLID

SOLID rules are very useful, but you can take them too far. Here are some common traps to watch for.

Too Many Layers

Making an interface for every class is not what SOLID means. For instance, if a class has just one version and likely always will, wrapping it adds weight for no gain. Instead, use contracts where they bring real value. That means places where you need test doubles or more than one class.

Thinking SRP Means “One Method Per Class”

SRP does not mean a class should have just one method. It means a class should have one reason to change. A UserValidator class might have methods like ValidateEmail, ValidatePassword, and ValidateAge. All of these are about one job: checking user data. So they belong in the same class.

Skipping the Real World

SOLID rules are guides, not laws. For example, in a small proof of concept, strict use of SOLID can slow you down for little gain. Instead, use your own judgment to decide when clean design pays off. As a rule of thumb, the bigger and longer-lived the code base, the more SOLID helps.

Using DIP Without DI

Adding contracts without a DI system means you still make real classes by hand somewhere. That “somewhere” becomes a pain point. In .NET, always pair DIP with the built-in DI system. You can also use a third-party tool like Autofac for more control.

If you liked this guide, these posts might help too:

Over to You

SOLID principles in C# are the kind of thing you learn once and use for life. They form the base of clean design, test-friendly code, and systems that age well. The key is to use them with care. Start with the rule that fixes your biggest pain. That might be messy classes (SRP), brittle child types (LSP), or tightly bound code (DIP). Then refactor from there.

Which SOLID rule do you find hardest to use in your own projects? Do you ever skip a SOLID guide on purpose for speed? I’d love to hear your take in the comments below.

Dirk Strauss

As a seasoned software developer with a long-standing career in C# and Visual Studio, I have had the privilege of working with a number of companies and learning from some of the most talented individuals in the industry. In addition to my professional experience, I have authored multiple books on topics such as C#, Visual Studio, and ASP.NET Core. My passion for programming is unwavering, and I am dedicated to staying current with the latest technology and sharing my expertise with others.