SOLID principles in C#: SRP, Open/Closed, Liskov, ISP and DIP with .NET examples

.NET Architecture & Design Patterns November 13, 2026 csharp

Five principles for code that is easy to change and to test.

csharp
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
// ======================================================================
// Q: Explain SOLID with C# examples.
//
// Five principles for code that is easy to change and to test. For each one: a
// bad example (✗) and the fix (✓).
// S: Single Responsibility. A class has one reason to change (one "actor"
// requesting changes).
// O: Open/Closed. Open for extension, closed for modification. New behavior = a
// new class, not a change to working code.
// ASP.NET Core itself is OCP: you add middleware, filters and health checks
// without changing the framework.
// L: Liskov Substitution. A subtype must be usable in place of its base type
// without breaking its promises: no stricter preconditions, no weaker
// postconditions, no unexpected exceptions.
// An example from .NET itself: ReadOnlyCollection<T> implements ICollection<T>
// but Add throws NotSupportedException (which is why IsReadOnly exists). The
// classic interview example is Square : Rectangle.
// I: Interface Segregation. Small, focused interfaces. A client does not depend
// on methods it doesn't use.
// D: Dependency Inversion. High-level modules (business logic) do not depend on
// low-level details (HTTP, SQL, the clock). Both depend on abstractions, and the
// abstraction is defined by the high-level code.
// DIP is not DI: Dependency Inversion is the principle (the direction of
// dependencies). Dependency Injection is the technique (you receive them through
// the constructor). The DI container is just a tool for the technique.
// Don't make it dogma. An interface for every class with a single implementation
// is over-engineering. Add abstractions where there is a real point of change or
// an external system (ERP, email, time). In the demo project:
// Application/Shipping (OCP), Application/Abstractions (DIP, ISP), handlers
// (SRP).
// ======================================================================

// S: Single Responsibility
// ✗ validation, persistence, PDF and email in one class: 4 reasons to change
public class OrderService(AppDbContext db, SmtpClient smtp)
{
    public async Task PlaceAsync(Order order)
    {
        if (order.Lines.Count == 0) throw new Exception("Empty order");
        db.Orders.Add(order);
        await db.SaveChangesAsync();
        var pdf = BuildInvoicePdf(order);           // changes when accounting changes
        await smtp.SendMailAsync(BuildMail(order, pdf)); // changes when marketing changes
    }
}

// ✓ the handler orchestrates, the rules live in the aggregate, side effects in event handlers
public class PlaceOrderHandler(IOrderRepository orders, IUnitOfWork uow)
{
    public async Task Handle(PlaceOrder cmd, CancellationToken ct)
    {
        var order = await orders.GetAsync(cmd.OrderId, ct) ?? throw new NotFoundException();
        order.Place();                    // rules + OrderPlaced domain event
        await uow.SaveChangesAsync(ct);   // → SendConfirmationEmail, CreateInvoicePdf handlers
    }
}

// O: Open/Closed (Strategy)
// ✗ new courier = change here, re-test all couriers
public decimal Quote(string carrier, decimal kg) => carrier switch
{
    "dhl" => 5.90m + 1.20m * kg,
    "ups" => 7.50m + 0.90m * kg,
    _     => throw new NotSupportedException(carrier)
};

// ✓ Strategy: one class per courier, the code that uses them never changes
public interface IShippingCalculator
{
    string Carrier { get; }
    Money Calculate(Shipment s);
}
public class DhlCalculator : IShippingCalculator { /* ... */ }
public class UpsCalculator : IShippingCalculator { /* ... */ }

builder.Services.AddKeyedSingleton<IShippingCalculator, DhlCalculator>("dhl");
builder.Services.AddKeyedSingleton<IShippingCalculator, UpsCalculator>("ups");
// new courier: one new class + one registration line

public class ShippingService(IServiceProvider sp)
{
    public Money Quote(Shipment s) =>
        sp.GetRequiredKeyedService<IShippingCalculator>(s.Carrier).Calculate(s);
}

// L: Liskov Substitution
// ✗ the subclass "disables" a parent method
public class FileStorage
{
    public virtual Task<Stream> ReadAsync(string path) { /* ... */ }
    public virtual Task SaveAsync(string path, Stream data) { /* ... */ }
}
public class ArchiveStorage : FileStorage
{
    public override Task SaveAsync(string path, Stream data) =>
        throw new NotSupportedException();   // anyone expecting a FileStorage blows up
}

// ✓ the hierarchy follows the real capabilities
public interface IFileReader { Task<Stream> ReadAsync(string path); }
public interface IFileWriter { Task SaveAsync(string path, Stream data); }
public class BlobStorage : IFileReader, IFileWriter { /* ... */ }
public class ArchiveStorage : IFileReader { /* ... */ }

// I: Interface Segregation
// ✗ "fat" interface: the catalog page also depends on import and pricing
public interface IProductService
{
    Task<ProductDto?> GetAsync(string sku);
    Task<IReadOnlyList<ProductDto>> SearchAsync(string text);
    Task UpdatePriceAsync(string sku, decimal price);
    Task ImportFromPimAsync(Stream file);
    Task DeleteAsync(string sku);
    // ... 15 more. Every fake in tests must implement all of them
}

// ✓ roles: each consumer asks only for what it needs
public interface IProductReader { Task<ProductDto?> GetAsync(string sku); /* Search */ }
public interface IPriceUpdater  { Task UpdatePriceAsync(string sku, decimal price); }
public interface IPimImporter   { Task ImportAsync(Stream file); }

// one class can implement several
public class ProductService : IProductReader, IPriceUpdater { /* ... */ }

// D: Dependency Inversion
// ✗ hard-coded dependencies: no unit tests, cannot swap the ERP
public class OrderSync
{
    public async Task SyncAsync(Order order)
    {
        var client = new SapRfcClient("sap-prod:3300");          // concrete
        if (DateTime.Now.Hour < 6) return;                      // hidden dependency: the clock
        await client.CreateSalesOrderAsync(order);
    }
}

// ✓ Application layer: defines what it needs
public interface IErpClient { Task<string> SendOrderAsync(Order order, CancellationToken ct); }

public class OrderSync(IErpClient erp, TimeProvider clock)
{
    public async Task SyncAsync(Order order, CancellationToken ct)
    {
        if (clock.GetLocalNow().Hour < 6) return;     // in tests: FakeTimeProvider
        await erp.SendOrderAsync(order, ct);
    }
}

// Infrastructure layer: the implementation. DI wires them together in Program.cs
public class SapErpClient(HttpClient http) : IErpClient { /* ... */ }
builder.Services.AddHttpClient<IErpClient, SapErpClient>();
builder.Services.AddSingleton(TimeProvider.System);