Five principles for code that is easy to change and to test.
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); |