Our Summer Sale is Live! 🎉
Everything 30% off with code SUMMER30! (Excl. Team and VS Pro)
00
Days
00
Hrs
00
Min
00
Sec
Get 30% off anything!

Introduction to SOLID Principles in C#

11/08/2025

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

author_img

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.

More courses by Nick Chapsas